use std::{fmt::Write as _, fs, path::Path};
use canic_core::{bootstrap::compiled::ConfigModel, ids::CanisterRole};
use toml::Value as TomlValue;
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct PackageCanicMetadata {
pub fleet: String,
pub role: String,
}
#[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()),
}
}
#[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 })
}
#[must_use]
pub fn declared_package_role(manifest_dir: &Path) -> Option<String> {
declared_package_metadata(manifest_dir).map(|metadata| metadata.role)
}
#[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()
)
})
}
#[must_use]
pub fn required_package_role(manifest_dir: &Path) -> String {
required_package_metadata(manifest_dir).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()))
}
#[must_use]
#[allow(dead_code)]
pub fn config_fleet_name(config: &ConfigModel) -> Option<&str> {
config.fleet_name()
}
#[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()))
}
#[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)
}
#[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"
package = "."
[app]
init_mode = "enabled"
[app.whitelist]
"#
)
}
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"
package = "root"
[roles.app]
kind = "canister"
package = "app"
[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"
package = "root"
[roles.app]
kind = "canister"
package = "app"
[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"));
}
}