Skip to main content

canic_cli/
lib.rs

1pub mod backup;
2pub mod list;
3pub mod manifest;
4pub mod restore;
5pub mod snapshot;
6
7mod output;
8
9use clap::{Arg, ArgAction, Command};
10use std::ffi::OsString;
11use thiserror::Error as ThisError;
12
13const VERSION_TEXT: &str = concat!("canic ", env!("CARGO_PKG_VERSION"));
14
15///
16/// CliError
17///
18
19#[derive(Debug, ThisError)]
20pub enum CliError {
21    #[error("{0}")]
22    Usage(String),
23
24    #[error(transparent)]
25    Backup(#[from] backup::BackupCommandError),
26
27    #[error(transparent)]
28    List(#[from] list::ListCommandError),
29
30    #[error(transparent)]
31    Manifest(#[from] manifest::ManifestCommandError),
32
33    #[error(transparent)]
34    Snapshot(#[from] snapshot::SnapshotCommandError),
35
36    #[error(transparent)]
37    Restore(#[from] restore::RestoreCommandError),
38}
39
40/// Run the CLI from process arguments.
41pub fn run_from_env() -> Result<(), CliError> {
42    run(std::env::args_os().skip(1))
43}
44
45/// Run the CLI from an argument iterator.
46pub fn run<I>(args: I) -> Result<(), CliError>
47where
48    I: IntoIterator<Item = OsString>,
49{
50    let args = args.into_iter().collect::<Vec<_>>();
51    if args
52        .iter()
53        .filter_map(|arg| arg.to_str())
54        .any(|arg| matches!(arg, "version" | "--version" | "-V"))
55    {
56        println!("{}", version_text());
57        return Ok(());
58    }
59
60    let mut args = args.into_iter();
61    let Some(command) = args.next().and_then(|arg| arg.into_string().ok()) else {
62        return Err(CliError::Usage(usage()));
63    };
64
65    match command.as_str() {
66        "backup" => backup::run(args).map_err(CliError::from),
67        "list" => list::run(args).map_err(CliError::from),
68        "manifest" => manifest::run(args).map_err(CliError::from),
69        "snapshot" => snapshot::run(args).map_err(CliError::from),
70        "restore" => restore::run(args).map_err(CliError::from),
71        "help" | "--help" | "-h" => {
72            println!("{}", usage());
73            Ok(())
74        }
75        _ => Err(CliError::Usage(usage())),
76    }
77}
78
79/// Build the top-level command metadata.
80#[must_use]
81pub fn top_level_command() -> Command {
82    Command::new("canic")
83        .about("Operator CLI for Canic fleet backup and restore workflows")
84        .disable_version_flag(true)
85        .arg(
86            Arg::new("version")
87                .short('V')
88                .long("version")
89                .action(ArgAction::SetTrue)
90                .help("Print version"),
91        )
92        .subcommand(Command::new("list").about("Show registry canisters as an ASCII tree"))
93        .subcommand(Command::new("snapshot").about("Capture and download canister snapshots"))
94        .subcommand(
95            Command::new("backup")
96                .about("Inspect, verify, preflight, or smoke-check a backup directory"),
97        )
98        .subcommand(Command::new("manifest").about("Validate fleet backup manifests"))
99        .subcommand(
100            Command::new("restore").about("Plan, preview, summarize, or run restore journals"),
101        )
102        .after_help("Run `canic <command> help` for command-specific help.")
103}
104
105/// Return the CLI version banner.
106#[must_use]
107pub const fn version_text() -> &'static str {
108    VERSION_TEXT
109}
110
111// Return the top-level usage text.
112fn usage() -> String {
113    let mut command = top_level_command();
114    command.render_help().to_string()
115}
116
117#[cfg(test)]
118mod tests {
119    use super::*;
120
121    // Ensure top-level help stays compact as command surfaces grow.
122    #[test]
123    fn usage_lists_command_families_without_nested_flags() {
124        let text = usage();
125
126        assert!(text.contains("Usage: canic"));
127        assert!(text.contains("list"));
128        assert!(text.contains("snapshot"));
129        assert!(text.contains("backup"));
130        assert!(text.contains("manifest"));
131        assert!(text.contains("restore"));
132        assert!(text.contains("canic <command> help"));
133        assert!(!text.contains("--require-batch"));
134        assert!(!text.contains("--require-no-pending-before"));
135    }
136
137    // Ensure command-family help paths return successfully instead of erroring.
138    #[test]
139    fn command_family_help_returns_ok() {
140        assert!(run([OsString::from("backup"), OsString::from("help")]).is_ok());
141        assert!(run([OsString::from("list"), OsString::from("help")]).is_ok());
142        assert!(run([OsString::from("restore"), OsString::from("help")]).is_ok());
143        assert!(run([OsString::from("manifest"), OsString::from("help")]).is_ok());
144        assert!(run([OsString::from("snapshot"), OsString::from("help")]).is_ok());
145    }
146
147    // Ensure version flags are accepted at the top level and command-family level.
148    #[test]
149    fn version_flags_return_ok() {
150        assert_eq!(version_text(), concat!("canic ", env!("CARGO_PKG_VERSION")));
151        assert!(run([OsString::from("--version")]).is_ok());
152        assert!(run([OsString::from("backup"), OsString::from("--version")]).is_ok());
153        assert!(run([OsString::from("list"), OsString::from("--version")]).is_ok());
154        assert!(run([OsString::from("restore"), OsString::from("--version")]).is_ok());
155        assert!(run([OsString::from("manifest"), OsString::from("--version")]).is_ok());
156        assert!(run([OsString::from("snapshot"), OsString::from("--version")]).is_ok());
157        assert!(
158            run([
159                OsString::from("backup"),
160                OsString::from("preflight"),
161                OsString::from("--version")
162            ])
163            .is_ok()
164        );
165    }
166}