summaryrefslogtreecommitdiff
path: root/crates/chat-server/src/app/cli.rs
diff options
context:
space:
mode:
authorDoan Luu Duc Tai <doan@luuduct.ai>2026-10-31 13:08:08 -0500
committerDoan Luu Duc Tai <doan@luuduct.ai>2026-10-31 13:08:08 -0500
commita80df869a92c330fbad9695057720b11a18af901 (patch)
tree5307272821cb3adc3f0348413a3b904290890044 /crates/chat-server/src/app/cli.rs
parent9139398d465f5899cc31bc4cdd54daa7392ca550 (diff)
Forbid Rust lints and enforce strict typed TypeScript linting
Diffstat (limited to 'crates/chat-server/src/app/cli.rs')
-rw-r--r--crates/chat-server/src/app/cli.rs167
1 files changed, 167 insertions, 0 deletions
diff --git a/crates/chat-server/src/app/cli.rs b/crates/chat-server/src/app/cli.rs
new file mode 100644
index 0000000..1296df4
--- /dev/null
+++ b/crates/chat-server/src/app/cli.rs
@@ -0,0 +1,167 @@
+// Copyright (C) 2026 Doan Luu Duc Tai <doan@luuduct.ai>
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as
+// published by the Free Software Foundation, either version 3 of the
+// License, or (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+use clap::{Arg, ArgAction, Command};
+use std::ffi::OsString;
+use std::net::SocketAddr;
+use std::path::PathBuf;
+
+#[derive(Debug)]
+
+pub(super) struct Arguments {
+ pub(super) root: PathBuf,
+
+ pub(super) listener: Listener,
+
+ pub(super) healthcheck: bool,
+
+ pub(super) init: bool,
+
+ pub(super) reset_owner: Option<String>,
+
+ pub(super) owner_fingerprint: bool,
+
+ pub(super) snapshot: Option<PathBuf>,
+}
+
+#[derive(Debug)]
+pub(super) struct Listener {
+ pub(super) bind: Option<SocketAddr>,
+
+ pub(super) proxy: bool,
+}
+
+fn flag(name: &'static str) -> Arg {
+ Arg::new(name).long(name).action(ArgAction::SetTrue)
+}
+
+fn path(name: &'static str) -> Arg {
+ Arg::new(name)
+ .long(name)
+ .value_parser(clap::builder::PathBufValueParser::new())
+}
+
+fn command() -> Command {
+ Command::new("stack-chat-server").about("Run private chat directly or behind the stack gateway")
+ .args([
+ path("root").default_value("."),
+ Arg::new("bind").long("bind").value_parser(str::parse::<SocketAddr>),
+ flag("proxy").help("Run behind the stack gateway using its explicit container environment"),
+ flag("healthcheck").help("Check the local HTTP health endpoint without opening the database"),
+ flag("init").conflicts_with_all(["reset-owner", "owner-fingerprint", "snapshot"]),
+ Arg::new("reset-owner").long("reset-owner").value_name("FINGERPRINT").conflicts_with_all(["owner-fingerprint", "snapshot"])
+ .help("Permanently clear the matching owner registration and all chat history after key loss"),
+ flag("owner-fingerprint").conflicts_with("snapshot").help("Print the registered owner's public fingerprint"),
+ path("snapshot"),
+ ])
+}
+
+impl Arguments {
+ pub(super) fn try_parse() -> Result<Self, clap::Error> {
+ Self::try_parse_from(std::env::args_os())
+ }
+
+ fn try_parse_from(
+ arguments: impl IntoIterator<Item = impl Into<OsString> + Clone>,
+ ) -> Result<Self, clap::Error> {
+ let mut values = command().try_get_matches_from(arguments)?;
+ let root = values.remove_one("root").ok_or_else(|| {
+ clap::Error::raw(
+ clap::error::ErrorKind::MissingRequiredArgument,
+ "Missing application root",
+ )
+ })?;
+ Ok(Self {
+ root,
+ listener: Listener {
+ bind: values.remove_one("bind"),
+ proxy: values.get_flag("proxy"),
+ },
+ healthcheck: values.get_flag("healthcheck"),
+ init: values.get_flag("init"),
+ reset_owner: values.remove_one("reset-owner"),
+ owner_fingerprint: values.get_flag("owner-fingerprint"),
+ snapshot: values.remove_one("snapshot"),
+ })
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::{Arguments, command};
+ use anyhow::{Result, ensure};
+ use std::path::Path;
+
+ #[test]
+ fn checked_command_schema() {
+ command().debug_assert();
+ }
+
+ #[test]
+ fn listener_defaults_and_maintenance_conflicts() -> Result<()> {
+ let defaults = Arguments::try_parse_from(["stack-chat-server"])?;
+ anyhow::ensure!((defaults.root) == (Path::new(".")), "Test invariant failed");
+ anyhow::ensure!(
+ !defaults.listener.proxy && defaults.listener.bind.is_none(),
+ "Test invariant failed"
+ );
+ let parsed = Arguments::try_parse_from([
+ "stack-chat-server",
+ "--root",
+ "/tmp/stack",
+ "--bind",
+ "127.0.0.1:9180",
+ "--proxy",
+ "--healthcheck",
+ ])?;
+ anyhow::ensure!(
+ (parsed.root) == (Path::new("/tmp/stack")),
+ "Test invariant failed"
+ );
+ ensure!(
+ parsed
+ .listener
+ .bind
+ .is_some_and(|address| address.port() == 9180),
+ "Listener port differs"
+ );
+ anyhow::ensure!(
+ parsed.listener.proxy && parsed.healthcheck,
+ "Test invariant failed"
+ );
+ for args in [
+ vec!["stack-chat-server", "--bind", "garbage"],
+ vec!["stack-chat-server", "--init", "--snapshot", "snapshot"],
+ vec![
+ "stack-chat-server",
+ "--reset-owner",
+ "abc",
+ "--owner-fingerprint",
+ ],
+ vec![
+ "stack-chat-server",
+ "--owner-fingerprint",
+ "--snapshot",
+ "snapshot",
+ ],
+ ] {
+ anyhow::ensure!(
+ Arguments::try_parse_from(args).is_err(),
+ "Test invariant failed"
+ );
+ }
+ Ok(())
+ }
+}