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;
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    Defaults,
38    FleetContext,
39    WorkspaceFiles,
40}
41
42impl CommandScope {
43    // Return the heading used in grouped top-level help.
44    const fn heading(self) -> &'static str {
45        match self {
46            Self::Defaults => "Default context commands",
47            Self::FleetContext => "Current network + 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 current Canic defaults",
68        scope: CommandScope::Defaults,
69    },
70    CommandSpec {
71        name: "network",
72        about: "Show or select the current default network",
73        scope: CommandScope::Defaults,
74    },
75    CommandSpec {
76        name: "fleet",
77        about: "Show, list, select, or delete Canic fleets",
78        scope: CommandScope::Defaults,
79    },
80    CommandSpec {
81        name: "scaffold",
82        about: "Create a minimal Canic fleet scaffold",
83        scope: CommandScope::Defaults,
84    },
85    CommandSpec {
86        name: "install",
87        about: "Install and bootstrap a Canic fleet",
88        scope: CommandScope::FleetContext,
89    },
90    CommandSpec {
91        name: "list",
92        about: "Show registry canisters as a tree table",
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("install: {0}")]
143    Install(String),
144
145    #[error("fleet: {0}")]
146    Fleets(String),
147
148    #[error("list: {0}")]
149    List(String),
150
151    #[error("manifest: {0}")]
152    Manifest(String),
153
154    #[error("medic: {0}")]
155    Medic(String),
156
157    #[error("network: {0}")]
158    Network(String),
159
160    #[error("snapshot: {0}")]
161    Snapshot(String),
162
163    #[error("status: {0}")]
164    Status(String),
165
166    #[error("restore: {0}")]
167    Restore(String),
168
169    #[error("scaffold: {0}")]
170    Scaffold(String),
171}
172
173impl From<backup::BackupCommandError> for CliError {
174    // Keep backup command internals private while preserving operator-facing messages.
175    fn from(err: backup::BackupCommandError) -> Self {
176        Self::Backup(err.to_string())
177    }
178}
179
180impl From<build::BuildCommandError> for CliError {
181    // Keep build command internals private while preserving operator-facing messages.
182    fn from(err: build::BuildCommandError) -> Self {
183        Self::Build(err.to_string())
184    }
185}
186
187impl From<install::InstallCommandError> for CliError {
188    // Keep install command internals private while preserving operator-facing messages.
189    fn from(err: install::InstallCommandError) -> Self {
190        Self::Install(err.to_string())
191    }
192}
193
194impl From<fleets::FleetCommandError> for CliError {
195    // Keep fleet command internals private while preserving operator-facing messages.
196    fn from(err: fleets::FleetCommandError) -> Self {
197        Self::Fleets(err.to_string())
198    }
199}
200
201impl From<list::ListCommandError> for CliError {
202    // Keep list command internals private while preserving operator-facing messages.
203    fn from(err: list::ListCommandError) -> Self {
204        Self::List(err.to_string())
205    }
206}
207
208impl From<manifest::ManifestCommandError> for CliError {
209    // Keep manifest command internals private while preserving operator-facing messages.
210    fn from(err: manifest::ManifestCommandError) -> Self {
211        Self::Manifest(err.to_string())
212    }
213}
214
215impl From<medic::MedicCommandError> for CliError {
216    // Keep medic command internals private while preserving operator-facing messages.
217    fn from(err: medic::MedicCommandError) -> Self {
218        Self::Medic(err.to_string())
219    }
220}
221
222impl From<network::NetworkCommandError> for CliError {
223    // Keep network command internals private while preserving operator-facing messages.
224    fn from(err: network::NetworkCommandError) -> Self {
225        Self::Network(err.to_string())
226    }
227}
228
229impl From<snapshot::SnapshotCommandError> for CliError {
230    // Keep snapshot command internals private while preserving operator-facing messages.
231    fn from(err: snapshot::SnapshotCommandError) -> Self {
232        Self::Snapshot(err.to_string())
233    }
234}
235
236impl From<status::StatusCommandError> for CliError {
237    // Keep status command internals private while preserving operator-facing messages.
238    fn from(err: status::StatusCommandError) -> Self {
239        Self::Status(err.to_string())
240    }
241}
242
243impl From<restore::RestoreCommandError> for CliError {
244    // Keep restore command internals private while preserving operator-facing messages.
245    fn from(err: restore::RestoreCommandError) -> Self {
246        Self::Restore(err.to_string())
247    }
248}
249
250impl From<scaffold::ScaffoldCommandError> for CliError {
251    // Keep scaffold command internals private while preserving operator-facing messages.
252    fn from(err: scaffold::ScaffoldCommandError) -> Self {
253        Self::Scaffold(err.to_string())
254    }
255}
256
257/// Run the CLI from process arguments.
258pub fn run_from_env() -> Result<(), CliError> {
259    run(std::env::args_os().skip(1))
260}
261
262/// Run the CLI from an argument iterator.
263pub fn run<I>(args: I) -> Result<(), CliError>
264where
265    I: IntoIterator<Item = OsString>,
266{
267    let args = args.into_iter().collect::<Vec<_>>();
268    if first_arg_is_version(&args) {
269        println!("{}", version_text());
270        return Ok(());
271    }
272
273    let mut args = args.into_iter();
274    let Some(command) = args.next().and_then(|arg| arg.into_string().ok()) else {
275        return Err(CliError::Usage(usage()));
276    };
277
278    match command.as_str() {
279        "backup" => backup::run(args).map_err(CliError::from),
280        "build" => build::run(args).map_err(CliError::from),
281        "fleet" => fleets::run(args).map_err(CliError::from),
282        "install" => install::run(args).map_err(CliError::from),
283        "list" => list::run(args).map_err(CliError::from),
284        "manifest" => manifest::run(args).map_err(CliError::from),
285        "medic" => medic::run(args).map_err(CliError::from),
286        "network" => network::run(args).map_err(CliError::from),
287        "scaffold" => scaffold::run(args).map_err(CliError::from),
288        "snapshot" => snapshot::run(args).map_err(CliError::from),
289        "status" => status::run(args).map_err(CliError::from),
290        "restore" => restore::run(args).map_err(CliError::from),
291        "help" | "--help" | "-h" => {
292            println!("{}", usage());
293            Ok(())
294        }
295        _ => Err(CliError::Usage(usage())),
296    }
297}
298
299/// Build the top-level command metadata.
300#[must_use]
301pub fn top_level_command() -> Command {
302    let command = Command::new("canic")
303        .version(env!("CARGO_PKG_VERSION"))
304        .about("Operator CLI for Canic install, backup, and restore workflows")
305        .disable_version_flag(true)
306        .arg(
307            Arg::new("version")
308                .short('V')
309                .long("version")
310                .action(ArgAction::SetTrue)
311                .help("Print version"),
312        )
313        .subcommand_help_heading("Commands")
314        .help_template(TOP_LEVEL_HELP_TEMPLATE)
315        .before_help(grouped_command_section(COMMAND_SPECS).join("\n"))
316        .after_help("Run `canic <command> help` for command-specific help.");
317
318    COMMAND_SPECS.iter().fold(command, |command, spec| {
319        command.subcommand(Command::new(spec.name).about(spec.about))
320    })
321}
322
323/// Return the CLI version banner.
324#[must_use]
325pub const fn version_text() -> &'static str {
326    VERSION_TEXT
327}
328
329// Return the top-level usage text.
330fn usage() -> String {
331    let mut lines = vec![
332        color(
333            COLOR_HEADING,
334            &format!("Canic Operator CLI v{}", env!("CARGO_PKG_VERSION")),
335        ),
336        String::new(),
337        "Usage: canic [OPTIONS] <COMMAND>".to_string(),
338        String::new(),
339        color(COLOR_HEADING, "Commands:"),
340    ];
341    lines.extend(grouped_command_section(COMMAND_SPECS));
342    lines.extend([
343        String::new(),
344        color(COLOR_HEADING, "Options:"),
345        "  -V, --version  Print version".to_string(),
346        "  -h, --help     Print help".to_string(),
347        String::new(),
348        format!(
349            "{}Tip:{} Run {} for command-specific help.",
350            COLOR_TIP,
351            COLOR_RESET,
352            color(COLOR_COMMAND, "`canic <command> help`")
353        ),
354    ]);
355    lines.join("\n")
356}
357
358// Render grouped command rows from the same metadata used to build Clap subcommands.
359fn grouped_command_section(specs: &[CommandSpec]) -> Vec<String> {
360    let mut lines = Vec::new();
361    let scopes = [
362        CommandScope::Defaults,
363        CommandScope::FleetContext,
364        CommandScope::WorkspaceFiles,
365    ];
366    for (index, scope) in scopes.into_iter().enumerate() {
367        lines.push(format!("  {}", color(COLOR_GROUP, scope.heading())));
368        for spec in specs.iter().filter(|spec| spec.scope == scope) {
369            let command = format!("{:<12}", spec.name);
370            lines.push(format!(
371                "    {} {}",
372                color(COLOR_COMMAND, &command),
373                spec.about
374            ));
375        }
376        if index + 1 < scopes.len() {
377            lines.push(String::new());
378        }
379    }
380    lines
381}
382
383// Wrap one help fragment in an ANSI color sequence.
384fn color(code: &str, text: &str) -> String {
385    format!("{code}{text}{COLOR_RESET}")
386}
387
388#[cfg(test)]
389mod tests {
390    use super::*;
391
392    // Ensure top-level help stays compact as command surfaces grow.
393    #[test]
394    fn usage_lists_command_families() {
395        let text = usage();
396        let plain = strip_ansi(&text);
397
398        assert!(plain.contains(&format!(
399            "Canic Operator CLI v{}",
400            env!("CARGO_PKG_VERSION")
401        )));
402        assert!(plain.contains("Usage: canic [OPTIONS] <COMMAND>"));
403        assert!(plain.contains("\nCommands:\n"));
404        assert!(plain.contains("Default context commands"));
405        assert!(plain.contains("Current network + fleet commands"));
406        assert!(plain.contains("Workspace and file commands"));
407        assert!(
408            plain.find("Default context commands") < plain.find("Current network + fleet commands")
409        );
410        assert!(
411            plain.find("Current network + fleet commands")
412                < plain.find("Workspace and file commands")
413        );
414        assert!(plain.find("    status") < plain.find("    network"));
415        assert!(plain.find("    network") < plain.find("    fleet"));
416        assert!(plain.find("    fleet") < plain.find("    scaffold"));
417        assert!(plain.find("    scaffold") < plain.find("    install"));
418        assert!(plain.contains("Options:"));
419        assert!(plain.contains("scaffold"));
420        assert!(plain.contains("list"));
421        assert!(plain.contains("build"));
422        assert!(plain.contains("network"));
423        assert!(plain.contains("status"));
424        assert!(plain.contains("fleet"));
425        assert!(plain.contains("install"));
426        assert!(plain.contains("snapshot"));
427        assert!(plain.contains("backup"));
428        assert!(plain.contains("manifest"));
429        assert!(plain.contains("medic"));
430        assert!(plain.contains("restore"));
431        assert!(plain.contains("Tip: Run `canic <command> help`"));
432        assert!(text.contains(COLOR_HEADING));
433        assert!(text.contains(COLOR_GROUP));
434        assert!(text.contains(COLOR_COMMAND));
435    }
436
437    // Ensure command-family help paths return successfully instead of erroring.
438    #[test]
439    fn command_family_help_returns_ok() {
440        assert!(run([OsString::from("backup"), OsString::from("help")]).is_ok());
441        assert!(
442            run([
443                OsString::from("backup"),
444                OsString::from("list"),
445                OsString::from("help")
446            ])
447            .is_ok()
448        );
449        assert!(
450            run([
451                OsString::from("backup"),
452                OsString::from("status"),
453                OsString::from("help")
454            ])
455            .is_ok()
456        );
457        assert!(
458            run([
459                OsString::from("backup"),
460                OsString::from("verify"),
461                OsString::from("help")
462            ])
463            .is_ok()
464        );
465        assert!(run([OsString::from("build"), OsString::from("help")]).is_ok());
466        assert!(run([OsString::from("install"), OsString::from("help")]).is_ok());
467        assert!(run([OsString::from("fleet"), OsString::from("help")]).is_ok());
468        assert!(
469            run([
470                OsString::from("fleet"),
471                OsString::from("list"),
472                OsString::from("help")
473            ])
474            .is_ok()
475        );
476        assert!(
477            run([
478                OsString::from("fleet"),
479                OsString::from("use"),
480                OsString::from("help")
481            ])
482            .is_ok()
483        );
484        assert!(
485            run([
486                OsString::from("fleet"),
487                OsString::from("delete"),
488                OsString::from("help")
489            ])
490            .is_ok()
491        );
492        assert!(run([OsString::from("list"), OsString::from("help")]).is_ok());
493        assert!(run([OsString::from("restore"), OsString::from("help")]).is_ok());
494        assert!(
495            run([
496                OsString::from("restore"),
497                OsString::from("plan"),
498                OsString::from("help")
499            ])
500            .is_ok()
501        );
502        assert!(
503            run([
504                OsString::from("restore"),
505                OsString::from("apply"),
506                OsString::from("help")
507            ])
508            .is_ok()
509        );
510        assert!(
511            run([
512                OsString::from("restore"),
513                OsString::from("run"),
514                OsString::from("help")
515            ])
516            .is_ok()
517        );
518        assert!(run([OsString::from("manifest"), OsString::from("help")]).is_ok());
519        assert!(
520            run([
521                OsString::from("manifest"),
522                OsString::from("validate"),
523                OsString::from("help")
524            ])
525            .is_ok()
526        );
527        assert!(run([OsString::from("medic"), OsString::from("help")]).is_ok());
528        assert!(run([OsString::from("network"), OsString::from("help")]).is_ok());
529        assert!(run([OsString::from("scaffold"), OsString::from("help")]).is_ok());
530        assert!(run([OsString::from("snapshot"), OsString::from("help")]).is_ok());
531        assert!(
532            run([
533                OsString::from("snapshot"),
534                OsString::from("download"),
535                OsString::from("help")
536            ])
537            .is_ok()
538        );
539        assert!(run([OsString::from("status"), OsString::from("help")]).is_ok());
540    }
541
542    // Ensure version flags are accepted at the top level and command-family level.
543    #[test]
544    fn version_flags_return_ok() {
545        assert_eq!(version_text(), concat!("canic ", env!("CARGO_PKG_VERSION")));
546        assert!(run([OsString::from("--version")]).is_ok());
547        assert!(
548            run([
549                OsString::from("backup"),
550                OsString::from("list"),
551                OsString::from("--dir"),
552                OsString::from("version")
553            ])
554            .is_ok()
555        );
556        assert!(run([OsString::from("backup"), OsString::from("--version")]).is_ok());
557        assert!(
558            run([
559                OsString::from("backup"),
560                OsString::from("list"),
561                OsString::from("--version")
562            ])
563            .is_ok()
564        );
565        assert!(run([OsString::from("build"), OsString::from("--version")]).is_ok());
566        assert!(run([OsString::from("install"), OsString::from("--version")]).is_ok());
567        assert!(run([OsString::from("fleet"), OsString::from("--version")]).is_ok());
568        assert!(run([OsString::from("list"), OsString::from("--version")]).is_ok());
569        assert!(run([OsString::from("restore"), OsString::from("--version")]).is_ok());
570        assert!(run([OsString::from("manifest"), OsString::from("--version")]).is_ok());
571        assert!(run([OsString::from("medic"), OsString::from("--version")]).is_ok());
572        assert!(run([OsString::from("network"), OsString::from("--version")]).is_ok());
573        assert!(run([OsString::from("scaffold"), OsString::from("--version")]).is_ok());
574        assert!(run([OsString::from("snapshot"), OsString::from("--version")]).is_ok());
575        assert!(
576            run([
577                OsString::from("snapshot"),
578                OsString::from("download"),
579                OsString::from("--version")
580            ])
581            .is_ok()
582        );
583        assert!(run([OsString::from("status"), OsString::from("--version")]).is_ok());
584    }
585
586    // Remove ANSI color sequences so tests can assert help structure.
587    fn strip_ansi(text: &str) -> String {
588        let mut plain = String::new();
589        let mut chars = text.chars().peekable();
590        while let Some(ch) = chars.next() {
591            if ch == '\x1b' && chars.peek() == Some(&'[') {
592                chars.next();
593                for ch in chars.by_ref() {
594                    if ch == 'm' {
595                        break;
596                    }
597                }
598                continue;
599            }
600            plain.push(ch);
601        }
602        plain
603    }
604}