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 release_set;
11mod restore;
12mod scaffold;
13mod snapshot;
14#[cfg(test)]
15mod test_support;
16
17use crate::args::any_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";
24
25///
26/// CommandScope
27///
28
29#[derive(Clone, Copy, Debug, Eq, PartialEq)]
30enum CommandScope {
31    ProjectSetup,
32    MultiFleet,
33    SingleFleet,
34    SingleCanister,
35}
36
37impl CommandScope {
38    // Return the heading used in grouped top-level help.
39    const fn heading(self) -> &'static str {
40        match self {
41            Self::ProjectSetup => "Project setup commands",
42            Self::MultiFleet => "Multi-fleet commands",
43            Self::SingleFleet => "Single-fleet commands",
44            Self::SingleCanister => "Single-canister commands",
45        }
46    }
47}
48
49///
50/// CommandSpec
51///
52
53#[derive(Clone, Copy, Debug, Eq, PartialEq)]
54struct CommandSpec {
55    name: &'static str,
56    about: &'static str,
57    scope: CommandScope,
58}
59
60const COMMAND_SPECS: &[CommandSpec] = &[
61    CommandSpec {
62        name: "scaffold",
63        about: "Create a minimal Canic fleet scaffold",
64        scope: CommandScope::ProjectSetup,
65    },
66    CommandSpec {
67        name: "fleets",
68        about: "List installed Canic fleets",
69        scope: CommandScope::MultiFleet,
70    },
71    CommandSpec {
72        name: "use",
73        about: "Select the current Canic fleet",
74        scope: CommandScope::MultiFleet,
75    },
76    CommandSpec {
77        name: "install",
78        about: "Install and bootstrap a Canic fleet",
79        scope: CommandScope::SingleFleet,
80    },
81    CommandSpec {
82        name: "list",
83        about: "Show registry canisters as a tree table",
84        scope: CommandScope::SingleFleet,
85    },
86    CommandSpec {
87        name: "backup",
88        about: "Verify backup directories and journal status",
89        scope: CommandScope::SingleFleet,
90    },
91    CommandSpec {
92        name: "manifest",
93        about: "Validate fleet backup manifests",
94        scope: CommandScope::SingleFleet,
95    },
96    CommandSpec {
97        name: "medic",
98        about: "Diagnose local Canic fleet setup",
99        scope: CommandScope::SingleFleet,
100    },
101    CommandSpec {
102        name: "release-set",
103        about: "Inspect, emit, or stage root release-set artifacts",
104        scope: CommandScope::SingleFleet,
105    },
106    CommandSpec {
107        name: "restore",
108        about: "Plan or run snapshot restores",
109        scope: CommandScope::SingleFleet,
110    },
111    CommandSpec {
112        name: "build",
113        about: "Build one Canic canister artifact",
114        scope: CommandScope::SingleCanister,
115    },
116    CommandSpec {
117        name: "snapshot",
118        about: "Capture and download canister snapshots",
119        scope: CommandScope::SingleCanister,
120    },
121];
122
123///
124/// CliError
125///
126
127#[derive(Debug, ThisError)]
128pub enum CliError {
129    #[error("{0}")]
130    Usage(String),
131
132    #[error("backup: {0}")]
133    Backup(String),
134
135    #[error("build: {0}")]
136    Build(String),
137
138    #[error("install: {0}")]
139    Install(String),
140
141    #[error("fleets: {0}")]
142    Fleets(String),
143
144    #[error("list: {0}")]
145    List(String),
146
147    #[error("manifest: {0}")]
148    Manifest(String),
149
150    #[error("medic: {0}")]
151    Medic(String),
152
153    #[error("snapshot: {0}")]
154    Snapshot(String),
155
156    #[error("release-set: {0}")]
157    ReleaseSet(String),
158
159    #[error("restore: {0}")]
160    Restore(String),
161
162    #[error("scaffold: {0}")]
163    Scaffold(String),
164}
165
166impl From<backup::BackupCommandError> for CliError {
167    // Keep backup command internals private while preserving operator-facing messages.
168    fn from(err: backup::BackupCommandError) -> Self {
169        Self::Backup(err.to_string())
170    }
171}
172
173impl From<build::BuildCommandError> for CliError {
174    // Keep build command internals private while preserving operator-facing messages.
175    fn from(err: build::BuildCommandError) -> Self {
176        Self::Build(err.to_string())
177    }
178}
179
180impl From<install::InstallCommandError> for CliError {
181    // Keep install command internals private while preserving operator-facing messages.
182    fn from(err: install::InstallCommandError) -> Self {
183        Self::Install(err.to_string())
184    }
185}
186
187impl From<fleets::FleetCommandError> for CliError {
188    // Keep fleet command internals private while preserving operator-facing messages.
189    fn from(err: fleets::FleetCommandError) -> Self {
190        Self::Fleets(err.to_string())
191    }
192}
193
194impl From<list::ListCommandError> for CliError {
195    // Keep list command internals private while preserving operator-facing messages.
196    fn from(err: list::ListCommandError) -> Self {
197        Self::List(err.to_string())
198    }
199}
200
201impl From<manifest::ManifestCommandError> for CliError {
202    // Keep manifest command internals private while preserving operator-facing messages.
203    fn from(err: manifest::ManifestCommandError) -> Self {
204        Self::Manifest(err.to_string())
205    }
206}
207
208impl From<medic::MedicCommandError> for CliError {
209    // Keep medic command internals private while preserving operator-facing messages.
210    fn from(err: medic::MedicCommandError) -> Self {
211        Self::Medic(err.to_string())
212    }
213}
214
215impl From<snapshot::SnapshotCommandError> for CliError {
216    // Keep snapshot command internals private while preserving operator-facing messages.
217    fn from(err: snapshot::SnapshotCommandError) -> Self {
218        Self::Snapshot(err.to_string())
219    }
220}
221
222impl From<release_set::ReleaseSetCommandError> for CliError {
223    // Keep release-set command internals private while preserving operator-facing messages.
224    fn from(err: release_set::ReleaseSetCommandError) -> Self {
225        Self::ReleaseSet(err.to_string())
226    }
227}
228
229impl From<restore::RestoreCommandError> for CliError {
230    // Keep restore command internals private while preserving operator-facing messages.
231    fn from(err: restore::RestoreCommandError) -> Self {
232        Self::Restore(err.to_string())
233    }
234}
235
236impl From<scaffold::ScaffoldCommandError> for CliError {
237    // Keep scaffold command internals private while preserving operator-facing messages.
238    fn from(err: scaffold::ScaffoldCommandError) -> Self {
239        Self::Scaffold(err.to_string())
240    }
241}
242
243/// Run the CLI from process arguments.
244pub fn run_from_env() -> Result<(), CliError> {
245    run(std::env::args_os().skip(1))
246}
247
248/// Run the CLI from an argument iterator.
249pub fn run<I>(args: I) -> Result<(), CliError>
250where
251    I: IntoIterator<Item = OsString>,
252{
253    let args = args.into_iter().collect::<Vec<_>>();
254    if any_arg_is_version(&args) {
255        println!("{}", version_text());
256        return Ok(());
257    }
258
259    let mut args = args.into_iter();
260    let Some(command) = args.next().and_then(|arg| arg.into_string().ok()) else {
261        return Err(CliError::Usage(usage()));
262    };
263
264    match command.as_str() {
265        "backup" => backup::run(args).map_err(CliError::from),
266        "build" => build::run(args).map_err(CliError::from),
267        "fleets" => fleets::run(args).map_err(CliError::from),
268        "install" => install::run(args).map_err(CliError::from),
269        "list" => list::run(args).map_err(CliError::from),
270        "manifest" => manifest::run(args).map_err(CliError::from),
271        "medic" => medic::run(args).map_err(CliError::from),
272        "release-set" => release_set::run(args).map_err(CliError::from),
273        "scaffold" => scaffold::run(args).map_err(CliError::from),
274        "snapshot" => snapshot::run(args).map_err(CliError::from),
275        "restore" => restore::run(args).map_err(CliError::from),
276        "use" => fleets::run_use(args).map_err(CliError::from),
277        "help" | "--help" | "-h" => {
278            println!("{}", usage());
279            Ok(())
280        }
281        _ => Err(CliError::Usage(usage())),
282    }
283}
284
285/// Build the top-level command metadata.
286#[must_use]
287pub fn top_level_command() -> Command {
288    let command = Command::new("canic")
289        .version(env!("CARGO_PKG_VERSION"))
290        .about("Operator CLI for Canic install, backup, and restore workflows")
291        .disable_version_flag(true)
292        .arg(
293            Arg::new("version")
294                .short('V')
295                .long("version")
296                .action(ArgAction::SetTrue)
297                .help("Print version"),
298        )
299        .subcommand_help_heading("Commands")
300        .help_template(TOP_LEVEL_HELP_TEMPLATE)
301        .before_help(grouped_command_section(COMMAND_SPECS))
302        .after_help("Run `canic <command> help` for command-specific help.");
303
304    COMMAND_SPECS.iter().fold(command, |command, spec| {
305        command.subcommand(Command::new(spec.name).about(spec.about))
306    })
307}
308
309/// Return the CLI version banner.
310#[must_use]
311pub const fn version_text() -> &'static str {
312    VERSION_TEXT
313}
314
315// Return the top-level usage text.
316fn usage() -> String {
317    let mut command = top_level_command();
318    command.render_help().to_string()
319}
320
321// Render grouped command rows from the same metadata used to build Clap subcommands.
322fn grouped_command_section(specs: &[CommandSpec]) -> String {
323    let mut lines = Vec::new();
324    let scopes = [
325        CommandScope::ProjectSetup,
326        CommandScope::MultiFleet,
327        CommandScope::SingleFleet,
328        CommandScope::SingleCanister,
329    ];
330    for (index, scope) in scopes.into_iter().enumerate() {
331        lines.push(format!("{}:", scope.heading()));
332        for spec in specs.iter().filter(|spec| spec.scope == scope) {
333            lines.push(format!("  {:<11} {}", spec.name, spec.about));
334        }
335        if index + 1 < scopes.len() {
336            lines.push(String::new());
337        }
338    }
339    lines.join("\n")
340}
341
342#[cfg(test)]
343mod tests {
344    use super::*;
345
346    // Ensure top-level help stays compact as command surfaces grow.
347    #[test]
348    fn usage_lists_command_families() {
349        let text = usage();
350
351        assert!(text.contains(version_text()));
352        assert!(text.contains("Usage: canic"));
353        assert!(text.contains("Project setup commands"));
354        assert!(text.contains("Multi-fleet commands"));
355        assert!(text.contains("Single-fleet commands"));
356        assert!(text.contains("Single-canister commands"));
357        assert!(!text.contains("\nCommands:\n"));
358        assert!(text.find("Project setup commands") < text.find("Multi-fleet commands"));
359        assert!(text.find("Multi-fleet commands") < text.find("Single-fleet commands"));
360        assert!(text.find("Single-fleet commands") < text.find("Single-canister commands"));
361        assert!(text.contains("scaffold"));
362        assert!(text.contains("list"));
363        assert!(text.contains("build"));
364        assert!(text.contains("fleets"));
365        assert!(text.contains("use"));
366        assert!(text.contains("install"));
367        assert!(text.contains("snapshot"));
368        assert!(text.contains("backup"));
369        assert!(text.contains("manifest"));
370        assert!(text.contains("medic"));
371        assert!(text.contains("release-set"));
372        assert!(text.contains("restore"));
373        assert!(text.contains("canic <command> help"));
374    }
375
376    // Ensure command-family help paths return successfully instead of erroring.
377    #[test]
378    fn command_family_help_returns_ok() {
379        assert!(run([OsString::from("backup"), OsString::from("help")]).is_ok());
380        assert!(run([OsString::from("build"), OsString::from("help")]).is_ok());
381        assert!(run([OsString::from("install"), OsString::from("help")]).is_ok());
382        assert!(run([OsString::from("fleets"), OsString::from("help")]).is_ok());
383        assert!(run([OsString::from("list"), OsString::from("help")]).is_ok());
384        assert!(run([OsString::from("restore"), OsString::from("help")]).is_ok());
385        assert!(run([OsString::from("manifest"), OsString::from("help")]).is_ok());
386        assert!(run([OsString::from("medic"), OsString::from("help")]).is_ok());
387        assert!(run([OsString::from("release-set"), OsString::from("help")]).is_ok());
388        assert!(run([OsString::from("scaffold"), OsString::from("help")]).is_ok());
389        assert!(run([OsString::from("snapshot"), OsString::from("help")]).is_ok());
390        assert!(run([OsString::from("use"), OsString::from("help")]).is_ok());
391    }
392
393    // Ensure version flags are accepted at the top level and command-family level.
394    #[test]
395    fn version_flags_return_ok() {
396        assert_eq!(version_text(), concat!("canic ", env!("CARGO_PKG_VERSION")));
397        assert!(run([OsString::from("--version")]).is_ok());
398        assert!(run([OsString::from("backup"), OsString::from("--version")]).is_ok());
399        assert!(run([OsString::from("build"), OsString::from("--version")]).is_ok());
400        assert!(run([OsString::from("install"), OsString::from("--version")]).is_ok());
401        assert!(run([OsString::from("fleets"), OsString::from("--version")]).is_ok());
402        assert!(run([OsString::from("list"), OsString::from("--version")]).is_ok());
403        assert!(run([OsString::from("restore"), OsString::from("--version")]).is_ok());
404        assert!(run([OsString::from("manifest"), OsString::from("--version")]).is_ok());
405        assert!(run([OsString::from("medic"), OsString::from("--version")]).is_ok());
406        assert!(run([OsString::from("release-set"), OsString::from("--version")]).is_ok());
407        assert!(run([OsString::from("scaffold"), OsString::from("--version")]).is_ok());
408        assert!(run([OsString::from("snapshot"), OsString::from("--version")]).is_ok());
409        assert!(run([OsString::from("use"), OsString::from("--version")]).is_ok());
410    }
411}