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