1use 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#[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#[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
156pub 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
165pub 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]
185pub 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
210pub 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#[cfg(test)]
281mod tests {
282 use super::*;
283
284 #[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}