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