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