1mod args;
2mod backup;
3mod build;
4mod fleets;
5mod install;
6mod list;
7mod manifest;
8mod medic;
9mod output;
10mod release_set;
11mod restore;
12mod scaffold;
13mod snapshot;
14#[cfg(test)]
15mod test_support;
16
17use crate::args::any_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";
24
25#[derive(Clone, Copy, Debug, Eq, PartialEq)]
30enum CommandScope {
31 ProjectSetup,
32 MultiFleet,
33 SingleFleet,
34 SingleCanister,
35}
36
37impl CommandScope {
38 const fn heading(self) -> &'static str {
40 match self {
41 Self::ProjectSetup => "Project setup commands",
42 Self::MultiFleet => "Multi-fleet commands",
43 Self::SingleFleet => "Single-fleet commands",
44 Self::SingleCanister => "Single-canister commands",
45 }
46 }
47}
48
49#[derive(Clone, Copy, Debug, Eq, PartialEq)]
54struct CommandSpec {
55 name: &'static str,
56 about: &'static str,
57 scope: CommandScope,
58}
59
60const COMMAND_SPECS: &[CommandSpec] = &[
61 CommandSpec {
62 name: "scaffold",
63 about: "Create a minimal Canic fleet scaffold",
64 scope: CommandScope::ProjectSetup,
65 },
66 CommandSpec {
67 name: "fleets",
68 about: "List installed Canic fleets",
69 scope: CommandScope::MultiFleet,
70 },
71 CommandSpec {
72 name: "use",
73 about: "Select the current Canic fleet",
74 scope: CommandScope::MultiFleet,
75 },
76 CommandSpec {
77 name: "install",
78 about: "Install and bootstrap a Canic fleet",
79 scope: CommandScope::SingleFleet,
80 },
81 CommandSpec {
82 name: "list",
83 about: "Show registry canisters as a tree table",
84 scope: CommandScope::SingleFleet,
85 },
86 CommandSpec {
87 name: "backup",
88 about: "Verify backup directories and journal status",
89 scope: CommandScope::SingleFleet,
90 },
91 CommandSpec {
92 name: "manifest",
93 about: "Validate fleet backup manifests",
94 scope: CommandScope::SingleFleet,
95 },
96 CommandSpec {
97 name: "medic",
98 about: "Diagnose local Canic fleet setup",
99 scope: CommandScope::SingleFleet,
100 },
101 CommandSpec {
102 name: "release-set",
103 about: "Inspect, emit, or stage root release-set artifacts",
104 scope: CommandScope::SingleFleet,
105 },
106 CommandSpec {
107 name: "restore",
108 about: "Plan or run snapshot restores",
109 scope: CommandScope::SingleFleet,
110 },
111 CommandSpec {
112 name: "build",
113 about: "Build one Canic canister artifact",
114 scope: CommandScope::SingleCanister,
115 },
116 CommandSpec {
117 name: "snapshot",
118 about: "Capture and download canister snapshots",
119 scope: CommandScope::SingleCanister,
120 },
121];
122
123#[derive(Debug, ThisError)]
128pub enum CliError {
129 #[error("{0}")]
130 Usage(String),
131
132 #[error("backup: {0}")]
133 Backup(String),
134
135 #[error("build: {0}")]
136 Build(String),
137
138 #[error("install: {0}")]
139 Install(String),
140
141 #[error("fleets: {0}")]
142 Fleets(String),
143
144 #[error("list: {0}")]
145 List(String),
146
147 #[error("manifest: {0}")]
148 Manifest(String),
149
150 #[error("medic: {0}")]
151 Medic(String),
152
153 #[error("snapshot: {0}")]
154 Snapshot(String),
155
156 #[error("release-set: {0}")]
157 ReleaseSet(String),
158
159 #[error("restore: {0}")]
160 Restore(String),
161
162 #[error("scaffold: {0}")]
163 Scaffold(String),
164}
165
166impl From<backup::BackupCommandError> for CliError {
167 fn from(err: backup::BackupCommandError) -> Self {
169 Self::Backup(err.to_string())
170 }
171}
172
173impl From<build::BuildCommandError> for CliError {
174 fn from(err: build::BuildCommandError) -> Self {
176 Self::Build(err.to_string())
177 }
178}
179
180impl From<install::InstallCommandError> for CliError {
181 fn from(err: install::InstallCommandError) -> Self {
183 Self::Install(err.to_string())
184 }
185}
186
187impl From<fleets::FleetCommandError> for CliError {
188 fn from(err: fleets::FleetCommandError) -> Self {
190 Self::Fleets(err.to_string())
191 }
192}
193
194impl From<list::ListCommandError> for CliError {
195 fn from(err: list::ListCommandError) -> Self {
197 Self::List(err.to_string())
198 }
199}
200
201impl From<manifest::ManifestCommandError> for CliError {
202 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 {
211 Self::Medic(err.to_string())
212 }
213}
214
215impl From<snapshot::SnapshotCommandError> for CliError {
216 fn from(err: snapshot::SnapshotCommandError) -> Self {
218 Self::Snapshot(err.to_string())
219 }
220}
221
222impl From<release_set::ReleaseSetCommandError> for CliError {
223 fn from(err: release_set::ReleaseSetCommandError) -> Self {
225 Self::ReleaseSet(err.to_string())
226 }
227}
228
229impl From<restore::RestoreCommandError> for CliError {
230 fn from(err: restore::RestoreCommandError) -> Self {
232 Self::Restore(err.to_string())
233 }
234}
235
236impl From<scaffold::ScaffoldCommandError> for CliError {
237 fn from(err: scaffold::ScaffoldCommandError) -> Self {
239 Self::Scaffold(err.to_string())
240 }
241}
242
243pub fn run_from_env() -> Result<(), CliError> {
245 run(std::env::args_os().skip(1))
246}
247
248pub fn run<I>(args: I) -> Result<(), CliError>
250where
251 I: IntoIterator<Item = OsString>,
252{
253 let args = args.into_iter().collect::<Vec<_>>();
254 if any_arg_is_version(&args) {
255 println!("{}", version_text());
256 return Ok(());
257 }
258
259 let mut args = args.into_iter();
260 let Some(command) = args.next().and_then(|arg| arg.into_string().ok()) else {
261 return Err(CliError::Usage(usage()));
262 };
263
264 match command.as_str() {
265 "backup" => backup::run(args).map_err(CliError::from),
266 "build" => build::run(args).map_err(CliError::from),
267 "fleets" => fleets::run(args).map_err(CliError::from),
268 "install" => install::run(args).map_err(CliError::from),
269 "list" => list::run(args).map_err(CliError::from),
270 "manifest" => manifest::run(args).map_err(CliError::from),
271 "medic" => medic::run(args).map_err(CliError::from),
272 "release-set" => release_set::run(args).map_err(CliError::from),
273 "scaffold" => scaffold::run(args).map_err(CliError::from),
274 "snapshot" => snapshot::run(args).map_err(CliError::from),
275 "restore" => restore::run(args).map_err(CliError::from),
276 "use" => fleets::run_use(args).map_err(CliError::from),
277 "help" | "--help" | "-h" => {
278 println!("{}", usage());
279 Ok(())
280 }
281 _ => Err(CliError::Usage(usage())),
282 }
283}
284
285#[must_use]
287pub fn top_level_command() -> Command {
288 let command = Command::new("canic")
289 .version(env!("CARGO_PKG_VERSION"))
290 .about("Operator CLI for Canic install, backup, and restore workflows")
291 .disable_version_flag(true)
292 .arg(
293 Arg::new("version")
294 .short('V')
295 .long("version")
296 .action(ArgAction::SetTrue)
297 .help("Print version"),
298 )
299 .subcommand_help_heading("Commands")
300 .help_template(TOP_LEVEL_HELP_TEMPLATE)
301 .before_help(grouped_command_section(COMMAND_SPECS))
302 .after_help("Run `canic <command> help` for command-specific help.");
303
304 COMMAND_SPECS.iter().fold(command, |command, spec| {
305 command.subcommand(Command::new(spec.name).about(spec.about))
306 })
307}
308
309#[must_use]
311pub const fn version_text() -> &'static str {
312 VERSION_TEXT
313}
314
315fn usage() -> String {
317 let mut command = top_level_command();
318 command.render_help().to_string()
319}
320
321fn grouped_command_section(specs: &[CommandSpec]) -> String {
323 let mut lines = Vec::new();
324 let scopes = [
325 CommandScope::ProjectSetup,
326 CommandScope::MultiFleet,
327 CommandScope::SingleFleet,
328 CommandScope::SingleCanister,
329 ];
330 for (index, scope) in scopes.into_iter().enumerate() {
331 lines.push(format!("{}:", scope.heading()));
332 for spec in specs.iter().filter(|spec| spec.scope == scope) {
333 lines.push(format!(" {:<11} {}", spec.name, spec.about));
334 }
335 if index + 1 < scopes.len() {
336 lines.push(String::new());
337 }
338 }
339 lines.join("\n")
340}
341
342#[cfg(test)]
343mod tests {
344 use super::*;
345
346 #[test]
348 fn usage_lists_command_families() {
349 let text = usage();
350
351 assert!(text.contains(version_text()));
352 assert!(text.contains("Usage: canic"));
353 assert!(text.contains("Project setup commands"));
354 assert!(text.contains("Multi-fleet commands"));
355 assert!(text.contains("Single-fleet commands"));
356 assert!(text.contains("Single-canister commands"));
357 assert!(!text.contains("\nCommands:\n"));
358 assert!(text.find("Project setup commands") < text.find("Multi-fleet commands"));
359 assert!(text.find("Multi-fleet commands") < text.find("Single-fleet commands"));
360 assert!(text.find("Single-fleet commands") < text.find("Single-canister commands"));
361 assert!(text.contains("scaffold"));
362 assert!(text.contains("list"));
363 assert!(text.contains("build"));
364 assert!(text.contains("fleets"));
365 assert!(text.contains("use"));
366 assert!(text.contains("install"));
367 assert!(text.contains("snapshot"));
368 assert!(text.contains("backup"));
369 assert!(text.contains("manifest"));
370 assert!(text.contains("medic"));
371 assert!(text.contains("release-set"));
372 assert!(text.contains("restore"));
373 assert!(text.contains("canic <command> help"));
374 }
375
376 #[test]
378 fn command_family_help_returns_ok() {
379 assert!(run([OsString::from("backup"), OsString::from("help")]).is_ok());
380 assert!(run([OsString::from("build"), OsString::from("help")]).is_ok());
381 assert!(run([OsString::from("install"), OsString::from("help")]).is_ok());
382 assert!(run([OsString::from("fleets"), OsString::from("help")]).is_ok());
383 assert!(run([OsString::from("list"), OsString::from("help")]).is_ok());
384 assert!(run([OsString::from("restore"), OsString::from("help")]).is_ok());
385 assert!(run([OsString::from("manifest"), OsString::from("help")]).is_ok());
386 assert!(run([OsString::from("medic"), OsString::from("help")]).is_ok());
387 assert!(run([OsString::from("release-set"), OsString::from("help")]).is_ok());
388 assert!(run([OsString::from("scaffold"), OsString::from("help")]).is_ok());
389 assert!(run([OsString::from("snapshot"), OsString::from("help")]).is_ok());
390 assert!(run([OsString::from("use"), OsString::from("help")]).is_ok());
391 }
392
393 #[test]
395 fn version_flags_return_ok() {
396 assert_eq!(version_text(), concat!("canic ", env!("CARGO_PKG_VERSION")));
397 assert!(run([OsString::from("--version")]).is_ok());
398 assert!(run([OsString::from("backup"), OsString::from("--version")]).is_ok());
399 assert!(run([OsString::from("build"), OsString::from("--version")]).is_ok());
400 assert!(run([OsString::from("install"), OsString::from("--version")]).is_ok());
401 assert!(run([OsString::from("fleets"), OsString::from("--version")]).is_ok());
402 assert!(run([OsString::from("list"), OsString::from("--version")]).is_ok());
403 assert!(run([OsString::from("restore"), OsString::from("--version")]).is_ok());
404 assert!(run([OsString::from("manifest"), OsString::from("--version")]).is_ok());
405 assert!(run([OsString::from("medic"), OsString::from("--version")]).is_ok());
406 assert!(run([OsString::from("release-set"), OsString::from("--version")]).is_ok());
407 assert!(run([OsString::from("scaffold"), OsString::from("--version")]).is_ok());
408 assert!(run([OsString::from("snapshot"), OsString::from("--version")]).is_ok());
409 assert!(run([OsString::from("use"), OsString::from("--version")]).is_ok());
410 }
411}