1mod args;
2mod backup;
3mod build;
4mod fleets;
5mod install;
6mod list;
7mod manifest;
8mod medic;
9mod network;
10mod output;
11mod restore;
12mod scaffold;
13mod snapshot;
14mod status;
15#[cfg(test)]
16mod test_support;
17
18use crate::args::first_arg_is_version;
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";
30
31#[derive(Clone, Copy, Debug, Eq, PartialEq)]
36enum CommandScope {
37 Defaults,
38 FleetContext,
39 WorkspaceFiles,
40}
41
42impl CommandScope {
43 const fn heading(self) -> &'static str {
45 match self {
46 Self::Defaults => "Default context commands",
47 Self::FleetContext => "Current network + 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 current Canic defaults",
68 scope: CommandScope::Defaults,
69 },
70 CommandSpec {
71 name: "network",
72 about: "Show or select the current default network",
73 scope: CommandScope::Defaults,
74 },
75 CommandSpec {
76 name: "fleet",
77 about: "Show, list, select, or delete Canic fleets",
78 scope: CommandScope::Defaults,
79 },
80 CommandSpec {
81 name: "scaffold",
82 about: "Create a minimal Canic fleet scaffold",
83 scope: CommandScope::Defaults,
84 },
85 CommandSpec {
86 name: "install",
87 about: "Install and bootstrap a Canic fleet",
88 scope: CommandScope::FleetContext,
89 },
90 CommandSpec {
91 name: "list",
92 about: "Show registry canisters as a tree table",
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("install: {0}")]
143 Install(String),
144
145 #[error("fleet: {0}")]
146 Fleets(String),
147
148 #[error("list: {0}")]
149 List(String),
150
151 #[error("manifest: {0}")]
152 Manifest(String),
153
154 #[error("medic: {0}")]
155 Medic(String),
156
157 #[error("network: {0}")]
158 Network(String),
159
160 #[error("snapshot: {0}")]
161 Snapshot(String),
162
163 #[error("status: {0}")]
164 Status(String),
165
166 #[error("restore: {0}")]
167 Restore(String),
168
169 #[error("scaffold: {0}")]
170 Scaffold(String),
171}
172
173impl From<backup::BackupCommandError> for CliError {
174 fn from(err: backup::BackupCommandError) -> Self {
176 Self::Backup(err.to_string())
177 }
178}
179
180impl From<build::BuildCommandError> for CliError {
181 fn from(err: build::BuildCommandError) -> Self {
183 Self::Build(err.to_string())
184 }
185}
186
187impl From<install::InstallCommandError> for CliError {
188 fn from(err: install::InstallCommandError) -> Self {
190 Self::Install(err.to_string())
191 }
192}
193
194impl From<fleets::FleetCommandError> for CliError {
195 fn from(err: fleets::FleetCommandError) -> Self {
197 Self::Fleets(err.to_string())
198 }
199}
200
201impl From<list::ListCommandError> for CliError {
202 fn from(err: list::ListCommandError) -> Self {
204 Self::List(err.to_string())
205 }
206}
207
208impl From<manifest::ManifestCommandError> for CliError {
209 fn from(err: manifest::ManifestCommandError) -> Self {
211 Self::Manifest(err.to_string())
212 }
213}
214
215impl From<medic::MedicCommandError> for CliError {
216 fn from(err: medic::MedicCommandError) -> Self {
218 Self::Medic(err.to_string())
219 }
220}
221
222impl From<network::NetworkCommandError> for CliError {
223 fn from(err: network::NetworkCommandError) -> Self {
225 Self::Network(err.to_string())
226 }
227}
228
229impl From<snapshot::SnapshotCommandError> for CliError {
230 fn from(err: snapshot::SnapshotCommandError) -> Self {
232 Self::Snapshot(err.to_string())
233 }
234}
235
236impl From<status::StatusCommandError> for CliError {
237 fn from(err: status::StatusCommandError) -> Self {
239 Self::Status(err.to_string())
240 }
241}
242
243impl From<restore::RestoreCommandError> for CliError {
244 fn from(err: restore::RestoreCommandError) -> Self {
246 Self::Restore(err.to_string())
247 }
248}
249
250impl From<scaffold::ScaffoldCommandError> for CliError {
251 fn from(err: scaffold::ScaffoldCommandError) -> Self {
253 Self::Scaffold(err.to_string())
254 }
255}
256
257pub fn run_from_env() -> Result<(), CliError> {
259 run(std::env::args_os().skip(1))
260}
261
262pub fn run<I>(args: I) -> Result<(), CliError>
264where
265 I: IntoIterator<Item = OsString>,
266{
267 let args = args.into_iter().collect::<Vec<_>>();
268 if first_arg_is_version(&args) {
269 println!("{}", version_text());
270 return Ok(());
271 }
272
273 let mut args = args.into_iter();
274 let Some(command) = args.next().and_then(|arg| arg.into_string().ok()) else {
275 return Err(CliError::Usage(usage()));
276 };
277
278 match command.as_str() {
279 "backup" => backup::run(args).map_err(CliError::from),
280 "build" => build::run(args).map_err(CliError::from),
281 "fleet" => fleets::run(args).map_err(CliError::from),
282 "install" => install::run(args).map_err(CliError::from),
283 "list" => list::run(args).map_err(CliError::from),
284 "manifest" => manifest::run(args).map_err(CliError::from),
285 "medic" => medic::run(args).map_err(CliError::from),
286 "network" => network::run(args).map_err(CliError::from),
287 "scaffold" => scaffold::run(args).map_err(CliError::from),
288 "snapshot" => snapshot::run(args).map_err(CliError::from),
289 "status" => status::run(args).map_err(CliError::from),
290 "restore" => restore::run(args).map_err(CliError::from),
291 "help" | "--help" | "-h" => {
292 println!("{}", usage());
293 Ok(())
294 }
295 _ => Err(CliError::Usage(usage())),
296 }
297}
298
299#[must_use]
301pub fn top_level_command() -> Command {
302 let command = Command::new("canic")
303 .version(env!("CARGO_PKG_VERSION"))
304 .about("Operator CLI for Canic install, backup, and restore workflows")
305 .disable_version_flag(true)
306 .arg(
307 Arg::new("version")
308 .short('V')
309 .long("version")
310 .action(ArgAction::SetTrue)
311 .help("Print version"),
312 )
313 .subcommand_help_heading("Commands")
314 .help_template(TOP_LEVEL_HELP_TEMPLATE)
315 .before_help(grouped_command_section(COMMAND_SPECS).join("\n"))
316 .after_help("Run `canic <command> help` for command-specific help.");
317
318 COMMAND_SPECS.iter().fold(command, |command, spec| {
319 command.subcommand(Command::new(spec.name).about(spec.about))
320 })
321}
322
323#[must_use]
325pub const fn version_text() -> &'static str {
326 VERSION_TEXT
327}
328
329fn usage() -> String {
331 let mut lines = vec![
332 color(
333 COLOR_HEADING,
334 &format!("Canic Operator CLI v{}", env!("CARGO_PKG_VERSION")),
335 ),
336 String::new(),
337 "Usage: canic [OPTIONS] <COMMAND>".to_string(),
338 String::new(),
339 color(COLOR_HEADING, "Commands:"),
340 ];
341 lines.extend(grouped_command_section(COMMAND_SPECS));
342 lines.extend([
343 String::new(),
344 color(COLOR_HEADING, "Options:"),
345 " -V, --version Print version".to_string(),
346 " -h, --help Print help".to_string(),
347 String::new(),
348 format!(
349 "{}Tip:{} Run {} for command-specific help.",
350 COLOR_TIP,
351 COLOR_RESET,
352 color(COLOR_COMMAND, "`canic <command> help`")
353 ),
354 ]);
355 lines.join("\n")
356}
357
358fn grouped_command_section(specs: &[CommandSpec]) -> Vec<String> {
360 let mut lines = Vec::new();
361 let scopes = [
362 CommandScope::Defaults,
363 CommandScope::FleetContext,
364 CommandScope::WorkspaceFiles,
365 ];
366 for (index, scope) in scopes.into_iter().enumerate() {
367 lines.push(format!(" {}", color(COLOR_GROUP, scope.heading())));
368 for spec in specs.iter().filter(|spec| spec.scope == scope) {
369 let command = format!("{:<12}", spec.name);
370 lines.push(format!(
371 " {} {}",
372 color(COLOR_COMMAND, &command),
373 spec.about
374 ));
375 }
376 if index + 1 < scopes.len() {
377 lines.push(String::new());
378 }
379 }
380 lines
381}
382
383fn color(code: &str, text: &str) -> String {
385 format!("{code}{text}{COLOR_RESET}")
386}
387
388#[cfg(test)]
389mod tests {
390 use super::*;
391
392 #[test]
394 fn usage_lists_command_families() {
395 let text = usage();
396 let plain = strip_ansi(&text);
397
398 assert!(plain.contains(&format!(
399 "Canic Operator CLI v{}",
400 env!("CARGO_PKG_VERSION")
401 )));
402 assert!(plain.contains("Usage: canic [OPTIONS] <COMMAND>"));
403 assert!(plain.contains("\nCommands:\n"));
404 assert!(plain.contains("Default context commands"));
405 assert!(plain.contains("Current network + fleet commands"));
406 assert!(plain.contains("Workspace and file commands"));
407 assert!(
408 plain.find("Default context commands") < plain.find("Current network + fleet commands")
409 );
410 assert!(
411 plain.find("Current network + fleet commands")
412 < plain.find("Workspace and file commands")
413 );
414 assert!(plain.find(" status") < plain.find(" network"));
415 assert!(plain.find(" network") < plain.find(" fleet"));
416 assert!(plain.find(" fleet") < plain.find(" scaffold"));
417 assert!(plain.find(" scaffold") < plain.find(" install"));
418 assert!(plain.contains("Options:"));
419 assert!(plain.contains("scaffold"));
420 assert!(plain.contains("list"));
421 assert!(plain.contains("build"));
422 assert!(plain.contains("network"));
423 assert!(plain.contains("status"));
424 assert!(plain.contains("fleet"));
425 assert!(plain.contains("install"));
426 assert!(plain.contains("snapshot"));
427 assert!(plain.contains("backup"));
428 assert!(plain.contains("manifest"));
429 assert!(plain.contains("medic"));
430 assert!(plain.contains("restore"));
431 assert!(plain.contains("Tip: Run `canic <command> help`"));
432 assert!(text.contains(COLOR_HEADING));
433 assert!(text.contains(COLOR_GROUP));
434 assert!(text.contains(COLOR_COMMAND));
435 }
436
437 #[test]
439 fn command_family_help_returns_ok() {
440 assert!(run([OsString::from("backup"), OsString::from("help")]).is_ok());
441 assert!(
442 run([
443 OsString::from("backup"),
444 OsString::from("list"),
445 OsString::from("help")
446 ])
447 .is_ok()
448 );
449 assert!(
450 run([
451 OsString::from("backup"),
452 OsString::from("status"),
453 OsString::from("help")
454 ])
455 .is_ok()
456 );
457 assert!(
458 run([
459 OsString::from("backup"),
460 OsString::from("verify"),
461 OsString::from("help")
462 ])
463 .is_ok()
464 );
465 assert!(run([OsString::from("build"), OsString::from("help")]).is_ok());
466 assert!(run([OsString::from("install"), OsString::from("help")]).is_ok());
467 assert!(run([OsString::from("fleet"), OsString::from("help")]).is_ok());
468 assert!(
469 run([
470 OsString::from("fleet"),
471 OsString::from("list"),
472 OsString::from("help")
473 ])
474 .is_ok()
475 );
476 assert!(
477 run([
478 OsString::from("fleet"),
479 OsString::from("use"),
480 OsString::from("help")
481 ])
482 .is_ok()
483 );
484 assert!(
485 run([
486 OsString::from("fleet"),
487 OsString::from("delete"),
488 OsString::from("help")
489 ])
490 .is_ok()
491 );
492 assert!(run([OsString::from("list"), OsString::from("help")]).is_ok());
493 assert!(run([OsString::from("restore"), OsString::from("help")]).is_ok());
494 assert!(
495 run([
496 OsString::from("restore"),
497 OsString::from("plan"),
498 OsString::from("help")
499 ])
500 .is_ok()
501 );
502 assert!(
503 run([
504 OsString::from("restore"),
505 OsString::from("apply"),
506 OsString::from("help")
507 ])
508 .is_ok()
509 );
510 assert!(
511 run([
512 OsString::from("restore"),
513 OsString::from("run"),
514 OsString::from("help")
515 ])
516 .is_ok()
517 );
518 assert!(run([OsString::from("manifest"), OsString::from("help")]).is_ok());
519 assert!(
520 run([
521 OsString::from("manifest"),
522 OsString::from("validate"),
523 OsString::from("help")
524 ])
525 .is_ok()
526 );
527 assert!(run([OsString::from("medic"), OsString::from("help")]).is_ok());
528 assert!(run([OsString::from("network"), OsString::from("help")]).is_ok());
529 assert!(run([OsString::from("scaffold"), OsString::from("help")]).is_ok());
530 assert!(run([OsString::from("snapshot"), OsString::from("help")]).is_ok());
531 assert!(
532 run([
533 OsString::from("snapshot"),
534 OsString::from("download"),
535 OsString::from("help")
536 ])
537 .is_ok()
538 );
539 assert!(run([OsString::from("status"), OsString::from("help")]).is_ok());
540 }
541
542 #[test]
544 fn version_flags_return_ok() {
545 assert_eq!(version_text(), concat!("canic ", env!("CARGO_PKG_VERSION")));
546 assert!(run([OsString::from("--version")]).is_ok());
547 assert!(
548 run([
549 OsString::from("backup"),
550 OsString::from("list"),
551 OsString::from("--dir"),
552 OsString::from("version")
553 ])
554 .is_ok()
555 );
556 assert!(run([OsString::from("backup"), OsString::from("--version")]).is_ok());
557 assert!(
558 run([
559 OsString::from("backup"),
560 OsString::from("list"),
561 OsString::from("--version")
562 ])
563 .is_ok()
564 );
565 assert!(run([OsString::from("build"), OsString::from("--version")]).is_ok());
566 assert!(run([OsString::from("install"), OsString::from("--version")]).is_ok());
567 assert!(run([OsString::from("fleet"), OsString::from("--version")]).is_ok());
568 assert!(run([OsString::from("list"), OsString::from("--version")]).is_ok());
569 assert!(run([OsString::from("restore"), OsString::from("--version")]).is_ok());
570 assert!(run([OsString::from("manifest"), OsString::from("--version")]).is_ok());
571 assert!(run([OsString::from("medic"), OsString::from("--version")]).is_ok());
572 assert!(run([OsString::from("network"), OsString::from("--version")]).is_ok());
573 assert!(run([OsString::from("scaffold"), OsString::from("--version")]).is_ok());
574 assert!(run([OsString::from("snapshot"), OsString::from("--version")]).is_ok());
575 assert!(
576 run([
577 OsString::from("snapshot"),
578 OsString::from("download"),
579 OsString::from("--version")
580 ])
581 .is_ok()
582 );
583 assert!(run([OsString::from("status"), OsString::from("--version")]).is_ok());
584 }
585
586 fn strip_ansi(text: &str) -> String {
588 let mut plain = String::new();
589 let mut chars = text.chars().peekable();
590 while let Some(ch) = chars.next() {
591 if ch == '\x1b' && chars.peek() == Some(&'[') {
592 chars.next();
593 for ch in chars.by_ref() {
594 if ch == 'm' {
595 break;
596 }
597 }
598 continue;
599 }
600 plain.push(ch);
601 }
602 plain
603 }
604}