Skip to main content

canic_cli/cli/
help.rs

1use crate::cli::globals::{icp_arg, network_arg};
2use clap::{Arg, ArgAction, Command};
3use std::ffi::OsString;
4
5const 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";
6const COLOR_RESET: &str = "\x1b[0m";
7const COLOR_HEADING: &str = "\x1b[1m";
8const COLOR_GROUP: &str = "\x1b[38;5;245m";
9const COLOR_COMMAND: &str = "\x1b[38;5;109m";
10const COLOR_TIP: &str = "\x1b[38;5;245m";
11
12///
13/// CommandScope
14///
15
16#[derive(Clone, Copy, Debug, Eq, PartialEq)]
17pub enum CommandScope {
18    Global,
19    FleetContext,
20    BackupRestore,
21}
22
23impl CommandScope {
24    const fn heading(self) -> &'static str {
25        match self {
26            Self::Global => "Global commands",
27            Self::FleetContext => "Fleet commands",
28            Self::BackupRestore => "Backup and restore commands",
29        }
30    }
31}
32
33///
34/// CommandSpec
35///
36
37#[derive(Clone, Copy, Debug, Eq, PartialEq)]
38pub struct CommandSpec {
39    pub name: &'static str,
40    pub about: &'static str,
41    scope: CommandScope,
42}
43
44pub const COMMAND_SPECS: &[CommandSpec] = &[
45    CommandSpec {
46        name: "status",
47        about: "Show quick Canic project status",
48        scope: CommandScope::Global,
49    },
50    CommandSpec {
51        name: "fleet",
52        about: "Manage Canic fleets",
53        scope: CommandScope::Global,
54    },
55    CommandSpec {
56        name: "replica",
57        about: "Manage the local ICP replica",
58        scope: CommandScope::Global,
59    },
60    CommandSpec {
61        name: "install",
62        about: "Install and bootstrap a Canic fleet",
63        scope: CommandScope::FleetContext,
64    },
65    CommandSpec {
66        name: "build",
67        about: "Build one Canic canister artifact",
68        scope: CommandScope::FleetContext,
69    },
70    CommandSpec {
71        name: "config",
72        about: "Inspect selected fleet config",
73        scope: CommandScope::FleetContext,
74    },
75    CommandSpec {
76        name: "list",
77        about: "List deployed fleet canisters",
78        scope: CommandScope::FleetContext,
79    },
80    CommandSpec {
81        name: "endpoints",
82        about: "List canister Candid endpoints",
83        scope: CommandScope::FleetContext,
84    },
85    CommandSpec {
86        name: "medic",
87        about: "Diagnose local Canic fleet setup",
88        scope: CommandScope::FleetContext,
89    },
90    CommandSpec {
91        name: "cycles",
92        about: "Summarize fleet cycle history",
93        scope: CommandScope::FleetContext,
94    },
95    CommandSpec {
96        name: "metrics",
97        about: "Query Canic runtime telemetry",
98        scope: CommandScope::FleetContext,
99    },
100    CommandSpec {
101        name: "snapshot",
102        about: "Capture and download canister snapshots",
103        scope: CommandScope::BackupRestore,
104    },
105    CommandSpec {
106        name: "backup",
107        about: "Verify backup directories and journal status",
108        scope: CommandScope::BackupRestore,
109    },
110    CommandSpec {
111        name: "manifest",
112        about: "Validate fleet backup manifests",
113        scope: CommandScope::BackupRestore,
114    },
115    CommandSpec {
116        name: "restore",
117        about: "Plan or run snapshot restores",
118        scope: CommandScope::BackupRestore,
119    },
120];
121
122pub fn is_help_arg(arg: &OsString) -> bool {
123    arg.to_str()
124        .is_some_and(|arg| matches!(arg, "help" | "--help" | "-h"))
125}
126
127pub fn is_version_arg(arg: &OsString) -> bool {
128    arg.to_str()
129        .is_some_and(|arg| matches!(arg, "version" | "--version" | "-V"))
130}
131
132pub fn first_arg_is_help(args: &[OsString]) -> bool {
133    args.first().is_some_and(is_help_arg)
134}
135
136pub fn first_arg_is_version(args: &[OsString]) -> bool {
137    args.first().is_some_and(is_version_arg)
138}
139
140pub fn print_help_or_version(
141    args: &[OsString],
142    usage: impl FnOnce() -> String,
143    version_text: &str,
144) -> bool {
145    if first_arg_is_help(args) {
146        println!("{}", usage());
147        return true;
148    }
149    if first_arg_is_version(args) {
150        println!("{version_text}");
151        return true;
152    }
153    false
154}
155
156#[must_use]
157pub fn top_level_command() -> Command {
158    let command = Command::new("canic")
159        .version(env!("CARGO_PKG_VERSION"))
160        .about("Operator CLI for Canic install, backup, and restore workflows")
161        .disable_version_flag(true)
162        .arg(
163            Arg::new("version")
164                .short('V')
165                .long("version")
166                .action(ArgAction::SetTrue)
167                .help("Print version"),
168        )
169        .arg(icp_arg().global(true))
170        .arg(network_arg().global(true))
171        .subcommand_help_heading("Commands")
172        .help_template(TOP_LEVEL_HELP_TEMPLATE)
173        .before_help(grouped_command_section(COMMAND_SPECS).join("\n"))
174        .after_help("Run `canic <command> help` for command-specific help.");
175
176    COMMAND_SPECS.iter().fold(command, |command, spec| {
177        command.subcommand(Command::new(spec.name).about(spec.about))
178    })
179}
180
181pub fn usage() -> String {
182    let mut lines = vec![
183        color(
184            COLOR_HEADING,
185            &format!("Canic Operator CLI v{}", env!("CARGO_PKG_VERSION")),
186        ),
187        String::new(),
188        "Usage: canic [OPTIONS] <COMMAND>".to_string(),
189        String::new(),
190        color(COLOR_HEADING, "Commands:"),
191    ];
192    lines.extend(grouped_command_section(COMMAND_SPECS));
193    lines.extend([
194        String::new(),
195        color(COLOR_HEADING, "Options:"),
196        "      --icp <path>      Path to the icp executable for ICP-backed commands".to_string(),
197        "      --network <name>  ICP CLI network for networked commands".to_string(),
198        "  -V, --version  Print version".to_string(),
199        "  -h, --help     Print help".to_string(),
200        String::new(),
201        format!(
202            "{}Tip:{} Run {} for command-specific help.",
203            COLOR_TIP,
204            COLOR_RESET,
205            color(COLOR_COMMAND, "`canic <command> help`")
206        ),
207    ]);
208    lines.join("\n")
209}
210
211fn grouped_command_section(specs: &[CommandSpec]) -> Vec<String> {
212    let mut lines = Vec::new();
213    let scopes = [
214        CommandScope::Global,
215        CommandScope::FleetContext,
216        CommandScope::BackupRestore,
217    ];
218    for scope in scopes {
219        let scope_specs = specs
220            .iter()
221            .filter(|spec| spec.scope == scope)
222            .collect::<Vec<_>>();
223        if scope_specs.is_empty() {
224            continue;
225        }
226        if !lines.is_empty() {
227            lines.push(String::new());
228        }
229        lines.push(format!("  {}", color(COLOR_GROUP, scope.heading())));
230        for spec in scope_specs {
231            let command = format!("{:<12}", spec.name);
232            lines.push(format!(
233                "    {} {}",
234                color(COLOR_COMMAND, &command),
235                spec.about
236            ));
237        }
238    }
239    lines
240}
241
242fn color(code: &str, text: &str) -> String {
243    format!("{code}{text}{COLOR_RESET}")
244}
245
246#[cfg(test)]
247pub fn strip_ansi(text: &str) -> String {
248    let mut plain = String::new();
249    let mut chars = text.chars().peekable();
250    while let Some(ch) = chars.next() {
251        if ch == '\x1b' && chars.peek() == Some(&'[') {
252            chars.next();
253            for ch in chars.by_ref() {
254                if ch == 'm' {
255                    break;
256                }
257            }
258            continue;
259        }
260        plain.push(ch);
261    }
262    plain
263}
264
265#[cfg(test)]
266pub const fn color_heading() -> &'static str {
267    COLOR_HEADING
268}
269
270#[cfg(test)]
271pub const fn color_group() -> &'static str {
272    COLOR_GROUP
273}
274
275#[cfg(test)]
276pub const fn color_command() -> &'static str {
277    COLOR_COMMAND
278}