mod args;
mod backup;
mod build;
mod fleets;
mod install;
mod list;
mod manifest;
mod medic;
mod output;
mod release_set;
mod restore;
mod scaffold;
mod snapshot;
#[cfg(test)]
mod test_support;
use crate::args::any_arg_is_version;
use clap::{Arg, ArgAction, Command};
use std::ffi::OsString;
use thiserror::Error as ThisError;
const VERSION_TEXT: &str = concat!("canic ", env!("CARGO_PKG_VERSION"));
const 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";
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum CommandScope {
ProjectSetup,
MultiFleet,
SingleFleet,
SingleCanister,
}
impl CommandScope {
const fn heading(self) -> &'static str {
match self {
Self::ProjectSetup => "Project setup commands",
Self::MultiFleet => "Multi-fleet commands",
Self::SingleFleet => "Single-fleet commands",
Self::SingleCanister => "Single-canister commands",
}
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
struct CommandSpec {
name: &'static str,
about: &'static str,
scope: CommandScope,
}
const COMMAND_SPECS: &[CommandSpec] = &[
CommandSpec {
name: "scaffold",
about: "Create a minimal Canic fleet scaffold",
scope: CommandScope::ProjectSetup,
},
CommandSpec {
name: "fleets",
about: "List installed Canic fleets",
scope: CommandScope::MultiFleet,
},
CommandSpec {
name: "use",
about: "Select the current Canic fleet",
scope: CommandScope::MultiFleet,
},
CommandSpec {
name: "install",
about: "Install and bootstrap a Canic fleet",
scope: CommandScope::SingleFleet,
},
CommandSpec {
name: "list",
about: "Show registry canisters as a tree table",
scope: CommandScope::SingleFleet,
},
CommandSpec {
name: "backup",
about: "Verify backup directories and journal status",
scope: CommandScope::SingleFleet,
},
CommandSpec {
name: "manifest",
about: "Validate fleet backup manifests",
scope: CommandScope::SingleFleet,
},
CommandSpec {
name: "medic",
about: "Diagnose local Canic fleet setup",
scope: CommandScope::SingleFleet,
},
CommandSpec {
name: "release-set",
about: "Inspect, emit, or stage root release-set artifacts",
scope: CommandScope::SingleFleet,
},
CommandSpec {
name: "restore",
about: "Plan or run snapshot restores",
scope: CommandScope::SingleFleet,
},
CommandSpec {
name: "build",
about: "Build one Canic canister artifact",
scope: CommandScope::SingleCanister,
},
CommandSpec {
name: "snapshot",
about: "Capture and download canister snapshots",
scope: CommandScope::SingleCanister,
},
];
#[derive(Debug, ThisError)]
pub enum CliError {
#[error("{0}")]
Usage(String),
#[error("backup: {0}")]
Backup(String),
#[error("build: {0}")]
Build(String),
#[error("install: {0}")]
Install(String),
#[error("fleets: {0}")]
Fleets(String),
#[error("list: {0}")]
List(String),
#[error("manifest: {0}")]
Manifest(String),
#[error("medic: {0}")]
Medic(String),
#[error("snapshot: {0}")]
Snapshot(String),
#[error("release-set: {0}")]
ReleaseSet(String),
#[error("restore: {0}")]
Restore(String),
#[error("scaffold: {0}")]
Scaffold(String),
}
impl From<backup::BackupCommandError> for CliError {
fn from(err: backup::BackupCommandError) -> Self {
Self::Backup(err.to_string())
}
}
impl From<build::BuildCommandError> for CliError {
fn from(err: build::BuildCommandError) -> Self {
Self::Build(err.to_string())
}
}
impl From<install::InstallCommandError> for CliError {
fn from(err: install::InstallCommandError) -> Self {
Self::Install(err.to_string())
}
}
impl From<fleets::FleetCommandError> for CliError {
fn from(err: fleets::FleetCommandError) -> Self {
Self::Fleets(err.to_string())
}
}
impl From<list::ListCommandError> for CliError {
fn from(err: list::ListCommandError) -> Self {
Self::List(err.to_string())
}
}
impl From<manifest::ManifestCommandError> for CliError {
fn from(err: manifest::ManifestCommandError) -> Self {
Self::Manifest(err.to_string())
}
}
impl From<medic::MedicCommandError> for CliError {
fn from(err: medic::MedicCommandError) -> Self {
Self::Medic(err.to_string())
}
}
impl From<snapshot::SnapshotCommandError> for CliError {
fn from(err: snapshot::SnapshotCommandError) -> Self {
Self::Snapshot(err.to_string())
}
}
impl From<release_set::ReleaseSetCommandError> for CliError {
fn from(err: release_set::ReleaseSetCommandError) -> Self {
Self::ReleaseSet(err.to_string())
}
}
impl From<restore::RestoreCommandError> for CliError {
fn from(err: restore::RestoreCommandError) -> Self {
Self::Restore(err.to_string())
}
}
impl From<scaffold::ScaffoldCommandError> for CliError {
fn from(err: scaffold::ScaffoldCommandError) -> Self {
Self::Scaffold(err.to_string())
}
}
pub fn run_from_env() -> Result<(), CliError> {
run(std::env::args_os().skip(1))
}
pub fn run<I>(args: I) -> Result<(), CliError>
where
I: IntoIterator<Item = OsString>,
{
let args = args.into_iter().collect::<Vec<_>>();
if any_arg_is_version(&args) {
println!("{}", version_text());
return Ok(());
}
let mut args = args.into_iter();
let Some(command) = args.next().and_then(|arg| arg.into_string().ok()) else {
return Err(CliError::Usage(usage()));
};
match command.as_str() {
"backup" => backup::run(args).map_err(CliError::from),
"build" => build::run(args).map_err(CliError::from),
"fleets" => fleets::run(args).map_err(CliError::from),
"install" => install::run(args).map_err(CliError::from),
"list" => list::run(args).map_err(CliError::from),
"manifest" => manifest::run(args).map_err(CliError::from),
"medic" => medic::run(args).map_err(CliError::from),
"release-set" => release_set::run(args).map_err(CliError::from),
"scaffold" => scaffold::run(args).map_err(CliError::from),
"snapshot" => snapshot::run(args).map_err(CliError::from),
"restore" => restore::run(args).map_err(CliError::from),
"use" => fleets::run_use(args).map_err(CliError::from),
"help" | "--help" | "-h" => {
println!("{}", usage());
Ok(())
}
_ => Err(CliError::Usage(usage())),
}
}
#[must_use]
pub fn top_level_command() -> Command {
let command = Command::new("canic")
.version(env!("CARGO_PKG_VERSION"))
.about("Operator CLI for Canic install, backup, and restore workflows")
.disable_version_flag(true)
.arg(
Arg::new("version")
.short('V')
.long("version")
.action(ArgAction::SetTrue)
.help("Print version"),
)
.subcommand_help_heading("Commands")
.help_template(TOP_LEVEL_HELP_TEMPLATE)
.before_help(grouped_command_section(COMMAND_SPECS))
.after_help("Run `canic <command> help` for command-specific help.");
COMMAND_SPECS.iter().fold(command, |command, spec| {
command.subcommand(Command::new(spec.name).about(spec.about))
})
}
#[must_use]
pub const fn version_text() -> &'static str {
VERSION_TEXT
}
fn usage() -> String {
let mut command = top_level_command();
command.render_help().to_string()
}
fn grouped_command_section(specs: &[CommandSpec]) -> String {
let mut lines = Vec::new();
let scopes = [
CommandScope::ProjectSetup,
CommandScope::MultiFleet,
CommandScope::SingleFleet,
CommandScope::SingleCanister,
];
for (index, scope) in scopes.into_iter().enumerate() {
lines.push(format!("{}:", scope.heading()));
for spec in specs.iter().filter(|spec| spec.scope == scope) {
lines.push(format!(" {:<11} {}", spec.name, spec.about));
}
if index + 1 < scopes.len() {
lines.push(String::new());
}
}
lines.join("\n")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn usage_lists_command_families() {
let text = usage();
assert!(text.contains(version_text()));
assert!(text.contains("Usage: canic"));
assert!(text.contains("Project setup commands"));
assert!(text.contains("Multi-fleet commands"));
assert!(text.contains("Single-fleet commands"));
assert!(text.contains("Single-canister commands"));
assert!(!text.contains("\nCommands:\n"));
assert!(text.find("Project setup commands") < text.find("Multi-fleet commands"));
assert!(text.find("Multi-fleet commands") < text.find("Single-fleet commands"));
assert!(text.find("Single-fleet commands") < text.find("Single-canister commands"));
assert!(text.contains("scaffold"));
assert!(text.contains("list"));
assert!(text.contains("build"));
assert!(text.contains("fleets"));
assert!(text.contains("use"));
assert!(text.contains("install"));
assert!(text.contains("snapshot"));
assert!(text.contains("backup"));
assert!(text.contains("manifest"));
assert!(text.contains("medic"));
assert!(text.contains("release-set"));
assert!(text.contains("restore"));
assert!(text.contains("canic <command> help"));
}
#[test]
fn command_family_help_returns_ok() {
assert!(run([OsString::from("backup"), OsString::from("help")]).is_ok());
assert!(run([OsString::from("build"), OsString::from("help")]).is_ok());
assert!(run([OsString::from("install"), OsString::from("help")]).is_ok());
assert!(run([OsString::from("fleets"), OsString::from("help")]).is_ok());
assert!(run([OsString::from("list"), OsString::from("help")]).is_ok());
assert!(run([OsString::from("restore"), OsString::from("help")]).is_ok());
assert!(run([OsString::from("manifest"), OsString::from("help")]).is_ok());
assert!(run([OsString::from("medic"), OsString::from("help")]).is_ok());
assert!(run([OsString::from("release-set"), OsString::from("help")]).is_ok());
assert!(run([OsString::from("scaffold"), OsString::from("help")]).is_ok());
assert!(run([OsString::from("snapshot"), OsString::from("help")]).is_ok());
assert!(run([OsString::from("use"), OsString::from("help")]).is_ok());
}
#[test]
fn version_flags_return_ok() {
assert_eq!(version_text(), concat!("canic ", env!("CARGO_PKG_VERSION")));
assert!(run([OsString::from("--version")]).is_ok());
assert!(run([OsString::from("backup"), OsString::from("--version")]).is_ok());
assert!(run([OsString::from("build"), OsString::from("--version")]).is_ok());
assert!(run([OsString::from("install"), OsString::from("--version")]).is_ok());
assert!(run([OsString::from("fleets"), OsString::from("--version")]).is_ok());
assert!(run([OsString::from("list"), OsString::from("--version")]).is_ok());
assert!(run([OsString::from("restore"), OsString::from("--version")]).is_ok());
assert!(run([OsString::from("manifest"), OsString::from("--version")]).is_ok());
assert!(run([OsString::from("medic"), OsString::from("--version")]).is_ok());
assert!(run([OsString::from("release-set"), OsString::from("--version")]).is_ok());
assert!(run([OsString::from("scaffold"), OsString::from("--version")]).is_ok());
assert!(run([OsString::from("snapshot"), OsString::from("--version")]).is_ok());
assert!(run([OsString::from("use"), OsString::from("--version")]).is_ok());
}
}