// Copyright (C) 2026 Doan Luu Duc Tai // // 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 . 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, pub(super) command: Action, } #[derive(Debug)] pub(super) enum Action { Chat { init: bool, reset_owner: Option, owner_fingerprint: bool, }, Dev { port: Option, }, Up { production: bool, local: bool, port: Option, online: bool, }, Config { production: bool, port: Option, }, Status, Down, Backup { destination: Option, include_images: bool, recipient: Option, key: Option, }, Restore { archive: PathBuf, destination: PathBuf, }, Check, Release { destination: Option, }, 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::::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( matches: &mut ArgMatches, name: &str, ) -> Result { matches.remove_one(name).ok_or_else(|| { clap::Error::raw( ErrorKind::MissingRequiredArgument, format!("Missing {name}"), ) }) } fn action(name: &str, args: &mut ArgMatches) -> Result { 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::try_parse_from(std::env::args_os()) } fn try_parse_from( arguments: impl IntoIterator + Clone>, ) -> Result { 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(()) } }