canic 0.50.15

Canic — a canister orchestration and management toolkit for the Internet Computer
Documentation
use std::{fmt::Write as _, fs, path::Path};

use canic_core::{bootstrap::compiled::ConfigModel, ids::CanisterRole};
use toml::Value as TomlValue;

///
/// PackageCanicMetadata
///
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct PackageCanicMetadata {
    pub fleet: String,
    pub role: String,
}

/// Read a Canic config source, or generate a minimal standalone config when allowed.
#[must_use]
pub fn read_config_source_or_default(
    config_path: &Path,
    explicit_config: bool,
    default_role: Option<&str>,
) -> (String, bool) {
    match fs::read_to_string(config_path) {
        Ok(source) => (source, false),
        Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
            let role = default_role
                .unwrap_or_else(|| panic!("Missing Canic config at {}", config_path.display()));

            assert!(
                !explicit_config,
                "Missing explicit Canic config at {}",
                config_path.display()
            );

            (standalone_config_source(role), true)
        }
        Err(err) => panic!("Failed to read {}: {err}", config_path.display()),
    }
}

/// Read optional Canic metadata declared in the package manifest.
#[must_use]
pub fn declared_package_metadata(manifest_dir: &Path) -> Option<PackageCanicMetadata> {
    let manifest = fs::read_to_string(manifest_dir.join("Cargo.toml")).ok()?;
    let canic = toml::from_str::<TomlValue>(&manifest)
        .ok()?
        .get("package")?
        .get("metadata")?
        .get("canic")?
        .clone();
    let fleet = canic.get("fleet")?.as_str()?.to_string();
    let role = canic.get("role")?.as_str()?.to_string();

    Some(PackageCanicMetadata { fleet, role })
}

/// Read an optional Canic role declared in the package manifest metadata.
#[must_use]
pub fn declared_package_role(manifest_dir: &Path) -> Option<String> {
    declared_package_metadata(manifest_dir).map(|metadata| metadata.role)
}

/// Read the required Canic metadata declared in package manifest metadata.
#[must_use]
pub fn required_package_metadata(manifest_dir: &Path) -> PackageCanicMetadata {
    let manifest_path = manifest_dir.join("Cargo.toml");
    declared_package_metadata(manifest_dir).unwrap_or_else(|| {
        panic!(
            "missing Canic package metadata in {}; add [package.metadata.canic] fleet = \"<fleet>\" and role = \"<role>\"",
            manifest_path.display()
        )
    })
}

/// Read the required Canic role declared in package manifest metadata.
#[must_use]
pub fn required_package_role(manifest_dir: &Path) -> String {
    required_package_metadata(manifest_dir).role
}

/// Return whether a validated config declares the requested fleet role.
#[must_use]
pub fn config_declares_role(config: &ConfigModel, fleet_name: &str, role_name: &str) -> bool {
    config.fleet_name() == Some(fleet_name)
        && config
            .roles
            .contains_key(&CanisterRole::owned(role_name.to_string()))
}

/// Return the fleet name declared by a validated config.
#[must_use]
#[allow(dead_code)]
pub fn config_fleet_name(config: &ConfigModel) -> Option<&str> {
    config.fleet_name()
}

/// Return whether a validated config attaches the requested fleet role.
#[must_use]
#[allow(dead_code)]
pub fn config_attaches_role(config: &ConfigModel, fleet_name: &str, role_name: &str) -> bool {
    if config.fleet_name() != Some(fleet_name) {
        return false;
    }

    config
        .attached_roles()
        .contains(&CanisterRole::owned(role_name.to_string()))
}

/// Return whether a validated config contains the requested canister role.
#[must_use]
pub fn config_contains_role(config: &ConfigModel, role_name: &str) -> bool {
    config_declares_role(config, config.fleet_name().unwrap_or_default(), role_name)
}

/// Render the minimal declared-only config needed by a standalone non-root canister.
#[must_use]
pub fn standalone_config_source(role: &str) -> String {
    assert!(
        !role.is_empty() && role != "root",
        "standalone Canic config requires a non-root role"
    );

    let role_key = toml_basic_string(role);

    format!(
        r#"controllers = []
app_index = []

[fleet]
name = "standalone"

[roles.{role_key}]
kind = "canister"

[app]
init_mode = "enabled"

[app.whitelist]
"#
    )
}

// Escape a role name as a TOML basic string for quoted table keys.
fn toml_basic_string(value: &str) -> String {
    let mut rendered = String::with_capacity(value.len() + 2);
    rendered.push('"');

    for ch in value.chars() {
        match ch {
            '"' => rendered.push_str("\\\""),
            '\\' => rendered.push_str("\\\\"),
            '\u{08}' => rendered.push_str("\\b"),
            '\t' => rendered.push_str("\\t"),
            '\n' => rendered.push_str("\\n"),
            '\u{0c}' => rendered.push_str("\\f"),
            '\r' => rendered.push_str("\\r"),
            ch if ch.is_control() => {
                let _ = write!(rendered, "\\u{:04X}", ch as u32);
            }
            ch => rendered.push(ch),
        }
    }

    rendered.push('"');
    rendered
}

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

    #[test]
    fn standalone_config_source_parses_for_plain_role() {
        let source = standalone_config_source("sandbox_minimal");
        let cfg = parse_config_model(&source).expect("generated standalone config parses");

        assert_eq!(cfg.fleet_name(), Some("standalone"));
        assert!(cfg.roles.contains_key("sandbox_minimal"));
        assert!(!cfg.roles.contains_key("root"));
        assert!(cfg.subnets.is_empty());
        assert!(!config_attaches_role(&cfg, "standalone", "sandbox_minimal"));
    }

    #[test]
    fn standalone_config_source_quotes_role_keys() {
        let source = standalone_config_source("demo.role");
        let cfg = parse_config_model(&source).expect("generated standalone config parses");

        assert_eq!(cfg.fleet_name(), Some("standalone"));
        assert!(cfg.roles.contains_key("demo.role"));
        assert!(cfg.subnets.is_empty());
    }

    #[test]
    #[should_panic(expected = "standalone Canic config requires a non-root role")]
    fn standalone_config_source_rejects_root_role() {
        let _ = standalone_config_source("root");
    }

    #[test]
    fn read_config_source_or_default_generates_when_implicit_file_is_missing() {
        let missing_path =
            std::env::temp_dir().join(format!("canic-missing-default-{}.toml", std::process::id()));
        let (source, generated) =
            read_config_source_or_default(missing_path.as_path(), false, Some("test"));

        assert!(generated);
        assert!(source.contains("[roles.\"test\"]"));
        assert!(!source.contains("[subnets."));
    }

    #[test]
    fn declared_package_role_reads_canic_metadata() {
        let dir = std::env::temp_dir().join(format!("canic-role-metadata-{}", std::process::id()));
        fs::create_dir_all(&dir).expect("create temp manifest dir");
        fs::write(
            dir.join("Cargo.toml"),
            r#"[package]
name = "canister_scale"
version = "0.1.0"
edition = "2024"

[package.metadata.canic]
fleet = "test"
role = "scale_replica"
"#,
        )
        .expect("write manifest");

        assert_eq!(
            declared_package_role(&dir).as_deref(),
            Some("scale_replica")
        );
        fs::remove_dir_all(&dir).expect("remove temp manifest dir");
    }

    #[test]
    fn required_package_role_rejects_missing_canic_metadata() {
        let dir = std::env::temp_dir().join(format!(
            "canic-missing-role-metadata-{}",
            std::process::id()
        ));
        fs::create_dir_all(&dir).expect("create temp manifest dir");
        fs::write(
            dir.join("Cargo.toml"),
            r#"[package]
name = "canister_missing"
version = "0.1.0"
edition = "2024"
"#,
        )
        .expect("write manifest");

        let panic = std::panic::catch_unwind(|| required_package_role(&dir))
            .expect_err("missing metadata should panic");
        let message = panic
            .downcast_ref::<String>()
            .map(String::as_str)
            .or_else(|| panic.downcast_ref::<&str>().copied())
            .expect("panic should include a message");

        assert!(message.contains("missing Canic package metadata"));
        fs::remove_dir_all(&dir).expect("remove temp manifest dir");
    }

    #[test]
    fn config_contains_role_accepts_exact_metadata_role() {
        let cfg = parse_config_model(
            r#"
[subnets.prime.canisters.root]
kind = "root"

[fleet]
name = "test"

[roles.root]
kind = "root"

[roles.app]
kind = "canister"

[subnets.prime.canisters.app]
kind = "singleton"
"#,
        )
        .expect("config parses");

        assert!(config_contains_role(&cfg, "root"));
        assert!(config_contains_role(&cfg, "app"));
    }

    #[test]
    fn config_contains_role_rejects_role_typos() {
        let cfg = parse_config_model(
            r#"
[subnets.prime.canisters.root]
kind = "root"

[fleet]
name = "test"

[roles.root]
kind = "root"

[roles.app]
kind = "canister"

[subnets.prime.canisters.app]
kind = "singleton"
"#,
        )
        .expect("config parses");

        assert!(!config_contains_role(&cfg, "Root"));
        assert!(!config_contains_role(&cfg, "roots"));
        assert!(!config_contains_role(&cfg, "missing"));
    }
}