1mod args;
2mod backup;
3mod build;
4mod endpoints;
5mod fleets;
6mod install;
7mod list;
8mod manifest;
9mod medic;
10mod output;
11mod replica;
12mod restore;
13mod scaffold;
14mod snapshot;
15mod status;
16#[cfg(test)]
17mod test_support;
18
19use crate::args::{first_arg_is_help, parse_matches};
20use clap::{Arg, ArgAction, Command};
21use std::ffi::OsString;
22use thiserror::Error as ThisError;
23
24const VERSION_TEXT: &str = concat!("canic ", env!("CARGO_PKG_VERSION"));
25const 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";
26const COLOR_RESET: &str = "\x1b[0m";
27const COLOR_HEADING: &str = "\x1b[1m";
28const COLOR_GROUP: &str = "\x1b[38;5;245m";
29const COLOR_COMMAND: &str = "\x1b[38;5;109m";
30const COLOR_TIP: &str = "\x1b[38;5;245m";
31const DISPATCH_ARGS: &str = "args";
32
33#[derive(Clone, Copy, Debug, Eq, PartialEq)]
38enum CommandScope {
39 Global,
40 FleetContext,
41 WorkspaceFiles,
42}
43
44impl CommandScope {
45 const fn heading(self) -> &'static str {
46 match self {
47 Self::Global => "Global commands",
48 Self::FleetContext => "Fleet commands",
49 Self::WorkspaceFiles => "Workspace and file commands",
50 }
51 }
52}
53
54#[derive(Clone, Copy, Debug, Eq, PartialEq)]
59struct CommandSpec {
60 name: &'static str,
61 about: &'static str,
62 scope: CommandScope,
63}
64
65const COMMAND_SPECS: &[CommandSpec] = &[
66 CommandSpec {
67 name: "status",
68 about: "Show quick Canic project status",
69 scope: CommandScope::Global,
70 },
71 CommandSpec {
72 name: "fleet",
73 about: "Manage Canic fleets",
74 scope: CommandScope::Global,
75 },
76 CommandSpec {
77 name: "replica",
78 about: "Manage the local ICP replica",
79 scope: CommandScope::Global,
80 },
81 CommandSpec {
82 name: "install",
83 about: "Install and bootstrap a Canic fleet",
84 scope: CommandScope::FleetContext,
85 },
86 CommandSpec {
87 name: "config",
88 about: "Inspect selected fleet config",
89 scope: CommandScope::FleetContext,
90 },
91 CommandSpec {
92 name: "list",
93 about: "List deployed fleet canisters",
94 scope: CommandScope::FleetContext,
95 },
96 CommandSpec {
97 name: "endpoints",
98 about: "List canister Candid endpoints",
99 scope: CommandScope::FleetContext,
100 },
101 CommandSpec {
102 name: "medic",
103 about: "Diagnose local Canic fleet setup",
104 scope: CommandScope::FleetContext,
105 },
106 CommandSpec {
107 name: "snapshot",
108 about: "Capture and download canister snapshots",
109 scope: CommandScope::FleetContext,
110 },
111 CommandSpec {
112 name: "build",
113 about: "Build one Canic canister artifact",
114 scope: CommandScope::WorkspaceFiles,
115 },
116 CommandSpec {
117 name: "backup",
118 about: "Verify backup directories and journal status",
119 scope: CommandScope::WorkspaceFiles,
120 },
121 CommandSpec {
122 name: "manifest",
123 about: "Validate fleet backup manifests",
124 scope: CommandScope::WorkspaceFiles,
125 },
126 CommandSpec {
127 name: "restore",
128 about: "Plan or run snapshot restores",
129 scope: CommandScope::WorkspaceFiles,
130 },
131];
132
133#[derive(Debug, ThisError)]
138pub enum CliError {
139 #[error("{0}")]
140 Usage(String),
141
142 #[error("backup: {0}")]
143 Backup(String),
144
145 #[error("build: {0}")]
146 Build(String),
147
148 #[error("config: {0}")]
149 Config(String),
150
151 #[error("endpoints: {0}")]
152 Endpoints(String),
153
154 #[error("install: {0}")]
155 Install(String),
156
157 #[error("fleet: {0}")]
158 Fleets(String),
159
160 #[error("list: {0}")]
161 List(String),
162
163 #[error("manifest: {0}")]
164 Manifest(String),
165
166 #[error("medic: {0}")]
167 Medic(String),
168
169 #[error("snapshot: {0}")]
170 Snapshot(String),
171
172 #[error("restore: {0}")]
173 Restore(String),
174
175 #[error("replica: {0}")]
176 Replica(String),
177
178 #[error("status: {0}")]
179 Status(String),
180}
181
182impl From<backup::BackupCommandError> for CliError {
183 fn from(err: backup::BackupCommandError) -> Self {
184 Self::Backup(err.to_string())
185 }
186}
187
188impl From<build::BuildCommandError> for CliError {
189 fn from(err: build::BuildCommandError) -> Self {
190 Self::Build(err.to_string())
191 }
192}
193
194impl From<endpoints::EndpointsCommandError> for CliError {
195 fn from(err: endpoints::EndpointsCommandError) -> Self {
196 Self::Endpoints(err.to_string())
197 }
198}
199
200impl From<install::InstallCommandError> for CliError {
201 fn from(err: install::InstallCommandError) -> Self {
202 Self::Install(err.to_string())
203 }
204}
205
206impl From<fleets::FleetCommandError> for CliError {
207 fn from(err: fleets::FleetCommandError) -> Self {
208 Self::Fleets(err.to_string())
209 }
210}
211
212impl From<list::ListCommandError> for CliError {
213 fn from(err: list::ListCommandError) -> Self {
214 Self::List(err.to_string())
215 }
216}
217
218impl From<manifest::ManifestCommandError> for CliError {
219 fn from(err: manifest::ManifestCommandError) -> Self {
220 Self::Manifest(err.to_string())
221 }
222}
223
224impl From<medic::MedicCommandError> for CliError {
225 fn from(err: medic::MedicCommandError) -> Self {
226 Self::Medic(err.to_string())
227 }
228}
229
230impl From<snapshot::SnapshotCommandError> for CliError {
231 fn from(err: snapshot::SnapshotCommandError) -> Self {
232 Self::Snapshot(err.to_string())
233 }
234}
235
236impl From<restore::RestoreCommandError> for CliError {
237 fn from(err: restore::RestoreCommandError) -> Self {
238 Self::Restore(err.to_string())
239 }
240}
241
242impl From<replica::ReplicaCommandError> for CliError {
243 fn from(err: replica::ReplicaCommandError) -> Self {
244 Self::Replica(err.to_string())
245 }
246}
247
248impl From<status::StatusCommandError> for CliError {
249 fn from(err: status::StatusCommandError) -> Self {
250 Self::Status(err.to_string())
251 }
252}
253
254pub fn run_from_env() -> Result<(), CliError> {
256 run(std::env::args_os().skip(1))
257}
258
259pub fn run<I>(args: I) -> Result<(), CliError>
261where
262 I: IntoIterator<Item = OsString>,
263{
264 let args = args.into_iter().collect::<Vec<_>>();
265 if first_arg_is_help(&args) {
266 println!("{}", usage());
267 return Ok(());
268 }
269
270 let matches =
271 parse_matches(top_level_dispatch_command(), args).map_err(|_| CliError::Usage(usage()))?;
272 if matches.get_flag("version") {
273 println!("{}", version_text());
274 return Ok(());
275 }
276
277 let Some((command, subcommand_matches)) = matches.subcommand() else {
278 return Err(CliError::Usage(usage()));
279 };
280 let tail = subcommand_matches
281 .get_many::<OsString>(DISPATCH_ARGS)
282 .map(|values| values.cloned().collect::<Vec<_>>())
283 .unwrap_or_default()
284 .into_iter();
285
286 match command {
287 "backup" => backup::run(tail).map_err(CliError::from),
288 "build" => build::run(tail).map_err(CliError::from),
289 "config" => list::run_config(tail).map_err(|err| CliError::Config(err.to_string())),
290 "endpoints" => endpoints::run(tail).map_err(CliError::from),
291 "fleet" => fleets::run(tail).map_err(CliError::from),
292 "install" => install::run(tail).map_err(CliError::from),
293 "list" => list::run(tail).map_err(CliError::from),
294 "manifest" => manifest::run(tail).map_err(CliError::from),
295 "medic" => medic::run(tail).map_err(CliError::from),
296 "replica" => replica::run(tail).map_err(CliError::from),
297 "snapshot" => snapshot::run(tail).map_err(CliError::from),
298 "status" => status::run(tail).map_err(CliError::from),
299 "restore" => restore::run(tail).map_err(CliError::from),
300 _ => unreachable!("top-level dispatch command only defines known commands"),
301 }
302}
303
304#[must_use]
305pub fn top_level_command() -> Command {
306 let command = Command::new("canic")
307 .version(env!("CARGO_PKG_VERSION"))
308 .about("Operator CLI for Canic install, backup, and restore workflows")
309 .disable_version_flag(true)
310 .arg(
311 Arg::new("version")
312 .short('V')
313 .long("version")
314 .action(ArgAction::SetTrue)
315 .help("Print version"),
316 )
317 .subcommand_help_heading("Commands")
318 .help_template(TOP_LEVEL_HELP_TEMPLATE)
319 .before_help(grouped_command_section(COMMAND_SPECS).join("\n"))
320 .after_help("Run `canic <command> help` for command-specific help.");
321
322 COMMAND_SPECS.iter().fold(command, |command, spec| {
323 command.subcommand(Command::new(spec.name).about(spec.about))
324 })
325}
326
327fn top_level_dispatch_command() -> Command {
328 let command = Command::new("canic")
329 .disable_help_flag(true)
330 .disable_version_flag(true)
331 .arg(
332 Arg::new("version")
333 .short('V')
334 .long("version")
335 .action(ArgAction::SetTrue),
336 );
337
338 COMMAND_SPECS.iter().fold(command, |command, spec| {
339 command.subcommand(
340 Command::new(spec.name).arg(
341 Arg::new(DISPATCH_ARGS)
342 .num_args(0..)
343 .allow_hyphen_values(true)
344 .trailing_var_arg(true)
345 .value_parser(clap::value_parser!(OsString)),
346 ),
347 )
348 })
349}
350
351#[must_use]
352pub const fn version_text() -> &'static str {
353 VERSION_TEXT
354}
355
356fn usage() -> String {
357 let mut lines = vec![
358 color(
359 COLOR_HEADING,
360 &format!("Canic Operator CLI v{}", env!("CARGO_PKG_VERSION")),
361 ),
362 String::new(),
363 "Usage: canic [OPTIONS] <COMMAND>".to_string(),
364 String::new(),
365 color(COLOR_HEADING, "Commands:"),
366 ];
367 lines.extend(grouped_command_section(COMMAND_SPECS));
368 lines.extend([
369 String::new(),
370 color(COLOR_HEADING, "Options:"),
371 " -V, --version Print version".to_string(),
372 " -h, --help Print help".to_string(),
373 String::new(),
374 format!(
375 "{}Tip:{} Run {} for command-specific help.",
376 COLOR_TIP,
377 COLOR_RESET,
378 color(COLOR_COMMAND, "`canic <command> help`")
379 ),
380 ]);
381 lines.join("\n")
382}
383
384fn grouped_command_section(specs: &[CommandSpec]) -> Vec<String> {
385 let mut lines = Vec::new();
386 let scopes = [
387 CommandScope::Global,
388 CommandScope::FleetContext,
389 CommandScope::WorkspaceFiles,
390 ];
391 for (index, scope) in scopes.into_iter().enumerate() {
392 lines.push(format!(" {}", color(COLOR_GROUP, scope.heading())));
393 for spec in specs.iter().filter(|spec| spec.scope == scope) {
394 let command = format!("{:<12}", spec.name);
395 lines.push(format!(
396 " {} {}",
397 color(COLOR_COMMAND, &command),
398 spec.about
399 ));
400 }
401 if index + 1 < scopes.len() {
402 lines.push(String::new());
403 }
404 }
405 lines
406}
407
408fn color(code: &str, text: &str) -> String {
409 format!("{code}{text}{COLOR_RESET}")
410}
411
412#[cfg(test)]
413mod tests {
414 use super::*;
415
416 #[test]
418 fn usage_lists_command_families() {
419 let text = usage();
420 let plain = strip_ansi(&text);
421
422 assert!(plain.contains(&format!(
423 "Canic Operator CLI v{}",
424 env!("CARGO_PKG_VERSION")
425 )));
426 assert!(plain.contains("Usage: canic [OPTIONS] <COMMAND>"));
427 assert!(plain.contains("\nCommands:\n"));
428 assert!(plain.contains("Global commands"));
429 assert!(plain.contains("Fleet commands"));
430 assert!(plain.contains("Workspace and file commands"));
431 assert!(plain.find(" status") < plain.find(" fleet"));
432 assert!(plain.find(" fleet") < plain.find(" replica"));
433 assert!(plain.find(" replica") < plain.find(" install"));
434 assert!(plain.find(" install") < plain.find(" config"));
435 assert!(plain.find(" config") < plain.find(" list"));
436 assert!(plain.find(" list") < plain.find(" endpoints"));
437 assert!(plain.contains("Options:"));
438 assert!(!plain.contains(" scaffold"));
439 assert!(plain.contains("config"));
440 assert!(plain.contains("list"));
441 assert!(plain.contains("endpoints"));
442 assert!(plain.contains("build"));
443 assert!(!plain.contains(" network"));
444 assert!(!plain.contains(" defaults"));
445 assert!(plain.contains(" status"));
446 assert!(plain.contains("fleet"));
447 assert!(plain.contains("replica"));
448 assert!(plain.contains("install"));
449 assert!(plain.contains("snapshot"));
450 assert!(plain.contains("backup"));
451 assert!(plain.contains("manifest"));
452 assert!(plain.contains("medic"));
453 assert!(plain.contains("restore"));
454 assert!(plain.contains("Tip: Run `canic <command> help`"));
455 assert!(text.contains(COLOR_HEADING));
456 assert!(text.contains(COLOR_GROUP));
457 assert!(text.contains(COLOR_COMMAND));
458 }
459
460 #[test]
462 fn command_family_help_returns_ok() {
463 for args in [
464 &["backup", "help"][..],
465 &["backup", "list", "help"],
466 &["backup", "status", "help"],
467 &["backup", "verify", "help"],
468 &["build", "help"],
469 &["config", "help"],
470 &["endpoints", "help"],
471 &["install", "help"],
472 &["fleet"],
473 &["fleet", "help"],
474 &["fleet", "create", "help"],
475 &["fleet", "list", "help"],
476 &["fleet", "delete", "help"],
477 &["replica"],
478 &["replica", "help"],
479 &["replica", "start", "help"],
480 &["replica", "status", "help"],
481 &["replica", "stop", "help"],
482 &["list", "help"],
483 &["restore", "help"],
484 &["restore", "plan", "help"],
485 &["restore", "apply", "help"],
486 &["restore", "run", "help"],
487 &["manifest", "help"],
488 &["manifest", "validate", "help"],
489 &["medic", "help"],
490 &["snapshot", "help"],
491 &["snapshot", "download", "help"],
492 &["status", "help"],
493 ] {
494 assert_run_ok(args);
495 }
496 }
497
498 #[test]
500 fn version_flags_return_ok() {
501 assert_eq!(version_text(), concat!("canic ", env!("CARGO_PKG_VERSION")));
502 assert!(run([OsString::from("--version")]).is_ok());
503 assert!(
504 run([
505 OsString::from("backup"),
506 OsString::from("list"),
507 OsString::from("--dir"),
508 OsString::from("version")
509 ])
510 .is_ok()
511 );
512 assert!(run([OsString::from("backup"), OsString::from("--version")]).is_ok());
513 assert!(
514 run([
515 OsString::from("backup"),
516 OsString::from("list"),
517 OsString::from("--version")
518 ])
519 .is_ok()
520 );
521 assert!(run([OsString::from("build"), OsString::from("--version")]).is_ok());
522 assert!(run([OsString::from("config"), OsString::from("--version")]).is_ok());
523 assert!(run([OsString::from("endpoints"), OsString::from("--version")]).is_ok());
524 assert!(run([OsString::from("install"), OsString::from("--version")]).is_ok());
525 assert!(run([OsString::from("fleet"), OsString::from("--version")]).is_ok());
526 assert!(run([OsString::from("replica"), OsString::from("--version")]).is_ok());
527 assert!(run([OsString::from("status"), OsString::from("--version")]).is_ok());
528 assert!(
529 run([
530 OsString::from("fleet"),
531 OsString::from("create"),
532 OsString::from("--version")
533 ])
534 .is_ok()
535 );
536 assert!(
537 run([
538 OsString::from("replica"),
539 OsString::from("start"),
540 OsString::from("--version")
541 ])
542 .is_ok()
543 );
544 assert!(run([OsString::from("list"), OsString::from("--version")]).is_ok());
545 assert!(run([OsString::from("restore"), OsString::from("--version")]).is_ok());
546 assert!(run([OsString::from("manifest"), OsString::from("--version")]).is_ok());
547 assert!(run([OsString::from("medic"), OsString::from("--version")]).is_ok());
548 assert!(run([OsString::from("snapshot"), OsString::from("--version")]).is_ok());
549 assert!(
550 run([
551 OsString::from("snapshot"),
552 OsString::from("download"),
553 OsString::from("--version")
554 ])
555 .is_ok()
556 );
557 }
558
559 fn strip_ansi(text: &str) -> String {
561 let mut plain = String::new();
562 let mut chars = text.chars().peekable();
563 while let Some(ch) = chars.next() {
564 if ch == '\x1b' && chars.peek() == Some(&'[') {
565 chars.next();
566 for ch in chars.by_ref() {
567 if ch == 'm' {
568 break;
569 }
570 }
571 continue;
572 }
573 plain.push(ch);
574 }
575 plain
576 }
577
578 fn assert_run_ok(raw_args: &[&str]) {
580 let args = raw_args.iter().map(OsString::from).collect::<Vec<_>>();
581 assert!(
582 run(args).is_ok(),
583 "expected successful run for {raw_args:?}"
584 );
585 }
586}