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