Skip to main content

canic_cli/
lib.rs

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