summaryrefslogtreecommitdiff
path: root/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 /src/app/cli.rs
parent9139398d465f5899cc31bc4cdd54daa7392ca550 (diff)
Forbid Rust lints and enforce strict typed TypeScript linting
Diffstat (limited to 'src/app/cli.rs')
-rw-r--r--src/app/cli.rs329
1 files changed, 329 insertions, 0 deletions
diff --git a/src/app/cli.rs b/src/app/cli.rs
new file mode 100644
index 0000000..1830ec1
--- /dev/null
+++ b/src/app/cli.rs
@@ -0,0 +1,329 @@
+// 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, ArgMatches, Command, error::ErrorKind};
+use std::ffi::OsString;
+use std::path::PathBuf;
+
+#[derive(Debug)]
+
+pub(super) struct Cli {
+ pub(super) root: Option<PathBuf>,
+
+ pub(super) command: Action,
+}
+
+#[derive(Debug)]
+pub(super) enum Action {
+ Chat {
+ init: bool,
+
+ reset_owner: Option<String>,
+
+ owner_fingerprint: bool,
+ },
+
+ Dev {
+ port: Option<u16>,
+ },
+
+ Up {
+ production: bool,
+
+ local: bool,
+
+ port: Option<u16>,
+
+ online: bool,
+ },
+
+ Config {
+ production: bool,
+
+ port: Option<u16>,
+ },
+
+ Status,
+
+ Down,
+
+ Backup {
+ destination: Option<PathBuf>,
+
+ include_images: bool,
+
+ recipient: Option<String>,
+
+ key: Option<PathBuf>,
+ },
+
+ Restore {
+ archive: PathBuf,
+ destination: PathBuf,
+ },
+
+ Check,
+
+ Release {
+ destination: Option<PathBuf>,
+ },
+
+ Activate {
+ archive: PathBuf,
+ },
+
+ Licenses,
+}
+
+fn flag(name: &'static str) -> Arg {
+ Arg::new(name).long(name).action(ArgAction::SetTrue)
+}
+
+fn path(name: &'static str) -> Arg {
+ Arg::new(name).value_parser(clap::builder::PathBufValueParser::new())
+}
+
+fn port() -> Arg {
+ Arg::new("port")
+ .long("port")
+ .value_parser(clap::builder::RangedU64ValueParser::<u16>::new())
+}
+
+fn development() -> [Command; 3] {
+ [
+ Command::new("dev")
+ .about("Start the local development services")
+ .arg(port()),
+ Command::new("up")
+ .about("Start or update services in the selected mode")
+ .args([
+ flag("production").conflicts_with("local"),
+ flag("local"),
+ port(),
+ flag("online"),
+ ]),
+ Command::new("config")
+ .about("Render public Caddy, website, and cgit configuration without starting services")
+ .args([flag("production"), port()]),
+ ]
+}
+
+fn archives() -> [Command; 4] {
+ [
+ Command::new("backup")
+ .about("Create a GPG-encrypted application export")
+ .args([
+ path("destination"),
+ flag("include-images"),
+ Arg::new("recipient").long("recipient"),
+ path("key").long("key"),
+ ]),
+ Command::new("restore")
+ .about("Decrypt, validate, and restore into a new directory without starting services")
+ .args([
+ path("archive").required(true),
+ path("destination").required(true),
+ ]),
+ Command::new("release")
+ .about("Check and build a portable production release locally")
+ .arg(path("destination")),
+ Command::new("activate")
+ .about("Install a prebuilt release without compiling on the server")
+ .arg(path("archive").required(true)),
+ ]
+}
+
+fn command() -> Command {
+ Command::new("stack")
+ .version(env!("CARGO_PKG_VERSION"))
+ .about("Develop, deploy, and back up the personal web stack")
+ .subcommand_required(true)
+ .arg_required_else_help(true)
+ .arg(
+ path("root").long("root").global(true).help(
+ "Application root; otherwise discovered from the working directory or binary",
+ ),
+ )
+ .subcommand(
+ Command::new("chat")
+ .about("Run experimental local chat or administer its packaged deployment")
+ .args([
+ flag("init").conflicts_with_all(["reset-owner", "owner-fingerprint"]),
+ Arg::new("reset-owner")
+ .long("reset-owner")
+ .value_name("FINGERPRINT")
+ .conflicts_with("owner-fingerprint")
+ .help(
+ "Permanently clear owner registration and chat history after key loss",
+ ),
+ flag("owner-fingerprint")
+ .help("Print the registered owner's public fingerprint"),
+ ]),
+ )
+ .subcommands(development())
+ .subcommands(archives())
+ .subcommands([
+ Command::new("status").about("Show service status"),
+ Command::new("down").about("Stop services while retaining data"),
+ Command::new("check")
+ .about("Run formatting, strict lints, tests, documentation, and dependency checks"),
+ Command::new("licenses")
+ .about("Generate the third-party license page with GNU license names"),
+ ])
+}
+
+fn required<T: Clone + Send + Sync + 'static>(
+ matches: &mut ArgMatches,
+ name: &str,
+) -> Result<T, clap::Error> {
+ matches.remove_one(name).ok_or_else(|| {
+ clap::Error::raw(
+ ErrorKind::MissingRequiredArgument,
+ format!("Missing {name}"),
+ )
+ })
+}
+
+fn action(name: &str, args: &mut ArgMatches) -> Result<Action, clap::Error> {
+ Ok(match name {
+ "chat" => Action::Chat {
+ init: args.get_flag("init"),
+ reset_owner: args.remove_one("reset-owner"),
+ owner_fingerprint: args.get_flag("owner-fingerprint"),
+ },
+ "dev" => Action::Dev {
+ port: args.remove_one("port"),
+ },
+ "up" => Action::Up {
+ production: args.get_flag("production"),
+ local: args.get_flag("local"),
+ port: args.remove_one("port"),
+ online: args.get_flag("online"),
+ },
+ "config" => Action::Config {
+ production: args.get_flag("production"),
+ port: args.remove_one("port"),
+ },
+ "status" => Action::Status,
+ "down" => Action::Down,
+ "backup" => Action::Backup {
+ destination: args.remove_one("destination"),
+ include_images: args.get_flag("include-images"),
+ recipient: args.remove_one("recipient"),
+ key: args.remove_one("key"),
+ },
+ "restore" => Action::Restore {
+ archive: required(args, "archive")?,
+ destination: required(args, "destination")?,
+ },
+ "check" => Action::Check,
+ "release" => Action::Release {
+ destination: args.remove_one("destination"),
+ },
+ "activate" => Action::Activate {
+ archive: required(args, "archive")?,
+ },
+ "licenses" => Action::Licenses,
+ _ => {
+ return Err(clap::Error::raw(
+ ErrorKind::InvalidSubcommand,
+ "Unknown command",
+ ));
+ }
+ })
+}
+
+impl Cli {
+ 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 matches = command().try_get_matches_from(arguments)?;
+ let root = matches.remove_one("root");
+ let (name, mut arguments) = matches.remove_subcommand().ok_or_else(|| {
+ clap::Error::raw(ErrorKind::MissingSubcommand, "Select a stack command")
+ })?;
+ Ok(Self {
+ root,
+ command: action(&name, &mut arguments)?,
+ })
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::{Action, Cli, command};
+ use anyhow::Result;
+ use std::path::PathBuf;
+
+ #[test]
+ fn checked_command_schema() {
+ command().debug_assert();
+ }
+
+ #[test]
+ fn modes_conflicts_and_required_paths() -> Result<()> {
+ let parsed = Cli::try_parse_from([
+ "stack",
+ "up",
+ "--root",
+ "/tmp/stack",
+ "--local",
+ "--port",
+ "8180",
+ "--online",
+ ])?;
+ anyhow::ensure!(
+ (parsed.root) == (Some(PathBuf::from("/tmp/stack"))),
+ "Test invariant failed"
+ );
+ anyhow::ensure!(
+ matches!(
+ parsed.command,
+ Action::Up {
+ production: false,
+ local: true,
+ port: Some(8180),
+ online: true
+ }
+ ),
+ "Test invariant failed"
+ );
+ for args in [
+ vec!["stack", "up", "--local", "--production"],
+ vec!["stack", "dev", "--port", "65536"],
+ vec!["stack", "chat", "--init", "--reset-owner", "abc"],
+ vec![
+ "stack",
+ "chat",
+ "--reset-owner",
+ "abc",
+ "--owner-fingerprint",
+ ],
+ vec!["stack", "restore", "archive"],
+ vec!["stack", "activate"],
+ ] {
+ anyhow::ensure!(Cli::try_parse_from(args).is_err(), "Test invariant failed");
+ }
+ anyhow::ensure!(
+ matches!(Cli::try_parse_from(["stack", "restore", "export.gpg", "new-root"])?.command, Action::Restore { archive, destination } if archive == std::path::Path::new("export.gpg") && destination == std::path::Path::new("new-root")),
+ "Test invariant failed"
+ );
+ Ok(())
+ }
+}