canic-cli 0.32.3

Operator CLI for Canic fleet backup and restore workflows
Documentation
use crate::{
    args::{
        default_network, first_arg_is_help, first_arg_is_version, flag_arg, parse_matches,
        path_option, string_option, value_arg,
    },
    version_text,
};
use canic_host::release_set::{
    config_path, configured_install_targets, dfx_root, emit_root_release_set_manifest,
    emit_root_release_set_manifest_if_ready, load_root_release_set_manifest, resolve_artifact_root,
    resume_root_bootstrap, root_release_set_manifest_path, stage_root_release_set, workspace_root,
};
use clap::{ArgMatches, Command as ClapCommand};
use std::{ffi::OsString, path::PathBuf};
use thiserror::Error as ThisError;

const DEFAULT_ROOT_TARGET: &str = "root";

///
/// ReleaseSetCommandError
///

#[derive(Debug, ThisError)]
pub enum ReleaseSetCommandError {
    #[error("{0}")]
    Usage(String),

    #[error(transparent)]
    ReleaseSet(#[from] Box<dyn std::error::Error>),
}

///
/// ReleaseSetCommand
///

#[derive(Clone, Debug, Eq, PartialEq)]
enum ReleaseSetCommand {
    Targets(TargetsOptions),
    Manifest(ManifestOptions),
    Stage(StageOptions),
}

///
/// TargetsOptions
///

#[derive(Clone, Debug, Eq, PartialEq)]
struct TargetsOptions {
    config_path: Option<PathBuf>,
    root_target: String,
}

///
/// ManifestOptions
///

#[derive(Clone, Debug, Eq, PartialEq)]
struct ManifestOptions {
    if_ready: bool,
}

///
/// StageOptions
///

#[derive(Clone, Debug, Eq, PartialEq)]
struct StageOptions {
    root_target: String,
}

/// Run the release-set command family.
pub fn run<I>(args: I) -> Result<(), ReleaseSetCommandError>
where
    I: IntoIterator<Item = OsString>,
{
    let args = args.into_iter().collect::<Vec<_>>();
    if first_arg_is_help(&args) {
        println!("{}", usage());
        return Ok(());
    }
    if first_arg_is_version(&args) {
        println!("{}", version_text());
        return Ok(());
    }

    match ReleaseSetCommand::parse(args)? {
        ReleaseSetCommand::Targets(options) => run_targets(options),
        ReleaseSetCommand::Manifest(options) => run_manifest(options),
        ReleaseSetCommand::Stage(options) => run_stage(options),
    }
    .map_err(ReleaseSetCommandError::from)
}

impl ReleaseSetCommand {
    // Parse the selected release-set subcommand.
    fn parse<I>(args: I) -> Result<Self, ReleaseSetCommandError>
    where
        I: IntoIterator<Item = OsString>,
    {
        let mut args = args.into_iter();
        let Some(command) = args.next().and_then(|arg| arg.into_string().ok()) else {
            return Err(ReleaseSetCommandError::Usage(usage()));
        };

        match command.as_str() {
            "targets" => Ok(Self::Targets(TargetsOptions::parse(args)?)),
            "manifest" => Ok(Self::Manifest(ManifestOptions::parse(args)?)),
            "stage" => Ok(Self::Stage(StageOptions::parse(args)?)),
            _ => Err(ReleaseSetCommandError::Usage(usage())),
        }
    }
}

impl TargetsOptions {
    // Parse install-target listing options.
    fn parse<I>(args: I) -> Result<Self, ReleaseSetCommandError>
    where
        I: IntoIterator<Item = OsString>,
    {
        let matches = parse_release_set_options(targets_command(), args, targets_usage)?;

        Ok(Self {
            config_path: path_option(&matches, "config"),
            root_target: string_option(&matches, "root")
                .unwrap_or_else(|| DEFAULT_ROOT_TARGET.to_string()),
        })
    }
}

impl ManifestOptions {
    // Parse root release-set manifest emission options.
    fn parse<I>(args: I) -> Result<Self, ReleaseSetCommandError>
    where
        I: IntoIterator<Item = OsString>,
    {
        let matches = parse_release_set_options(manifest_command(), args, manifest_usage)?;

        Ok(Self {
            if_ready: matches.get_flag("if-ready"),
        })
    }
}

impl StageOptions {
    // Parse root release-set staging options.
    fn parse<I>(args: I) -> Result<Self, ReleaseSetCommandError>
    where
        I: IntoIterator<Item = OsString>,
    {
        let matches = parse_release_set_options(stage_command(), args, stage_usage)?;

        Ok(Self {
            root_target: string_option(&matches, "root-canister")
                .or_else(|| std::env::var("ROOT_CANISTER").ok())
                .unwrap_or_else(|| DEFAULT_ROOT_TARGET.to_string()),
        })
    }
}

// Parse one release-set subcommand option set.
fn parse_release_set_options<I>(
    command: ClapCommand,
    args: I,
    usage: fn() -> String,
) -> Result<ArgMatches, ReleaseSetCommandError>
where
    I: IntoIterator<Item = OsString>,
{
    parse_matches(command, args).map_err(|_| ReleaseSetCommandError::Usage(usage()))
}

// Build the install-target parser.
fn targets_command() -> ClapCommand {
    ClapCommand::new("targets")
        .bin_name("canic release-set targets")
        .about("List root plus ordinary install targets from canic.toml")
        .disable_help_flag(true)
        .arg(value_arg("config").long("config").value_name("canic.toml"))
        .arg(
            value_arg("root")
                .long("root")
                .value_name("dfx-canister-name"),
        )
}

// Build the manifest emission parser.
fn manifest_command() -> ClapCommand {
    ClapCommand::new("manifest")
        .bin_name("canic release-set manifest")
        .about("Emit the current root release-set manifest from local build artifacts")
        .disable_help_flag(true)
        .arg(flag_arg("if-ready").long("if-ready"))
}

// Build the release staging parser.
fn stage_command() -> ClapCommand {
    ClapCommand::new("stage")
        .bin_name("canic release-set stage")
        .about("Stage the current root release set and resume root bootstrap")
        .disable_help_flag(true)
        .arg(value_arg("root-canister").value_name("root-canister"))
}

// Print configured install targets in the order the install flow uses.
fn run_targets(options: TargetsOptions) -> Result<(), Box<dyn std::error::Error>> {
    let workspace_root = workspace_root()?;
    let config_path = options
        .config_path
        .unwrap_or_else(|| config_path(&workspace_root));

    for role in configured_install_targets(&config_path, &options.root_target)? {
        println!("{role}");
    }

    Ok(())
}

// Emit the root release-set manifest from current build artifacts.
fn run_manifest(options: ManifestOptions) -> Result<(), Box<dyn std::error::Error>> {
    let workspace_root = workspace_root()?;
    let dfx_root = dfx_root()?;
    let network = default_network();
    let manifest_path = if options.if_ready {
        emit_root_release_set_manifest_if_ready(&workspace_root, &dfx_root, &network)?
    } else {
        Some(emit_root_release_set_manifest(
            &workspace_root,
            &dfx_root,
            &network,
        )?)
    };

    if let Some(path) = manifest_path {
        println!("{}", path.display());
    }

    Ok(())
}

// Stage the current root release set and resume root bootstrap.
fn run_stage(options: StageOptions) -> Result<(), Box<dyn std::error::Error>> {
    let dfx_root = dfx_root()?;
    let network = default_network();
    let artifact_root = resolve_artifact_root(&dfx_root, &network)?;
    let manifest_path = root_release_set_manifest_path(&artifact_root)?;
    let manifest = load_root_release_set_manifest(&manifest_path)?;

    stage_root_release_set(&dfx_root, &options.root_target, &manifest)?;
    resume_root_bootstrap(&options.root_target)?;
    Ok(())
}

// Return release-set command family usage.
fn usage() -> String {
    let mut command = release_set_command();
    command.render_help().to_string()
}

// Return release-set target listing usage.
fn targets_usage() -> String {
    let mut command = targets_command();
    command.render_help().to_string()
}

// Return release-set manifest usage.
fn manifest_usage() -> String {
    let mut command = manifest_command();
    command.render_help().to_string()
}

// Return release-set stage usage.
fn stage_usage() -> String {
    let mut command = stage_command();
    command.render_help().to_string()
}

// Build the release-set command-family parser for help rendering.
fn release_set_command() -> ClapCommand {
    ClapCommand::new("release-set")
        .bin_name("canic release-set")
        .about("Inspect, emit, or stage root release-set artifacts")
        .disable_help_flag(true)
        .subcommand(
            ClapCommand::new("targets")
                .about("List root plus ordinary install targets from canic.toml")
                .disable_help_flag(true),
        )
        .subcommand(
            ClapCommand::new("manifest")
                .about("Emit the current root release-set manifest from local build artifacts")
                .disable_help_flag(true),
        )
        .subcommand(
            ClapCommand::new("stage")
                .about("Stage the current root release set and resume root bootstrap")
                .disable_help_flag(true),
        )
}

#[cfg(test)]
mod tests {
    use super::*;

    // Ensure target listing options preserve config and root inputs.
    #[test]
    fn parses_targets_options() {
        let parsed = ReleaseSetCommand::parse([
            OsString::from("targets"),
            OsString::from("--config"),
            OsString::from("canisters/demo/canic.toml"),
            OsString::from("--root"),
            OsString::from("custom_root"),
        ])
        .expect("parse targets");

        let ReleaseSetCommand::Targets(options) = parsed else {
            panic!("expected targets command");
        };

        assert_eq!(
            options.config_path,
            Some(PathBuf::from("canisters/demo/canic.toml"))
        );
        assert_eq!(options.root_target, "custom_root");
    }

    // Ensure manifest emission accepts the readiness gate flag.
    #[test]
    fn parses_manifest_options() {
        let parsed =
            ReleaseSetCommand::parse([OsString::from("manifest"), OsString::from("--if-ready")])
                .expect("parse manifest");

        let ReleaseSetCommand::Manifest(options) = parsed else {
            panic!("expected manifest command");
        };

        assert!(options.if_ready);
    }

    // Ensure stage accepts an explicit root target.
    #[test]
    fn parses_stage_root_target() {
        let parsed =
            ReleaseSetCommand::parse([OsString::from("stage"), OsString::from("custom_root")])
                .expect("parse stage");

        let ReleaseSetCommand::Stage(options) = parsed else {
            panic!("expected stage command");
        };

        assert_eq!(options.root_target, "custom_root");
    }
}