From ce0dbf6b249956700c6a1705bf4ad85a09d53e8c Mon Sep 17 00:00:00 2001 From: Dawid Rycerz Date: Sun, 15 Feb 2026 21:27:00 +0100 Subject: feat: witryna 0.2.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Switch, cleanup, and status CLI commands. Persistent build state via state.json. Post-deploy hooks on success and failure with WITRYNA_BUILD_STATUS. Dependency diet (axum→tiny_http, clap→argh, tracing→log). Drop built-in rate limiting. Nix flake with NixOS module. Arch Linux PKGBUILD. Centralized version management. Co-Authored-By: Claude Opus 4.6 --- src/cli.rs | 303 +++++++++++++++++++++++++++++++++++++++++++++---------------- 1 file changed, 227 insertions(+), 76 deletions(-) (limited to 'src/cli.rs') diff --git a/src/cli.rs b/src/cli.rs index ab191a4..4d958ea 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -1,57 +1,114 @@ -use clap::{Parser, Subcommand}; +use argh::FromArgs; use std::path::PathBuf; -/// Witryna - minimalist Git-based static site deployment orchestrator -#[derive(Debug, Parser)] -#[command( - name = "witryna", - version, - author, - about = "Minimalist Git-based static site deployment orchestrator", - long_about = "Minimalist Git-based static site deployment orchestrator.\n\n\ - Witryna listens for webhook HTTP requests, pulls the corresponding Git \ - repository (with automatic Git LFS fetch and submodule initialization), \ - runs a user-defined build command inside an ephemeral container and \ - publishes the resulting assets via atomic symlink switching.\n\n\ - A health-check endpoint is available at GET /health (returns 200 OK).\n\n\ - Witryna does not serve files, terminate TLS, or manage DNS. \ - It is designed to sit behind a reverse proxy (Nginx, Caddy, etc.).", - subcommand_required = true, - arg_required_else_help = true -)] +/// Minimalist Git-based static site deployment orchestrator +#[derive(Debug, FromArgs)] pub struct Cli { - /// Path to the configuration file. - /// If not specified, searches: ./witryna.toml, $XDG_CONFIG_HOME/witryna/witryna.toml, /etc/witryna/witryna.toml - #[arg(long, global = true, value_name = "FILE")] - pub config: Option, - - #[command(subcommand)] + #[argh(subcommand)] pub command: Command, } -#[derive(Debug, Subcommand)] +#[derive(Debug, FromArgs)] +#[argh(subcommand)] pub enum Command { - /// Start the deployment server (foreground) - Serve, - /// Validate configuration file and print summary - Validate, - /// Trigger a one-off build for a site (synchronous, no server) - Run { - /// Site name (as defined in witryna.toml) - site: String, - /// Stream full build output to stderr in real-time - #[arg(long, short)] - verbose: bool, - }, - /// Show deployment status for configured sites - Status { - /// Show last 10 deployments for a single site - #[arg(long, short)] - site: Option, - /// Output in JSON format - #[arg(long)] - json: bool, - }, + Serve(ServeCmd), + Validate(ValidateCmd), + Run(RunCmd), + Status(StatusCmd), + Switch(SwitchCmd), + Cleanup(CleanupCmd), +} + +impl Command { + #[must_use] + pub fn config(&self) -> Option<&std::path::Path> { + match self { + Self::Serve(c) => c.config.as_deref(), + Self::Validate(c) => c.config.as_deref(), + Self::Run(c) => c.config.as_deref(), + Self::Status(c) => c.config.as_deref(), + Self::Switch(c) => c.config.as_deref(), + Self::Cleanup(c) => c.config.as_deref(), + } + } +} + +/// Start the deployment server (foreground) +#[derive(Debug, FromArgs)] +#[argh(subcommand, name = "serve")] +pub struct ServeCmd { + /// path to configuration file + #[argh(option)] + pub config: Option, +} + +/// Validate configuration file and print summary +#[derive(Debug, FromArgs)] +#[argh(subcommand, name = "validate")] +pub struct ValidateCmd { + /// path to configuration file + #[argh(option)] + pub config: Option, +} + +/// Trigger a one-off build for a site (synchronous, no server) +#[derive(Debug, FromArgs)] +#[argh(subcommand, name = "run")] +pub struct RunCmd { + /// path to configuration file + #[argh(option)] + pub config: Option, + /// site name (as defined in witryna.toml) + #[argh(positional)] + pub site: String, + /// stream full build output to stderr in real-time + #[argh(switch, short = 'v')] + pub verbose: bool, +} + +/// Show deployment status for configured sites +#[derive(Debug, FromArgs)] +#[argh(subcommand, name = "status")] +pub struct StatusCmd { + /// path to configuration file + #[argh(option)] + pub config: Option, + /// site name (if omitted, shows all sites) + #[argh(positional)] + pub site: Option, + /// output in JSON format + #[argh(switch)] + pub json: bool, +} + +/// Switch the active build symlink (rollback) +#[derive(Debug, FromArgs)] +#[argh(subcommand, name = "switch")] +pub struct SwitchCmd { + /// path to configuration file + #[argh(option)] + pub config: Option, + /// site name (as defined in witryna.toml) + #[argh(positional)] + pub site: String, + /// build timestamp to switch to (from `witryna status `) + #[argh(positional)] + pub build: String, +} + +/// Remove old builds and logs +#[derive(Debug, FromArgs)] +#[argh(subcommand, name = "cleanup")] +pub struct CleanupCmd { + /// path to configuration file + #[argh(option)] + pub config: Option, + /// site name (if omitted, cleans all sites) + #[argh(positional)] + pub site: Option, + /// number of builds to keep per site (overrides `max_builds_to_keep`) + #[argh(option)] + pub keep: Option, } #[cfg(test)] @@ -61,74 +118,168 @@ mod tests { #[test] fn run_parses_site_name() { - let cli = Cli::try_parse_from(["witryna", "run", "my-site"]).unwrap(); + let cli = Cli::from_args(&["witryna"], &["run", "my-site"]).unwrap(); match cli.command { - Command::Run { site, verbose } => { - assert_eq!(site, "my-site"); - assert!(!verbose); + Command::Run(cmd) => { + assert_eq!(cmd.site, "my-site"); + assert!(!cmd.verbose); } - _ => panic!("expected Run command"), + _ => unreachable!("expected Run command"), } } #[test] fn run_parses_verbose_flag() { - let cli = Cli::try_parse_from(["witryna", "run", "my-site", "--verbose"]).unwrap(); + let cli = Cli::from_args(&["witryna"], &["run", "my-site", "--verbose"]).unwrap(); match cli.command { - Command::Run { site, verbose } => { - assert_eq!(site, "my-site"); - assert!(verbose); + Command::Run(cmd) => { + assert_eq!(cmd.site, "my-site"); + assert!(cmd.verbose); } - _ => panic!("expected Run command"), + _ => unreachable!("expected Run command"), } } #[test] fn status_parses_without_flags() { - let cli = Cli::try_parse_from(["witryna", "status"]).unwrap(); + let cli = Cli::from_args(&["witryna"], &["status"]).unwrap(); match cli.command { - Command::Status { site, json } => { - assert!(site.is_none()); - assert!(!json); + Command::Status(cmd) => { + assert!(cmd.site.is_none()); + assert!(!cmd.json); } - _ => panic!("expected Status command"), + _ => unreachable!("expected Status command"), } } #[test] fn status_parses_site_filter() { - let cli = Cli::try_parse_from(["witryna", "status", "--site", "my-site"]).unwrap(); + let cli = Cli::from_args(&["witryna"], &["status", "my-site"]).unwrap(); match cli.command { - Command::Status { site, json } => { - assert_eq!(site.as_deref(), Some("my-site")); - assert!(!json); + Command::Status(cmd) => { + assert_eq!(cmd.site.as_deref(), Some("my-site")); + assert!(!cmd.json); } - _ => panic!("expected Status command"), + _ => unreachable!("expected Status command"), } } #[test] fn status_parses_json_flag() { - let cli = Cli::try_parse_from(["witryna", "status", "--json"]).unwrap(); + let cli = Cli::from_args(&["witryna"], &["status", "--json"]).unwrap(); + match cli.command { + Command::Status(cmd) => { + assert!(cmd.site.is_none()); + assert!(cmd.json); + } + _ => unreachable!("expected Status command"), + } + } + + #[test] + fn cleanup_parses_without_args() { + let cli = Cli::from_args(&["witryna"], &["cleanup"]).unwrap(); + match cli.command { + Command::Cleanup(cmd) => { + assert!(cmd.site.is_none()); + assert!(cmd.keep.is_none()); + } + _ => unreachable!("expected Cleanup command"), + } + } + + #[test] + fn cleanup_parses_site_name() { + let cli = Cli::from_args(&["witryna"], &["cleanup", "my-site"]).unwrap(); + match cli.command { + Command::Cleanup(cmd) => { + assert_eq!(cmd.site.as_deref(), Some("my-site")); + assert!(cmd.keep.is_none()); + } + _ => unreachable!("expected Cleanup command"), + } + } + + #[test] + fn cleanup_parses_keep_flag() { + let cli = Cli::from_args(&["witryna"], &["cleanup", "--keep", "3"]).unwrap(); + match cli.command { + Command::Cleanup(cmd) => { + assert!(cmd.site.is_none()); + assert_eq!(cmd.keep, Some(3)); + } + _ => unreachable!("expected Cleanup command"), + } + } + + #[test] + fn cleanup_parses_site_and_keep() { + let cli = Cli::from_args(&["witryna"], &["cleanup", "my-site", "--keep", "3"]).unwrap(); + match cli.command { + Command::Cleanup(cmd) => { + assert_eq!(cmd.site.as_deref(), Some("my-site")); + assert_eq!(cmd.keep, Some(3)); + } + _ => unreachable!("expected Cleanup command"), + } + } + + #[test] + fn switch_parses_site_and_build() { + let cli = Cli::from_args( + &["witryna"], + &["switch", "my-site", "20260126-143000-123456"], + ) + .unwrap(); + match cli.command { + Command::Switch(cmd) => { + assert_eq!(cmd.site, "my-site"); + assert_eq!(cmd.build, "20260126-143000-123456"); + assert!(cmd.config.is_none()); + } + _ => unreachable!("expected Switch command"), + } + } + + #[test] + fn switch_parses_config_flag() { + let cli = Cli::from_args( + &["witryna"], + &[ + "switch", + "--config", + "/etc/witryna.toml", + "my-site", + "20260126-143000-123456", + ], + ) + .unwrap(); match cli.command { - Command::Status { site, json } => { - assert!(site.is_none()); - assert!(json); + Command::Switch(cmd) => { + assert_eq!(cmd.site, "my-site"); + assert_eq!(cmd.build, "20260126-143000-123456"); + assert_eq!( + cmd.config, + Some(std::path::PathBuf::from("/etc/witryna.toml")) + ); } - _ => panic!("expected Status command"), + _ => unreachable!("expected Switch command"), } } #[test] fn config_flag_is_optional() { - let cli = Cli::try_parse_from(["witryna", "status"]).unwrap(); - assert!(cli.config.is_none()); + let cli = Cli::from_args(&["witryna"], &["status"]).unwrap(); + assert!(cli.command.config().is_none()); } #[test] fn config_flag_explicit_path() { let cli = - Cli::try_parse_from(["witryna", "--config", "/etc/witryna.toml", "status"]).unwrap(); - assert_eq!(cli.config, Some(PathBuf::from("/etc/witryna.toml"))); + Cli::from_args(&["witryna"], &["status", "--config", "/etc/witryna.toml"]).unwrap(); + assert_eq!( + cli.command.config(), + Some(std::path::Path::new("/etc/witryna.toml")) + ); } } -- cgit v1.2.3