1mod args;
2pub mod backup;
3pub mod build;
4pub mod fleets;
5pub mod install;
6pub mod list;
7pub mod manifest;
8mod output;
9pub mod release_set;
10pub mod restore;
11pub mod snapshot;
12
13use crate::args::any_arg_is_version;
14use clap::{Arg, ArgAction, Command};
15use std::ffi::OsString;
16use thiserror::Error as ThisError;
17
18const VERSION_TEXT: &str = concat!("canic ", env!("CARGO_PKG_VERSION"));
19const TOP_LEVEL_HELP_TEMPLATE: &str = "{about-with-newline}\n{usage-heading} {usage}\n\n{before-help}Options:\n{options}{after-help}\n";
20
21#[derive(Clone, Copy, Debug, Eq, PartialEq)]
26enum CommandScope {
27 MultiFleet,
28 SingleFleet,
29 SingleCanister,
30}
31
32impl CommandScope {
33 const fn heading(self) -> &'static str {
35 match self {
36 Self::MultiFleet => "Multi-fleet commands",
37 Self::SingleFleet => "Single-fleet commands",
38 Self::SingleCanister => "Single-canister commands",
39 }
40 }
41}
42
43#[derive(Clone, Copy, Debug, Eq, PartialEq)]
48struct CommandSpec {
49 name: &'static str,
50 about: &'static str,
51 scope: CommandScope,
52}
53
54const COMMAND_SPECS: &[CommandSpec] = &[
55 CommandSpec {
56 name: "fleets",
57 about: "List installed Canic fleets",
58 scope: CommandScope::MultiFleet,
59 },
60 CommandSpec {
61 name: "use",
62 about: "Select the current Canic fleet",
63 scope: CommandScope::MultiFleet,
64 },
65 CommandSpec {
66 name: "install",
67 about: "Install and bootstrap a Canic fleet",
68 scope: CommandScope::SingleFleet,
69 },
70 CommandSpec {
71 name: "list",
72 about: "Show registry canisters as a tree table",
73 scope: CommandScope::SingleFleet,
74 },
75 CommandSpec {
76 name: "backup",
77 about: "Verify backup directories and journal status",
78 scope: CommandScope::SingleFleet,
79 },
80 CommandSpec {
81 name: "manifest",
82 about: "Validate fleet backup manifests",
83 scope: CommandScope::SingleFleet,
84 },
85 CommandSpec {
86 name: "release-set",
87 about: "Inspect, emit, or stage root release-set artifacts",
88 scope: CommandScope::SingleFleet,
89 },
90 CommandSpec {
91 name: "restore",
92 about: "Plan or run snapshot restores",
93 scope: CommandScope::SingleFleet,
94 },
95 CommandSpec {
96 name: "build",
97 about: "Build one Canic canister artifact",
98 scope: CommandScope::SingleCanister,
99 },
100 CommandSpec {
101 name: "snapshot",
102 about: "Capture and download canister snapshots",
103 scope: CommandScope::SingleCanister,
104 },
105];
106
107#[derive(Debug, ThisError)]
112pub enum CliError {
113 #[error("{0}")]
114 Usage(String),
115
116 #[error(transparent)]
117 Backup(#[from] backup::BackupCommandError),
118
119 #[error(transparent)]
120 Build(#[from] build::BuildCommandError),
121
122 #[error(transparent)]
123 Install(#[from] install::InstallCommandError),
124
125 #[error(transparent)]
126 Fleets(#[from] fleets::FleetCommandError),
127
128 #[error(transparent)]
129 List(#[from] list::ListCommandError),
130
131 #[error(transparent)]
132 Manifest(#[from] manifest::ManifestCommandError),
133
134 #[error(transparent)]
135 Snapshot(#[from] snapshot::SnapshotCommandError),
136
137 #[error(transparent)]
138 ReleaseSet(#[from] release_set::ReleaseSetCommandError),
139
140 #[error(transparent)]
141 Restore(#[from] restore::RestoreCommandError),
142}
143
144pub fn run_from_env() -> Result<(), CliError> {
146 run(std::env::args_os().skip(1))
147}
148
149pub fn run<I>(args: I) -> Result<(), CliError>
151where
152 I: IntoIterator<Item = OsString>,
153{
154 let args = args.into_iter().collect::<Vec<_>>();
155 if any_arg_is_version(&args) {
156 println!("{}", version_text());
157 return Ok(());
158 }
159
160 let mut args = args.into_iter();
161 let Some(command) = args.next().and_then(|arg| arg.into_string().ok()) else {
162 return Err(CliError::Usage(usage()));
163 };
164
165 match command.as_str() {
166 "backup" => backup::run(args).map_err(CliError::from),
167 "build" => build::run(args).map_err(CliError::from),
168 "fleets" => fleets::run(args).map_err(CliError::from),
169 "install" => install::run(args).map_err(CliError::from),
170 "list" => list::run(args).map_err(CliError::from),
171 "manifest" => manifest::run(args).map_err(CliError::from),
172 "release-set" => release_set::run(args).map_err(CliError::from),
173 "snapshot" => snapshot::run(args).map_err(CliError::from),
174 "restore" => restore::run(args).map_err(CliError::from),
175 "use" => fleets::run_use(args).map_err(CliError::from),
176 "help" | "--help" | "-h" => {
177 println!("{}", usage());
178 Ok(())
179 }
180 _ => Err(CliError::Usage(usage())),
181 }
182}
183
184#[must_use]
186pub fn top_level_command() -> Command {
187 let command = Command::new("canic")
188 .about("Operator CLI for Canic install, backup, and restore workflows")
189 .disable_version_flag(true)
190 .arg(
191 Arg::new("version")
192 .short('V')
193 .long("version")
194 .action(ArgAction::SetTrue)
195 .help("Print version"),
196 )
197 .subcommand_help_heading("Commands")
198 .help_template(TOP_LEVEL_HELP_TEMPLATE)
199 .before_help(grouped_command_section(COMMAND_SPECS))
200 .after_help("Run `canic <command> help` for command-specific help.");
201
202 COMMAND_SPECS.iter().fold(command, |command, spec| {
203 command.subcommand(Command::new(spec.name).about(spec.about))
204 })
205}
206
207#[must_use]
209pub const fn version_text() -> &'static str {
210 VERSION_TEXT
211}
212
213fn usage() -> String {
215 let mut command = top_level_command();
216 command.render_help().to_string()
217}
218
219fn grouped_command_section(specs: &[CommandSpec]) -> String {
221 let mut lines = Vec::new();
222 let scopes = [
223 CommandScope::MultiFleet,
224 CommandScope::SingleFleet,
225 CommandScope::SingleCanister,
226 ];
227 for (index, scope) in scopes.into_iter().enumerate() {
228 lines.push(format!("{}:", scope.heading()));
229 for spec in specs.iter().filter(|spec| spec.scope == scope) {
230 lines.push(format!(" {:<11} {}", spec.name, spec.about));
231 }
232 if index + 1 < scopes.len() {
233 lines.push(String::new());
234 }
235 }
236 lines.join("\n")
237}
238
239#[cfg(test)]
240mod tests {
241 use super::*;
242
243 #[test]
245 fn usage_lists_command_families() {
246 let text = usage();
247
248 assert!(text.contains("Usage: canic"));
249 assert!(text.contains("Multi-fleet commands"));
250 assert!(text.contains("Single-fleet commands"));
251 assert!(text.contains("Single-canister commands"));
252 assert!(!text.contains("\nCommands:\n"));
253 assert!(text.find("Multi-fleet commands") < text.find("Single-fleet commands"));
254 assert!(text.find("Single-fleet commands") < text.find("Single-canister commands"));
255 assert!(text.contains("list"));
256 assert!(text.contains("build"));
257 assert!(text.contains("fleets"));
258 assert!(text.contains("use"));
259 assert!(text.contains("install"));
260 assert!(text.contains("snapshot"));
261 assert!(text.contains("backup"));
262 assert!(text.contains("manifest"));
263 assert!(text.contains("release-set"));
264 assert!(text.contains("restore"));
265 assert!(text.contains("canic <command> help"));
266 }
267
268 #[test]
270 fn command_family_help_returns_ok() {
271 assert!(run([OsString::from("backup"), OsString::from("help")]).is_ok());
272 assert!(run([OsString::from("build"), OsString::from("help")]).is_ok());
273 assert!(run([OsString::from("install"), OsString::from("help")]).is_ok());
274 assert!(run([OsString::from("fleets"), OsString::from("help")]).is_ok());
275 assert!(run([OsString::from("list"), OsString::from("help")]).is_ok());
276 assert!(run([OsString::from("restore"), OsString::from("help")]).is_ok());
277 assert!(run([OsString::from("manifest"), OsString::from("help")]).is_ok());
278 assert!(run([OsString::from("release-set"), OsString::from("help")]).is_ok());
279 assert!(run([OsString::from("snapshot"), OsString::from("help")]).is_ok());
280 assert!(run([OsString::from("use"), OsString::from("help")]).is_ok());
281 }
282
283 #[test]
285 fn version_flags_return_ok() {
286 assert_eq!(version_text(), concat!("canic ", env!("CARGO_PKG_VERSION")));
287 assert!(run([OsString::from("--version")]).is_ok());
288 assert!(run([OsString::from("backup"), OsString::from("--version")]).is_ok());
289 assert!(run([OsString::from("build"), OsString::from("--version")]).is_ok());
290 assert!(run([OsString::from("install"), OsString::from("--version")]).is_ok());
291 assert!(run([OsString::from("fleets"), OsString::from("--version")]).is_ok());
292 assert!(run([OsString::from("list"), OsString::from("--version")]).is_ok());
293 assert!(run([OsString::from("restore"), OsString::from("--version")]).is_ok());
294 assert!(run([OsString::from("manifest"), OsString::from("--version")]).is_ok());
295 assert!(run([OsString::from("release-set"), OsString::from("--version")]).is_ok());
296 assert!(run([OsString::from("snapshot"), OsString::from("--version")]).is_ok());
297 assert!(run([OsString::from("use"), OsString::from("--version")]).is_ok());
298 }
299}