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