Skip to main content

canic_cli/
lib.rs

1mod args;
2mod backup;
3mod build;
4mod fleets;
5mod install;
6mod list;
7mod manifest;
8mod medic;
9mod output;
10mod restore;
11mod scaffold;
12mod snapshot;
13#[cfg(test)]
14mod test_support;
15
16use crate::args::first_arg_is_version;
17use clap::{Arg, ArgAction, Command};
18use std::ffi::OsString;
19use thiserror::Error as ThisError;
20
21const VERSION_TEXT: &str = concat!("canic ", env!("CARGO_PKG_VERSION"));
22const TOP_LEVEL_HELP_TEMPLATE: &str = "{name} {version}\n{about-with-newline}\n{usage-heading} {usage}\n\n{before-help}Options:\n{options}{after-help}\n";
23const COLOR_RESET: &str = "\x1b[0m";
24const COLOR_HEADING: &str = "\x1b[1m";
25const COLOR_GROUP: &str = "\x1b[38;5;245m";
26const COLOR_COMMAND: &str = "\x1b[38;5;109m";
27const COLOR_TIP: &str = "\x1b[38;5;245m";
28
29///
30/// CommandScope
31///
32
33#[derive(Clone, Copy, Debug, Eq, PartialEq)]
34enum CommandScope {
35    Global,
36    FleetContext,
37    WorkspaceFiles,
38}
39
40impl CommandScope {
41    // Return the heading used in grouped top-level help.
42    const fn heading(self) -> &'static str {
43        match self {
44            Self::Global => "Global commands",
45            Self::FleetContext => "Fleet commands",
46            Self::WorkspaceFiles => "Workspace and file commands",
47        }
48    }
49}
50
51///
52/// CommandSpec
53///
54
55#[derive(Clone, Copy, Debug, Eq, PartialEq)]
56struct CommandSpec {
57    name: &'static str,
58    about: &'static str,
59    scope: CommandScope,
60}
61
62const COMMAND_SPECS: &[CommandSpec] = &[
63    CommandSpec {
64        name: "fleet",
65        about: "Manage Canic fleets",
66        scope: CommandScope::Global,
67    },
68    CommandSpec {
69        name: "install",
70        about: "Install and bootstrap a Canic fleet",
71        scope: CommandScope::FleetContext,
72    },
73    CommandSpec {
74        name: "config",
75        about: "Inspect selected fleet config",
76        scope: CommandScope::FleetContext,
77    },
78    CommandSpec {
79        name: "list",
80        about: "List deployed fleet canisters",
81        scope: CommandScope::FleetContext,
82    },
83    CommandSpec {
84        name: "medic",
85        about: "Diagnose local Canic fleet setup",
86        scope: CommandScope::FleetContext,
87    },
88    CommandSpec {
89        name: "snapshot",
90        about: "Capture and download canister snapshots",
91        scope: CommandScope::FleetContext,
92    },
93    CommandSpec {
94        name: "build",
95        about: "Build one Canic canister artifact",
96        scope: CommandScope::WorkspaceFiles,
97    },
98    CommandSpec {
99        name: "backup",
100        about: "Verify backup directories and journal status",
101        scope: CommandScope::WorkspaceFiles,
102    },
103    CommandSpec {
104        name: "manifest",
105        about: "Validate fleet backup manifests",
106        scope: CommandScope::WorkspaceFiles,
107    },
108    CommandSpec {
109        name: "restore",
110        about: "Plan or run snapshot restores",
111        scope: CommandScope::WorkspaceFiles,
112    },
113];
114
115///
116/// CliError
117///
118
119#[derive(Debug, ThisError)]
120pub enum CliError {
121    #[error("{0}")]
122    Usage(String),
123
124    #[error("backup: {0}")]
125    Backup(String),
126
127    #[error("build: {0}")]
128    Build(String),
129
130    #[error("config: {0}")]
131    Config(String),
132
133    #[error("install: {0}")]
134    Install(String),
135
136    #[error("fleet: {0}")]
137    Fleets(String),
138
139    #[error("list: {0}")]
140    List(String),
141
142    #[error("manifest: {0}")]
143    Manifest(String),
144
145    #[error("medic: {0}")]
146    Medic(String),
147
148    #[error("snapshot: {0}")]
149    Snapshot(String),
150
151    #[error("restore: {0}")]
152    Restore(String),
153}
154
155impl From<backup::BackupCommandError> for CliError {
156    // Keep backup command internals private while preserving operator-facing messages.
157    fn from(err: backup::BackupCommandError) -> Self {
158        Self::Backup(err.to_string())
159    }
160}
161
162impl From<build::BuildCommandError> for CliError {
163    // Keep build command internals private while preserving operator-facing messages.
164    fn from(err: build::BuildCommandError) -> Self {
165        Self::Build(err.to_string())
166    }
167}
168
169impl From<install::InstallCommandError> for CliError {
170    // Keep install command internals private while preserving operator-facing messages.
171    fn from(err: install::InstallCommandError) -> Self {
172        Self::Install(err.to_string())
173    }
174}
175
176impl From<fleets::FleetCommandError> for CliError {
177    // Keep fleet command internals private while preserving operator-facing messages.
178    fn from(err: fleets::FleetCommandError) -> Self {
179        Self::Fleets(err.to_string())
180    }
181}
182
183impl From<list::ListCommandError> for CliError {
184    // Keep list command internals private while preserving operator-facing messages.
185    fn from(err: list::ListCommandError) -> Self {
186        Self::List(err.to_string())
187    }
188}
189
190impl From<manifest::ManifestCommandError> for CliError {
191    // Keep manifest command internals private while preserving operator-facing messages.
192    fn from(err: manifest::ManifestCommandError) -> Self {
193        Self::Manifest(err.to_string())
194    }
195}
196
197impl From<medic::MedicCommandError> for CliError {
198    // Keep medic command internals private while preserving operator-facing messages.
199    fn from(err: medic::MedicCommandError) -> Self {
200        Self::Medic(err.to_string())
201    }
202}
203
204impl From<snapshot::SnapshotCommandError> for CliError {
205    // Keep snapshot command internals private while preserving operator-facing messages.
206    fn from(err: snapshot::SnapshotCommandError) -> Self {
207        Self::Snapshot(err.to_string())
208    }
209}
210
211impl From<restore::RestoreCommandError> for CliError {
212    // Keep restore command internals private while preserving operator-facing messages.
213    fn from(err: restore::RestoreCommandError) -> Self {
214        Self::Restore(err.to_string())
215    }
216}
217
218/// Run the CLI from process arguments.
219pub fn run_from_env() -> Result<(), CliError> {
220    run(std::env::args_os().skip(1))
221}
222
223/// Run the CLI from an argument iterator.
224pub fn run<I>(args: I) -> Result<(), CliError>
225where
226    I: IntoIterator<Item = OsString>,
227{
228    let args = args.into_iter().collect::<Vec<_>>();
229    if first_arg_is_version(&args) {
230        println!("{}", version_text());
231        return Ok(());
232    }
233
234    let mut args = args.into_iter();
235    let Some(command) = args.next().and_then(|arg| arg.into_string().ok()) else {
236        return Err(CliError::Usage(usage()));
237    };
238
239    match command.as_str() {
240        "backup" => backup::run(args).map_err(CliError::from),
241        "build" => build::run(args).map_err(CliError::from),
242        "config" => list::run_config(args).map_err(|err| CliError::Config(err.to_string())),
243        "fleet" => fleets::run(args).map_err(CliError::from),
244        "install" => install::run(args).map_err(CliError::from),
245        "list" => list::run(args).map_err(CliError::from),
246        "manifest" => manifest::run(args).map_err(CliError::from),
247        "medic" => medic::run(args).map_err(CliError::from),
248        "snapshot" => snapshot::run(args).map_err(CliError::from),
249        "restore" => restore::run(args).map_err(CliError::from),
250        "help" | "--help" | "-h" => {
251            println!("{}", usage());
252            Ok(())
253        }
254        _ => Err(CliError::Usage(usage())),
255    }
256}
257
258/// Build the top-level command metadata.
259#[must_use]
260pub fn top_level_command() -> Command {
261    let command = Command::new("canic")
262        .version(env!("CARGO_PKG_VERSION"))
263        .about("Operator CLI for Canic install, backup, and restore workflows")
264        .disable_version_flag(true)
265        .arg(
266            Arg::new("version")
267                .short('V')
268                .long("version")
269                .action(ArgAction::SetTrue)
270                .help("Print version"),
271        )
272        .subcommand_help_heading("Commands")
273        .help_template(TOP_LEVEL_HELP_TEMPLATE)
274        .before_help(grouped_command_section(COMMAND_SPECS).join("\n"))
275        .after_help("Run `canic <command> help` for command-specific help.");
276
277    COMMAND_SPECS.iter().fold(command, |command, spec| {
278        command.subcommand(Command::new(spec.name).about(spec.about))
279    })
280}
281
282/// Return the CLI version banner.
283#[must_use]
284pub const fn version_text() -> &'static str {
285    VERSION_TEXT
286}
287
288// Return the top-level usage text.
289fn usage() -> String {
290    let mut lines = vec![
291        color(
292            COLOR_HEADING,
293            &format!("Canic Operator CLI v{}", env!("CARGO_PKG_VERSION")),
294        ),
295        String::new(),
296        "Usage: canic [OPTIONS] <COMMAND>".to_string(),
297        String::new(),
298        color(COLOR_HEADING, "Commands:"),
299    ];
300    lines.extend(grouped_command_section(COMMAND_SPECS));
301    lines.extend([
302        String::new(),
303        color(COLOR_HEADING, "Options:"),
304        "  -V, --version  Print version".to_string(),
305        "  -h, --help     Print help".to_string(),
306        String::new(),
307        format!(
308            "{}Tip:{} Run {} for command-specific help.",
309            COLOR_TIP,
310            COLOR_RESET,
311            color(COLOR_COMMAND, "`canic <command> help`")
312        ),
313    ]);
314    lines.join("\n")
315}
316
317// Render grouped command rows from the same metadata used to build Clap subcommands.
318fn grouped_command_section(specs: &[CommandSpec]) -> Vec<String> {
319    let mut lines = Vec::new();
320    let scopes = [
321        CommandScope::Global,
322        CommandScope::FleetContext,
323        CommandScope::WorkspaceFiles,
324    ];
325    for (index, scope) in scopes.into_iter().enumerate() {
326        lines.push(format!("  {}", color(COLOR_GROUP, scope.heading())));
327        for spec in specs.iter().filter(|spec| spec.scope == scope) {
328            let command = format!("{:<12}", spec.name);
329            lines.push(format!(
330                "    {} {}",
331                color(COLOR_COMMAND, &command),
332                spec.about
333            ));
334        }
335        if index + 1 < scopes.len() {
336            lines.push(String::new());
337        }
338    }
339    lines
340}
341
342// Wrap one help fragment in an ANSI color sequence.
343fn color(code: &str, text: &str) -> String {
344    format!("{code}{text}{COLOR_RESET}")
345}
346
347#[cfg(test)]
348mod tests {
349    use super::*;
350
351    // Ensure top-level help stays compact as command surfaces grow.
352    #[test]
353    fn usage_lists_command_families() {
354        let text = usage();
355        let plain = strip_ansi(&text);
356
357        assert!(plain.contains(&format!(
358            "Canic Operator CLI v{}",
359            env!("CARGO_PKG_VERSION")
360        )));
361        assert!(plain.contains("Usage: canic [OPTIONS] <COMMAND>"));
362        assert!(plain.contains("\nCommands:\n"));
363        assert!(plain.contains("Global commands"));
364        assert!(plain.contains("Fleet commands"));
365        assert!(plain.contains("Workspace and file commands"));
366        assert!(plain.find("    fleet") < plain.find("    install"));
367        assert!(plain.find("    install") < plain.find("    config"));
368        assert!(plain.find("    config") < plain.find("    list"));
369        assert!(plain.contains("Options:"));
370        assert!(!plain.contains("    scaffold"));
371        assert!(plain.contains("config"));
372        assert!(plain.contains("list"));
373        assert!(plain.contains("build"));
374        assert!(!plain.contains("    network"));
375        assert!(!plain.contains("    defaults"));
376        assert!(!plain.contains("    status"));
377        assert!(plain.contains("fleet"));
378        assert!(plain.contains("install"));
379        assert!(plain.contains("snapshot"));
380        assert!(plain.contains("backup"));
381        assert!(plain.contains("manifest"));
382        assert!(plain.contains("medic"));
383        assert!(plain.contains("restore"));
384        assert!(plain.contains("Tip: Run `canic <command> help`"));
385        assert!(text.contains(COLOR_HEADING));
386        assert!(text.contains(COLOR_GROUP));
387        assert!(text.contains(COLOR_COMMAND));
388    }
389
390    // Ensure command-family help paths return successfully instead of erroring.
391    #[test]
392    fn command_family_help_returns_ok() {
393        for args in [
394            &["backup", "help"][..],
395            &["backup", "list", "help"],
396            &["backup", "status", "help"],
397            &["backup", "verify", "help"],
398            &["build", "help"],
399            &["config", "help"],
400            &["install", "help"],
401            &["fleet"],
402            &["fleet", "help"],
403            &["fleet", "create", "help"],
404            &["fleet", "list", "help"],
405            &["fleet", "delete", "help"],
406            &["list", "help"],
407            &["restore", "help"],
408            &["restore", "plan", "help"],
409            &["restore", "apply", "help"],
410            &["restore", "run", "help"],
411            &["manifest", "help"],
412            &["manifest", "validate", "help"],
413            &["medic", "help"],
414            &["snapshot", "help"],
415            &["snapshot", "download", "help"],
416        ] {
417            assert_run_ok(args);
418        }
419    }
420
421    // Ensure version flags are accepted at the top level and command-family level.
422    #[test]
423    fn version_flags_return_ok() {
424        assert_eq!(version_text(), concat!("canic ", env!("CARGO_PKG_VERSION")));
425        assert!(run([OsString::from("--version")]).is_ok());
426        assert!(
427            run([
428                OsString::from("backup"),
429                OsString::from("list"),
430                OsString::from("--dir"),
431                OsString::from("version")
432            ])
433            .is_ok()
434        );
435        assert!(run([OsString::from("backup"), OsString::from("--version")]).is_ok());
436        assert!(
437            run([
438                OsString::from("backup"),
439                OsString::from("list"),
440                OsString::from("--version")
441            ])
442            .is_ok()
443        );
444        assert!(run([OsString::from("build"), OsString::from("--version")]).is_ok());
445        assert!(run([OsString::from("config"), OsString::from("--version")]).is_ok());
446        assert!(run([OsString::from("install"), OsString::from("--version")]).is_ok());
447        assert!(run([OsString::from("fleet"), OsString::from("--version")]).is_ok());
448        assert!(
449            run([
450                OsString::from("fleet"),
451                OsString::from("create"),
452                OsString::from("--version")
453            ])
454            .is_ok()
455        );
456        assert!(run([OsString::from("list"), OsString::from("--version")]).is_ok());
457        assert!(run([OsString::from("restore"), OsString::from("--version")]).is_ok());
458        assert!(run([OsString::from("manifest"), OsString::from("--version")]).is_ok());
459        assert!(run([OsString::from("medic"), OsString::from("--version")]).is_ok());
460        assert!(run([OsString::from("snapshot"), OsString::from("--version")]).is_ok());
461        assert!(
462            run([
463                OsString::from("snapshot"),
464                OsString::from("download"),
465                OsString::from("--version")
466            ])
467            .is_ok()
468        );
469    }
470
471    // Remove ANSI color sequences so tests can assert help structure.
472    fn strip_ansi(text: &str) -> String {
473        let mut plain = String::new();
474        let mut chars = text.chars().peekable();
475        while let Some(ch) = chars.next() {
476            if ch == '\x1b' && chars.peek() == Some(&'[') {
477                chars.next();
478                for ch in chars.by_ref() {
479                    if ch == 'm' {
480                        break;
481                    }
482                }
483                continue;
484            }
485            plain.push(ch);
486        }
487        plain
488    }
489
490    // Assert that a CLI argv slice returns successfully.
491    fn assert_run_ok(raw_args: &[&str]) {
492        let args = raw_args.iter().map(OsString::from).collect::<Vec<_>>();
493        assert!(
494            run(args).is_ok(),
495            "expected successful run for {raw_args:?}"
496        );
497    }
498}