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