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