Skip to main content

canic_cli/cli/
help.rs

1//! Module: canic_cli::cli::help
2//!
3//! Responsibility: render top-level CLI help and detect help/version requests.
4//! Does not own: command execution, command-specific help text, or global option forwarding.
5//! Boundary: defines the top-level command catalog shared by help and dispatch.
6
7use crate::cli::globals::{icp_arg, network_arg};
8use clap::{Arg, ArgAction, Command};
9use std::ffi::OsString;
10
11const 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";
12const COLOR_RESET: &str = "\x1b[0m";
13const COLOR_HEADING: &str = "\x1b[1m";
14const COLOR_GROUP: &str = "\x1b[38;5;245m";
15const COLOR_COMMAND: &str = "\x1b[38;5;109m";
16const COLOR_TIP: &str = "\x1b[38;5;245m";
17
18/// Top-level help grouping for commands.
19
20#[derive(Clone, Copy, Debug, Eq, PartialEq)]
21enum CommandScope {
22    Project,
23    Deployment,
24    IcpWallet,
25    BackupRestore,
26}
27
28impl CommandScope {
29    const fn heading(self) -> &'static str {
30        match self {
31            Self::Project => "Project commands",
32            Self::Deployment => "Deployment commands",
33            Self::IcpWallet => "ICP wallet commands",
34            Self::BackupRestore => "Backup and restore commands",
35        }
36    }
37}
38
39/// One top-level command shown in help and accepted by dispatch.
40
41#[derive(Clone, Copy, Debug, Eq, PartialEq)]
42pub(super) struct CommandSpec {
43    pub(super) name: &'static str,
44    about: &'static str,
45    scope: CommandScope,
46}
47
48pub(super) const COMMAND_SPECS: &[CommandSpec] = &[
49    CommandSpec {
50        name: "status",
51        about: "Show quick Canic project status",
52        scope: CommandScope::Project,
53    },
54    CommandSpec {
55        name: "medic",
56        about: "Diagnose project and deployment preflight readiness",
57        scope: CommandScope::Project,
58    },
59    CommandSpec {
60        name: "state",
61        about: "Audit declared Canic state metadata",
62        scope: CommandScope::Project,
63    },
64    CommandSpec {
65        name: "fleet",
66        about: "Manage Canic fleets and roles",
67        scope: CommandScope::Project,
68    },
69    CommandSpec {
70        name: "scaffold",
71        about: "Scaffold Canic source files",
72        scope: CommandScope::Project,
73    },
74    CommandSpec {
75        name: "replica",
76        about: "Manage the local ICP replica",
77        scope: CommandScope::Project,
78    },
79    CommandSpec {
80        name: "install",
81        about: "Install and bootstrap a Canic fleet",
82        scope: CommandScope::Deployment,
83    },
84    CommandSpec {
85        name: "inspect",
86        about: "Inspect runtime-observed status for one deployed canister",
87        scope: CommandScope::Deployment,
88    },
89    CommandSpec {
90        name: "blob-storage",
91        about: "Inspect and provision blob-storage billing",
92        scope: CommandScope::Deployment,
93    },
94    CommandSpec {
95        name: "auth",
96        about: "Run delegated-auth operator workflows",
97        scope: CommandScope::Deployment,
98    },
99    CommandSpec {
100        name: "build",
101        about: "Build one Canic canister artifact",
102        scope: CommandScope::Deployment,
103    },
104    CommandSpec {
105        name: "deploy",
106        about: "Check, inspect, register, and install deployments",
107        scope: CommandScope::Deployment,
108    },
109    CommandSpec {
110        name: "evidence",
111        about: "Evaluate stable evidence envelopes",
112        scope: CommandScope::Deployment,
113    },
114    CommandSpec {
115        name: "cycles",
116        about: "Wrap ICP cycles balance and transfer commands",
117        scope: CommandScope::IcpWallet,
118    },
119    CommandSpec {
120        name: "token",
121        about: "Wrap ICP token balance and transfer commands",
122        scope: CommandScope::IcpWallet,
123    },
124    CommandSpec {
125        name: "info",
126        about: "Query deployed canister information",
127        scope: CommandScope::Deployment,
128    },
129    CommandSpec {
130        name: "snapshot",
131        about: "Capture and download canister snapshots",
132        scope: CommandScope::BackupRestore,
133    },
134    CommandSpec {
135        name: "backup",
136        about: "Plan, inspect, and verify backups",
137        scope: CommandScope::BackupRestore,
138    },
139    CommandSpec {
140        name: "restore",
141        about: "Plan or run snapshot restores",
142        scope: CommandScope::BackupRestore,
143    },
144];
145
146fn is_help_arg(arg: &OsString) -> bool {
147    arg.to_str()
148        .is_some_and(|arg| matches!(arg, "help" | "--help" | "-h"))
149}
150
151fn is_version_arg(arg: &OsString) -> bool {
152    arg.to_str()
153        .is_some_and(|arg| matches!(arg, "version" | "--version" | "-V"))
154}
155
156/// Return whether the first CLI argument requests help.
157pub fn first_arg_is_help(args: &[OsString]) -> bool {
158    args.first().is_some_and(is_help_arg)
159}
160
161fn first_arg_is_version(args: &[OsString]) -> bool {
162    args.first().is_some_and(is_version_arg)
163}
164
165/// Print help or version text when the first CLI argument requests it.
166///
167/// Returns `true` when the caller should stop command execution.
168pub fn print_help_or_version(
169    args: &[OsString],
170    usage: impl FnOnce() -> String,
171    version_text: &str,
172) -> bool {
173    if first_arg_is_help(args) {
174        println!("{}", usage());
175        return true;
176    }
177    if first_arg_is_version(args) {
178        println!("{version_text}");
179        return true;
180    }
181    false
182}
183
184#[must_use]
185/// Build the top-level Clap command used for public help rendering.
186pub fn top_level_command() -> Command {
187    let command = Command::new("canic")
188        .version(env!("CARGO_PKG_VERSION"))
189        .about("Operator CLI for Canic projects, deployments, backups, and ICP wallet workflows")
190        .disable_version_flag(true)
191        .arg(
192            Arg::new("version")
193                .short('V')
194                .long("version")
195                .action(ArgAction::SetTrue)
196                .help("Print version"),
197        )
198        .arg(icp_arg().global(true))
199        .arg(network_arg().global(true))
200        .subcommand_help_heading("Commands")
201        .help_template(TOP_LEVEL_HELP_TEMPLATE)
202        .before_help(grouped_command_section(COMMAND_SPECS).join("\n"))
203        .after_help("Run `canic <command> help` for command-specific help.");
204
205    COMMAND_SPECS.iter().fold(command, |command, spec| {
206        command.subcommand(Command::new(spec.name).about(spec.about))
207    })
208}
209
210/// Render Canic's custom colorized top-level usage text.
211pub fn usage() -> String {
212    let mut lines = vec![
213        color(
214            COLOR_HEADING,
215            &format!("Canic Operator CLI v{}", env!("CARGO_PKG_VERSION")),
216        ),
217        String::new(),
218        "Usage: canic [OPTIONS] <COMMAND>".to_string(),
219        String::new(),
220        color(COLOR_HEADING, "Commands:"),
221    ];
222    lines.extend(grouped_command_section(COMMAND_SPECS));
223    lines.extend([
224        String::new(),
225        color(COLOR_HEADING, "Options:"),
226        "      --icp <path>      Path to the icp executable for ICP-backed commands".to_string(),
227        "      --network <name>  ICP CLI network for networked commands".to_string(),
228        "  -V, --version  Print version".to_string(),
229        "  -h, --help     Print help".to_string(),
230        String::new(),
231        format!(
232            "{}Tip:{} Run {} for command-specific help.",
233            COLOR_TIP,
234            COLOR_RESET,
235            color(COLOR_COMMAND, "`canic <command> help`")
236        ),
237    ]);
238    lines.join("\n")
239}
240
241fn grouped_command_section(specs: &[CommandSpec]) -> Vec<String> {
242    let mut lines = Vec::new();
243    let scopes = [
244        CommandScope::Project,
245        CommandScope::Deployment,
246        CommandScope::IcpWallet,
247        CommandScope::BackupRestore,
248    ];
249    for scope in scopes {
250        let scope_specs = specs
251            .iter()
252            .filter(|spec| spec.scope == scope)
253            .collect::<Vec<_>>();
254        if scope_specs.is_empty() {
255            continue;
256        }
257        if !lines.is_empty() {
258            lines.push(String::new());
259        }
260        lines.push(format!("  {}", color(COLOR_GROUP, scope.heading())));
261        for spec in scope_specs {
262            let command = format!("{:<12}", spec.name);
263            lines.push(format!(
264                "    {} {}",
265                color(COLOR_COMMAND, &command),
266                spec.about
267            ));
268        }
269    }
270    lines
271}
272
273fn color(code: &str, text: &str) -> String {
274    format!("{code}{text}{COLOR_RESET}")
275}
276
277// -----------------------------------------------------------------------------
278// Tests
279
280#[cfg(test)]
281mod tests {
282    use super::*;
283
284    // Ensure top-level usage keeps the intended color groups.
285    #[test]
286    fn usage_contains_help_colors() {
287        let text = usage();
288
289        assert!(text.contains(COLOR_HEADING));
290        assert!(text.contains(COLOR_GROUP));
291        assert!(text.contains(COLOR_COMMAND));
292    }
293
294    #[test]
295    fn first_arg_help_and_version_detection_accepts_aliases() {
296        assert!(first_arg_is_help(&[OsString::from("help")]));
297        assert!(first_arg_is_help(&[OsString::from("--help")]));
298        assert!(first_arg_is_help(&[OsString::from("-h")]));
299        assert!(first_arg_is_version(&[OsString::from("version")]));
300        assert!(first_arg_is_version(&[OsString::from("--version")]));
301        assert!(first_arg_is_version(&[OsString::from("-V")]));
302    }
303}