Skip to main content

canic_cli/
lib.rs

1mod args;
2pub mod backup;
3pub mod build;
4pub mod fleets;
5pub mod install;
6pub mod list;
7pub mod manifest;
8mod output;
9pub mod release_set;
10pub mod restore;
11pub mod snapshot;
12
13use crate::args::any_arg_is_version;
14use clap::{Arg, ArgAction, Command};
15use std::ffi::OsString;
16use thiserror::Error as ThisError;
17
18const VERSION_TEXT: &str = concat!("canic ", env!("CARGO_PKG_VERSION"));
19const TOP_LEVEL_HELP_TEMPLATE: &str = "{about-with-newline}\n{usage-heading} {usage}\n\n{before-help}Options:\n{options}{after-help}\n";
20
21///
22/// CommandScope
23///
24
25#[derive(Clone, Copy, Debug, Eq, PartialEq)]
26enum CommandScope {
27    MultiFleet,
28    SingleFleet,
29    SingleCanister,
30}
31
32impl CommandScope {
33    // Return the heading used in grouped top-level help.
34    const fn heading(self) -> &'static str {
35        match self {
36            Self::MultiFleet => "Multi-fleet commands",
37            Self::SingleFleet => "Single-fleet commands",
38            Self::SingleCanister => "Single-canister commands",
39        }
40    }
41}
42
43///
44/// CommandSpec
45///
46
47#[derive(Clone, Copy, Debug, Eq, PartialEq)]
48struct CommandSpec {
49    name: &'static str,
50    about: &'static str,
51    scope: CommandScope,
52}
53
54const COMMAND_SPECS: &[CommandSpec] = &[
55    CommandSpec {
56        name: "fleets",
57        about: "List installed Canic fleets",
58        scope: CommandScope::MultiFleet,
59    },
60    CommandSpec {
61        name: "use",
62        about: "Select the current Canic fleet",
63        scope: CommandScope::MultiFleet,
64    },
65    CommandSpec {
66        name: "install",
67        about: "Install and bootstrap a Canic fleet",
68        scope: CommandScope::SingleFleet,
69    },
70    CommandSpec {
71        name: "list",
72        about: "Show registry canisters as a tree table",
73        scope: CommandScope::SingleFleet,
74    },
75    CommandSpec {
76        name: "backup",
77        about: "Verify backup directories and journal status",
78        scope: CommandScope::SingleFleet,
79    },
80    CommandSpec {
81        name: "manifest",
82        about: "Validate fleet backup manifests",
83        scope: CommandScope::SingleFleet,
84    },
85    CommandSpec {
86        name: "release-set",
87        about: "Inspect, emit, or stage root release-set artifacts",
88        scope: CommandScope::SingleFleet,
89    },
90    CommandSpec {
91        name: "restore",
92        about: "Plan or run snapshot restores",
93        scope: CommandScope::SingleFleet,
94    },
95    CommandSpec {
96        name: "build",
97        about: "Build one Canic canister artifact",
98        scope: CommandScope::SingleCanister,
99    },
100    CommandSpec {
101        name: "snapshot",
102        about: "Capture and download canister snapshots",
103        scope: CommandScope::SingleCanister,
104    },
105];
106
107///
108/// CliError
109///
110
111#[derive(Debug, ThisError)]
112pub enum CliError {
113    #[error("{0}")]
114    Usage(String),
115
116    #[error(transparent)]
117    Backup(#[from] backup::BackupCommandError),
118
119    #[error(transparent)]
120    Build(#[from] build::BuildCommandError),
121
122    #[error(transparent)]
123    Install(#[from] install::InstallCommandError),
124
125    #[error(transparent)]
126    Fleets(#[from] fleets::FleetCommandError),
127
128    #[error(transparent)]
129    List(#[from] list::ListCommandError),
130
131    #[error(transparent)]
132    Manifest(#[from] manifest::ManifestCommandError),
133
134    #[error(transparent)]
135    Snapshot(#[from] snapshot::SnapshotCommandError),
136
137    #[error(transparent)]
138    ReleaseSet(#[from] release_set::ReleaseSetCommandError),
139
140    #[error(transparent)]
141    Restore(#[from] restore::RestoreCommandError),
142}
143
144/// Run the CLI from process arguments.
145pub fn run_from_env() -> Result<(), CliError> {
146    run(std::env::args_os().skip(1))
147}
148
149/// Run the CLI from an argument iterator.
150pub fn run<I>(args: I) -> Result<(), CliError>
151where
152    I: IntoIterator<Item = OsString>,
153{
154    let args = args.into_iter().collect::<Vec<_>>();
155    if any_arg_is_version(&args) {
156        println!("{}", version_text());
157        return Ok(());
158    }
159
160    let mut args = args.into_iter();
161    let Some(command) = args.next().and_then(|arg| arg.into_string().ok()) else {
162        return Err(CliError::Usage(usage()));
163    };
164
165    match command.as_str() {
166        "backup" => backup::run(args).map_err(CliError::from),
167        "build" => build::run(args).map_err(CliError::from),
168        "fleets" => fleets::run(args).map_err(CliError::from),
169        "install" => install::run(args).map_err(CliError::from),
170        "list" => list::run(args).map_err(CliError::from),
171        "manifest" => manifest::run(args).map_err(CliError::from),
172        "release-set" => release_set::run(args).map_err(CliError::from),
173        "snapshot" => snapshot::run(args).map_err(CliError::from),
174        "restore" => restore::run(args).map_err(CliError::from),
175        "use" => fleets::run_use(args).map_err(CliError::from),
176        "help" | "--help" | "-h" => {
177            println!("{}", usage());
178            Ok(())
179        }
180        _ => Err(CliError::Usage(usage())),
181    }
182}
183
184/// Build the top-level command metadata.
185#[must_use]
186pub fn top_level_command() -> Command {
187    let command = Command::new("canic")
188        .about("Operator CLI for Canic install, backup, and restore workflows")
189        .disable_version_flag(true)
190        .arg(
191            Arg::new("version")
192                .short('V')
193                .long("version")
194                .action(ArgAction::SetTrue)
195                .help("Print version"),
196        )
197        .subcommand_help_heading("Commands")
198        .help_template(TOP_LEVEL_HELP_TEMPLATE)
199        .before_help(grouped_command_section(COMMAND_SPECS))
200        .after_help("Run `canic <command> help` for command-specific help.");
201
202    COMMAND_SPECS.iter().fold(command, |command, spec| {
203        command.subcommand(Command::new(spec.name).about(spec.about))
204    })
205}
206
207/// Return the CLI version banner.
208#[must_use]
209pub const fn version_text() -> &'static str {
210    VERSION_TEXT
211}
212
213// Return the top-level usage text.
214fn usage() -> String {
215    let mut command = top_level_command();
216    command.render_help().to_string()
217}
218
219// Render grouped command rows from the same metadata used to build Clap subcommands.
220fn grouped_command_section(specs: &[CommandSpec]) -> String {
221    let mut lines = Vec::new();
222    let scopes = [
223        CommandScope::MultiFleet,
224        CommandScope::SingleFleet,
225        CommandScope::SingleCanister,
226    ];
227    for (index, scope) in scopes.into_iter().enumerate() {
228        lines.push(format!("{}:", scope.heading()));
229        for spec in specs.iter().filter(|spec| spec.scope == scope) {
230            lines.push(format!("  {:<11} {}", spec.name, spec.about));
231        }
232        if index + 1 < scopes.len() {
233            lines.push(String::new());
234        }
235    }
236    lines.join("\n")
237}
238
239#[cfg(test)]
240mod tests {
241    use super::*;
242
243    // Ensure top-level help stays compact as command surfaces grow.
244    #[test]
245    fn usage_lists_command_families() {
246        let text = usage();
247
248        assert!(text.contains("Usage: canic"));
249        assert!(text.contains("Multi-fleet commands"));
250        assert!(text.contains("Single-fleet commands"));
251        assert!(text.contains("Single-canister commands"));
252        assert!(!text.contains("\nCommands:\n"));
253        assert!(text.find("Multi-fleet commands") < text.find("Single-fleet commands"));
254        assert!(text.find("Single-fleet commands") < text.find("Single-canister commands"));
255        assert!(text.contains("list"));
256        assert!(text.contains("build"));
257        assert!(text.contains("fleets"));
258        assert!(text.contains("use"));
259        assert!(text.contains("install"));
260        assert!(text.contains("snapshot"));
261        assert!(text.contains("backup"));
262        assert!(text.contains("manifest"));
263        assert!(text.contains("release-set"));
264        assert!(text.contains("restore"));
265        assert!(text.contains("canic <command> help"));
266    }
267
268    // Ensure command-family help paths return successfully instead of erroring.
269    #[test]
270    fn command_family_help_returns_ok() {
271        assert!(run([OsString::from("backup"), OsString::from("help")]).is_ok());
272        assert!(run([OsString::from("build"), OsString::from("help")]).is_ok());
273        assert!(run([OsString::from("install"), OsString::from("help")]).is_ok());
274        assert!(run([OsString::from("fleets"), OsString::from("help")]).is_ok());
275        assert!(run([OsString::from("list"), OsString::from("help")]).is_ok());
276        assert!(run([OsString::from("restore"), OsString::from("help")]).is_ok());
277        assert!(run([OsString::from("manifest"), OsString::from("help")]).is_ok());
278        assert!(run([OsString::from("release-set"), OsString::from("help")]).is_ok());
279        assert!(run([OsString::from("snapshot"), OsString::from("help")]).is_ok());
280        assert!(run([OsString::from("use"), OsString::from("help")]).is_ok());
281    }
282
283    // Ensure version flags are accepted at the top level and command-family level.
284    #[test]
285    fn version_flags_return_ok() {
286        assert_eq!(version_text(), concat!("canic ", env!("CARGO_PKG_VERSION")));
287        assert!(run([OsString::from("--version")]).is_ok());
288        assert!(run([OsString::from("backup"), OsString::from("--version")]).is_ok());
289        assert!(run([OsString::from("build"), OsString::from("--version")]).is_ok());
290        assert!(run([OsString::from("install"), OsString::from("--version")]).is_ok());
291        assert!(run([OsString::from("fleets"), OsString::from("--version")]).is_ok());
292        assert!(run([OsString::from("list"), OsString::from("--version")]).is_ok());
293        assert!(run([OsString::from("restore"), OsString::from("--version")]).is_ok());
294        assert!(run([OsString::from("manifest"), OsString::from("--version")]).is_ok());
295        assert!(run([OsString::from("release-set"), OsString::from("--version")]).is_ok());
296        assert!(run([OsString::from("snapshot"), OsString::from("--version")]).is_ok());
297        assert!(run([OsString::from("use"), OsString::from("--version")]).is_ok());
298    }
299}