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