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