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#[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#[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 #[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}