use std::collections::HashMap;
use std::path::{Path, PathBuf};
use serde::Deserialize;
use super::parse::parse_config;
use super::resolve::merge_layers;
use super::*;
use crate::deep_merge_yaml;
use crate::test_helpers::{SAMPLE_CONFIG_NO_ORIGIN_YAML, SAMPLE_CONFIG_YAML, SAMPLE_PROFILE_YAML};
#[test]
fn parse_yaml_config() {
let config = parse_config(SAMPLE_CONFIG_YAML, Path::new("cfgd.yaml")).unwrap();
assert_eq!(config.metadata.name, "test-config");
assert_eq!(config.spec.profile.as_deref(), Some("default"));
assert_eq!(config.spec.origin.len(), 1);
assert_eq!(
config.spec.origin[0].url,
"https://github.com/test/repo.git"
);
assert_eq!(config.spec.origin[0].branch, "master");
}
#[test]
fn parse_config_without_origin() {
let config = parse_config(SAMPLE_CONFIG_NO_ORIGIN_YAML, Path::new("cfgd.yaml")).unwrap();
assert!(config.spec.origin.is_empty());
assert!(config.spec.sources.is_empty());
}
#[test]
fn parse_profile_yaml() {
let doc: ProfileDocument = serde_yaml::from_str(SAMPLE_PROFILE_YAML).unwrap();
assert_eq!(doc.metadata.name, "base");
assert_eq!(doc.spec.env.len(), 2);
let pkgs = doc.spec.packages.as_ref().unwrap();
let brew = pkgs.brew.as_ref().unwrap();
assert_eq!(brew.formulae, vec!["ripgrep", "fd"]);
assert_eq!(pkgs.cargo.as_ref().unwrap().packages, vec!["bat"]);
}
#[test]
fn merge_env_override() {
let layer1 = ProfileLayer {
source: "local".into(),
profile_name: "base".into(),
priority: 1000,
policy: LayerPolicy::Local,
spec: ProfileSpec {
env: vec![
EnvVar {
name: "editor".into(),
value: "vim".into(),
},
EnvVar {
name: "shell".into(),
value: "/bin/bash".into(),
},
],
..Default::default()
},
};
let layer2 = ProfileLayer {
source: "local".into(),
profile_name: "work".into(),
priority: 1000,
policy: LayerPolicy::Local,
spec: ProfileSpec {
env: vec![EnvVar {
name: "editor".into(),
value: "code".into(),
}],
..Default::default()
},
};
let merged = merge_layers(&[layer1, layer2]);
assert_eq!(
merged
.env
.iter()
.find(|e| e.name == "editor")
.map(|e| &e.value),
Some(&"code".to_string())
);
assert_eq!(
merged
.env
.iter()
.find(|e| e.name == "shell")
.map(|e| &e.value),
Some(&"/bin/bash".to_string())
);
}
#[test]
fn merge_packages_union() {
let layer1 = ProfileLayer {
source: "local".into(),
profile_name: "base".into(),
priority: 1000,
policy: LayerPolicy::Local,
spec: ProfileSpec {
packages: Some(PackagesSpec {
cargo: Some(CargoSpec {
file: None,
packages: vec!["bat".into()],
}),
..Default::default()
}),
..Default::default()
},
};
let layer2 = ProfileLayer {
source: "local".into(),
profile_name: "work".into(),
priority: 1000,
policy: LayerPolicy::Local,
spec: ProfileSpec {
packages: Some(PackagesSpec {
cargo: Some(CargoSpec {
file: None,
packages: vec!["bat".into(), "exa".into()],
}),
..Default::default()
}),
..Default::default()
},
};
let merged = merge_layers(&[layer1, layer2]);
assert_eq!(
merged.packages.cargo.as_ref().unwrap().packages,
vec!["bat", "exa"]
);
}
#[test]
fn merge_files_overlay() {
let layer1 = ProfileLayer {
source: "local".into(),
profile_name: "base".into(),
priority: 1000,
policy: LayerPolicy::Local,
spec: ProfileSpec {
files: Some(FilesSpec {
managed: vec![ManagedFileSpec {
source: "base/.zshrc".into(),
target: PathBuf::from("/home/user/.zshrc"),
strategy: None,
private: false,
origin: None,
encryption: None,
permissions: None,
}],
..Default::default()
}),
..Default::default()
},
};
let layer2 = ProfileLayer {
source: "local".into(),
profile_name: "work".into(),
priority: 1000,
policy: LayerPolicy::Local,
spec: ProfileSpec {
files: Some(FilesSpec {
managed: vec![ManagedFileSpec {
source: "work/.zshrc".into(),
target: PathBuf::from("/home/user/.zshrc"),
strategy: None,
private: false,
origin: None,
encryption: None,
permissions: None,
}],
..Default::default()
}),
..Default::default()
},
};
let merged = merge_layers(&[layer1, layer2]);
assert_eq!(merged.files.managed.len(), 1);
assert_eq!(merged.files.managed[0].source, "work/.zshrc");
}
#[test]
fn deep_merge_yaml_maps() {
let mut base = serde_yaml::from_str::<serde_yaml::Value>(
r#"
domain1:
key1: value1
key2: value2
"#,
)
.unwrap();
let overlay = serde_yaml::from_str::<serde_yaml::Value>(
r#"
domain1:
key2: overridden
key3: value3
"#,
)
.unwrap();
deep_merge_yaml(&mut base, &overlay);
let map = base.as_mapping().unwrap();
let domain = map
.get(serde_yaml::Value::String("domain1".into()))
.unwrap()
.as_mapping()
.unwrap();
assert_eq!(
domain.get(serde_yaml::Value::String("key1".into())),
Some(&serde_yaml::Value::String("value1".into()))
);
assert_eq!(
domain.get(serde_yaml::Value::String("key2".into())),
Some(&serde_yaml::Value::String("overridden".into()))
);
assert_eq!(
domain.get(serde_yaml::Value::String("key3".into())),
Some(&serde_yaml::Value::String("value3".into()))
);
}
#[test]
fn profile_resolution_with_filesystem() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("base.yaml"),
r#"
apiVersion: cfgd.io/v1alpha1
kind: Profile
metadata:
name: base
spec:
env:
- name: editor
value: vim
packages:
cargo:
- bat
"#,
)
.unwrap();
std::fs::write(
dir.path().join("work.yaml"),
r#"
apiVersion: cfgd.io/v1alpha1
kind: Profile
metadata:
name: work
spec:
inherits:
- base
env:
- name: editor
value: code
packages:
cargo:
- exa
"#,
)
.unwrap();
let resolved = resolve_profile("work", dir.path()).unwrap();
assert_eq!(resolved.layers.len(), 2);
assert_eq!(resolved.layers[0].profile_name, "base");
assert_eq!(resolved.layers[1].profile_name, "work");
assert_eq!(
resolved
.merged
.env
.iter()
.find(|e| e.name == "editor")
.map(|e| &e.value),
Some(&"code".to_string())
);
assert_eq!(
resolved.merged.packages.cargo.as_ref().unwrap().packages,
vec!["bat", "exa"]
);
}
#[test]
fn circular_inheritance_detected() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("a.yaml"),
r#"
apiVersion: cfgd.io/v1alpha1
kind: Profile
metadata:
name: a
spec:
inherits:
- b
"#,
)
.unwrap();
std::fs::write(
dir.path().join("b.yaml"),
r#"
apiVersion: cfgd.io/v1alpha1
kind: Profile
metadata:
name: b
spec:
inherits:
- a
"#,
)
.unwrap();
let result = resolve_profile("a", dir.path());
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(err.contains("circular"));
}
#[test]
fn config_not_found_error() {
let result = load_config(Path::new("/nonexistent/cfgd.yaml"));
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("not found"));
}
#[test]
fn parse_config_source_manifest() {
let yaml = r#"
apiVersion: cfgd.io/v1alpha1
kind: ConfigSource
metadata:
name: acme-corp-dev
version: "2.1.0"
description: "ACME Corp developer environment"
spec:
provides:
profiles:
- acme-base
- acme-backend
policy:
required:
packages:
brew:
formulae:
- git-secrets
- pre-commit
recommended:
packages:
brew:
formulae:
- k9s
env:
- name: EDITOR
value: "code --wait"
locked:
files:
- source: "security/policy.yaml"
target: "~/.config/company/security-policy.yaml"
constraints:
noScripts: true
noSecretsRead: true
allowedTargetPaths:
- "~/.config/acme/"
- "~/.eslintrc*"
"#;
let doc = parse_config_source(yaml).unwrap();
assert_eq!(doc.metadata.name, "acme-corp-dev");
assert_eq!(doc.metadata.version.as_deref(), Some("2.1.0"));
assert_eq!(doc.spec.provides.profiles.len(), 2);
let required_pkgs = doc.spec.policy.required.packages.as_ref().unwrap();
let brew = required_pkgs.brew.as_ref().unwrap();
assert_eq!(brew.formulae, vec!["git-secrets", "pre-commit"]);
assert!(doc.spec.policy.constraints.no_scripts);
assert_eq!(doc.spec.policy.constraints.allowed_target_paths.len(), 2);
assert_eq!(doc.spec.policy.locked.files.len(), 1);
}
#[test]
fn parse_config_source_wrong_kind() {
let yaml = r#"
apiVersion: cfgd.io/v1alpha1
kind: Config
metadata:
name: not-a-source
spec:
provides:
profiles: []
policy: {}
"#;
let result = parse_config_source(yaml);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("ConfigSource"));
}
#[test]
fn source_spec_defaults() {
let yaml = r#"
name: test-source
origin:
type: Git
url: https://example.com/config.git
"#;
let spec: SourceSpec = serde_yaml::from_str(yaml).unwrap();
assert_eq!(spec.subscription.priority, 500);
assert_eq!(spec.sync.interval, "1h");
assert!(!spec.sync.auto_apply);
}
#[test]
fn cargo_spec_deserialize_list() {
let yaml = r#"
cargo:
- bat
- ripgrep
"#;
#[derive(Deserialize)]
struct Wrapper {
cargo: CargoSpec,
}
let w: Wrapper = serde_yaml::from_str(yaml).unwrap();
assert_eq!(w.cargo.packages, vec!["bat", "ripgrep"]);
assert!(w.cargo.file.is_none());
}
#[test]
fn cargo_spec_deserialize_map() {
let yaml = r#"
cargo:
file: Cargo.toml
packages:
- extra-pkg
"#;
#[derive(Deserialize)]
struct Wrapper {
cargo: CargoSpec,
}
let w: Wrapper = serde_yaml::from_str(yaml).unwrap();
assert_eq!(w.cargo.file.as_deref(), Some("Cargo.toml"));
assert_eq!(w.cargo.packages, vec!["extra-pkg"]);
}
#[test]
fn cargo_spec_deserialize_file_only() {
let yaml = r#"
cargo:
file: Cargo.toml
"#;
#[derive(Deserialize)]
struct Wrapper {
cargo: CargoSpec,
}
let w: Wrapper = serde_yaml::from_str(yaml).unwrap();
assert_eq!(w.cargo.file.as_deref(), Some("Cargo.toml"));
assert!(w.cargo.packages.is_empty());
}
#[test]
fn packages_spec_with_manifest_files() {
let yaml = r#"
brew:
file: Brewfile
formulae:
- extra-tool
apt:
file: packages.apt.txt
npm:
file: package.json
global:
- extra-global
cargo:
file: Cargo.toml
"#;
let spec: PackagesSpec = serde_yaml::from_str(yaml).unwrap();
let brew = spec.brew.as_ref().unwrap();
assert_eq!(brew.file.as_deref(), Some("Brewfile"));
assert_eq!(brew.formulae, vec!["extra-tool"]);
let apt = spec.apt.as_ref().unwrap();
assert_eq!(apt.file.as_deref(), Some("packages.apt.txt"));
let npm = spec.npm.as_ref().unwrap();
assert_eq!(npm.file.as_deref(), Some("package.json"));
assert_eq!(npm.global, vec!["extra-global"]);
let cargo = spec.cargo.as_ref().unwrap();
assert_eq!(cargo.file.as_deref(), Some("Cargo.toml"));
}
#[test]
fn merge_manifest_file_fields() {
let layer1 = ProfileLayer {
source: "local".into(),
profile_name: "base".into(),
priority: 1000,
policy: LayerPolicy::Local,
spec: ProfileSpec {
packages: Some(PackagesSpec {
brew: Some(BrewSpec {
file: Some("Brewfile".into()),
formulae: vec!["git".into()],
..Default::default()
}),
..Default::default()
}),
..Default::default()
},
};
let layer2 = ProfileLayer {
source: "local".into(),
profile_name: "work".into(),
priority: 1000,
policy: LayerPolicy::Local,
spec: ProfileSpec {
packages: Some(PackagesSpec {
brew: Some(BrewSpec {
formulae: vec!["ripgrep".into()],
..Default::default()
}),
..Default::default()
}),
..Default::default()
},
};
let merged = merge_layers(&[layer1, layer2]);
let brew = merged.packages.brew.as_ref().unwrap();
assert_eq!(brew.file.as_deref(), Some("Brewfile"));
assert_eq!(brew.formulae, vec!["git", "ripgrep"]);
}
#[test]
fn parse_config_source_with_profile_details() {
let yaml = r#"
apiVersion: cfgd.io/v1alpha1
kind: ConfigSource
metadata:
name: acme
spec:
provides:
profiles:
- acme-base
- acme-backend
profileDetails:
- name: acme-base
description: "Core tools and security"
path: profiles/base.yaml
- name: acme-backend
description: "Go, k8s tools"
path: profiles/backend.yaml
inherits:
- acme-base
platformProfiles:
macos: acme-base
debian: acme-backend
policy: {}
"#;
let doc = parse_config_source(yaml).unwrap();
assert_eq!(doc.spec.provides.profile_details.len(), 2);
assert_eq!(doc.spec.provides.profile_details[0].name, "acme-base");
assert_eq!(
doc.spec.provides.profile_details[0].description.as_deref(),
Some("Core tools and security")
);
assert_eq!(
doc.spec.provides.profile_details[1].inherits,
vec!["acme-base"]
);
assert_eq!(doc.spec.provides.platform_profiles.len(), 2);
assert_eq!(
doc.spec.provides.platform_profiles.get("macos").unwrap(),
"acme-base"
);
}
#[test]
fn parse_os_release_debian() {
let content = r#"PRETTY_NAME="Debian GNU/Linux 12 (bookworm)"
NAME="Debian GNU/Linux"
VERSION_ID="12"
ID=debian
"#;
let fields = crate::platform::parse_os_release_content(content);
assert_eq!(
fields.get("ID").map(|v| v.to_lowercase()).as_deref(),
Some("debian")
);
assert_eq!(fields.get("VERSION_ID").map(|s| s.as_str()), Some("12"));
}
#[test]
fn parse_os_release_ubuntu() {
let content = r#"NAME="Ubuntu"
VERSION="22.04.3 LTS (Jammy Jellyfish)"
ID=ubuntu
VERSION_ID="22.04"
"#;
let fields = crate::platform::parse_os_release_content(content);
assert_eq!(
fields.get("ID").map(|v| v.to_lowercase()).as_deref(),
Some("ubuntu")
);
assert_eq!(fields.get("VERSION_ID").map(|s| s.as_str()), Some("22.04"));
}
#[test]
fn parse_os_release_empty() {
let fields = crate::platform::parse_os_release_content("");
assert!(!fields.contains_key("ID"));
assert!(!fields.contains_key("VERSION_ID"));
}
#[test]
fn match_platform_profile_exact_distro() {
let mut profiles = HashMap::new();
profiles.insert("macos".into(), "profiles/macos.yaml".into());
profiles.insert("debian".into(), "profiles/debian.yaml".into());
let platform = PlatformInfo {
os: "linux".into(),
distro: Some("debian".into()),
distro_version: Some("12".into()),
};
assert_eq!(
match_platform_profile(&platform, &profiles),
Some("profiles/debian.yaml".into())
);
}
#[test]
fn match_platform_profile_os_fallback() {
let mut profiles = HashMap::new();
profiles.insert("macos".into(), "profiles/macos.yaml".into());
profiles.insert("linux".into(), "profiles/linux.yaml".into());
let platform = PlatformInfo {
os: "linux".into(),
distro: Some("arch".into()),
distro_version: None,
};
assert_eq!(
match_platform_profile(&platform, &profiles),
Some("profiles/linux.yaml".into())
);
}
#[test]
fn match_platform_profile_no_match() {
let mut profiles = HashMap::new();
profiles.insert("debian".into(), "profiles/debian.yaml".into());
let platform = PlatformInfo {
os: "macos".into(),
distro: None,
distro_version: None,
};
assert!(match_platform_profile(&platform, &profiles).is_none());
}
#[test]
fn source_profile_names_from_details() {
let provides = ConfigSourceProvides {
profiles: vec!["old-name".into()],
profile_details: vec![
ConfigSourceProfileEntry {
name: "base".into(),
description: Some("Base profile".into()),
path: None,
inherits: vec![],
},
ConfigSourceProfileEntry {
name: "backend".into(),
description: None,
path: None,
inherits: vec!["base".into()],
},
],
platform_profiles: HashMap::new(),
modules: vec![],
};
assert_eq!(source_profile_names(&provides), vec!["base", "backend"]);
}
#[test]
fn source_profile_names_fallback_to_profiles() {
let provides = ConfigSourceProvides {
profiles: vec!["alpha".into(), "beta".into()],
profile_details: vec![],
platform_profiles: HashMap::new(),
modules: vec![],
};
assert_eq!(source_profile_names(&provides), vec!["alpha", "beta"]);
}
#[test]
fn auto_apply_policy_deserializes() {
let yaml = r#"
newRecommended: Accept
newOptional: Notify
lockedConflict: Reject
"#;
let policy: AutoApplyPolicyConfig = serde_yaml::from_str(yaml).unwrap();
assert_eq!(policy.new_recommended, PolicyAction::Accept);
assert_eq!(policy.new_optional, PolicyAction::Notify);
assert_eq!(policy.locked_conflict, PolicyAction::Reject);
}
#[test]
fn reconcile_config_with_policy_deserializes() {
let yaml = r#"
interval: 5m
onChange: true
autoApply: true
policy:
newRecommended: Accept
newOptional: Ignore
lockedConflict: Notify
"#;
let config: ReconcileConfig = serde_yaml::from_str(yaml).unwrap();
assert!(config.auto_apply);
assert!(config.on_change);
let policy = config.policy.unwrap();
assert_eq!(policy.new_recommended, PolicyAction::Accept);
}
#[test]
fn reconcile_patches_deserialize() {
let yaml = r#"
interval: 5m
patches:
- kind: Module
name: certificates
interval: 1m
driftPolicy: Auto
- kind: Profile
name: work
autoApply: true
- kind: Module
interval: 30s
"#;
let config: ReconcileConfig = serde_yaml::from_str(yaml).unwrap();
assert_eq!(config.patches.len(), 3);
assert_eq!(config.patches[0].kind, ReconcilePatchKind::Module);
assert_eq!(config.patches[0].name.as_deref(), Some("certificates"));
assert_eq!(config.patches[0].interval.as_deref(), Some("1m"));
assert_eq!(config.patches[0].drift_policy, Some(DriftPolicy::Auto));
assert!(config.patches[0].auto_apply.is_none());
assert_eq!(config.patches[1].kind, ReconcilePatchKind::Profile);
assert_eq!(config.patches[1].name.as_deref(), Some("work"));
assert_eq!(config.patches[1].auto_apply, Some(true));
assert!(config.patches[1].interval.is_none());
assert_eq!(config.patches[2].kind, ReconcilePatchKind::Module);
assert!(config.patches[2].name.is_none());
assert_eq!(config.patches[2].interval.as_deref(), Some("30s"));
}
#[test]
fn reconcile_config_without_patches_has_empty_vec() {
let yaml = "interval: 10m\n";
let config: ReconcileConfig = serde_yaml::from_str(yaml).unwrap();
assert!(config.patches.is_empty());
}
#[test]
fn desired_packages_brew_formulae() {
let spec = PackagesSpec {
brew: Some(BrewSpec {
formulae: vec!["curl".into(), "wget".into()],
..Default::default()
}),
..Default::default()
};
assert_eq!(
desired_packages_for_spec("brew", &spec),
vec!["curl", "wget"]
);
}
#[test]
fn desired_packages_brew_taps() {
let spec = PackagesSpec {
brew: Some(BrewSpec {
taps: vec!["homebrew/core".into()],
..Default::default()
}),
..Default::default()
};
assert_eq!(
desired_packages_for_spec("brew-tap", &spec),
vec!["homebrew/core"]
);
}
#[test]
fn desired_packages_brew_casks() {
let spec = PackagesSpec {
brew: Some(BrewSpec {
casks: vec!["firefox".into()],
..Default::default()
}),
..Default::default()
};
assert_eq!(
desired_packages_for_spec("brew-cask", &spec),
vec!["firefox"]
);
}
#[test]
fn desired_packages_apt() {
let spec = PackagesSpec {
apt: Some(AptSpec {
packages: vec!["git".into()],
..Default::default()
}),
..Default::default()
};
assert_eq!(desired_packages_for_spec("apt", &spec), vec!["git"]);
}
#[test]
fn desired_packages_cargo() {
let spec = PackagesSpec {
cargo: Some(CargoSpec {
packages: vec!["ripgrep".into()],
..Default::default()
}),
..Default::default()
};
assert_eq!(desired_packages_for_spec("cargo", &spec), vec!["ripgrep"]);
}
#[test]
fn desired_packages_npm() {
let spec = PackagesSpec {
npm: Some(NpmSpec {
global: vec!["typescript".into()],
..Default::default()
}),
..Default::default()
};
assert_eq!(desired_packages_for_spec("npm", &spec), vec!["typescript"]);
}
#[test]
fn desired_packages_pipx() {
let spec = PackagesSpec {
pipx: vec!["black".into()],
..Default::default()
};
assert_eq!(desired_packages_for_spec("pipx", &spec), vec!["black"]);
}
#[test]
fn desired_packages_snap_merges_classic() {
let spec = PackagesSpec {
snap: Some(SnapSpec {
packages: vec!["core".into()],
classic: vec!["code".into()],
}),
..Default::default()
};
let result = desired_packages_for_spec("snap", &spec);
assert_eq!(result, vec!["core", "code"]);
}
#[test]
fn desired_packages_snap_classic_dedup() {
let spec = PackagesSpec {
snap: Some(SnapSpec {
packages: vec!["code".into()],
classic: vec!["code".into()],
}),
..Default::default()
};
let result = desired_packages_for_spec("snap", &spec);
assert_eq!(result, vec!["code"]);
}
#[test]
fn desired_packages_custom_manager() {
let spec = PackagesSpec {
custom: vec![CustomManagerSpec {
name: "my-mgr".into(),
check: "which my-mgr".into(),
list_installed: "my-mgr list".into(),
install: "my-mgr install".into(),
uninstall: "my-mgr remove".into(),
update: None,
packages: vec!["tool-a".into()],
}],
..Default::default()
};
assert_eq!(desired_packages_for_spec("my-mgr", &spec), vec!["tool-a"]);
}
#[test]
fn desired_packages_unknown_manager() {
let spec = PackagesSpec::default();
assert!(desired_packages_for_spec("nonexistent", &spec).is_empty());
}
#[test]
fn desired_packages_winget() {
let spec = PackagesSpec {
winget: vec!["Microsoft.VisualStudioCode".into(), "Git.Git".into()],
..Default::default()
};
assert_eq!(
desired_packages_for_spec("winget", &spec),
vec!["Microsoft.VisualStudioCode", "Git.Git"]
);
}
#[test]
fn desired_packages_chocolatey() {
let spec = PackagesSpec {
chocolatey: vec!["nodejs".into()],
..Default::default()
};
assert_eq!(
desired_packages_for_spec("chocolatey", &spec),
vec!["nodejs"]
);
}
#[test]
fn desired_packages_scoop() {
let spec = PackagesSpec {
scoop: vec!["ripgrep".into()],
..Default::default()
};
assert_eq!(desired_packages_for_spec("scoop", &spec), vec!["ripgrep"]);
}
#[test]
fn load_config_valid_yaml() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("cfgd.yaml");
let yaml = "apiVersion: cfgd.io/v1alpha1\nkind: Config\nmetadata:\n name: test\nspec:\n profile: default\n".to_string();
std::fs::write(&path, &yaml).unwrap();
let cfg = load_config(&path).unwrap();
assert_eq!(cfg.metadata.name, "test");
}
#[test]
fn load_config_valid_toml() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("cfgd.toml");
let toml = "apiVersion = \"cfgd.io/v1alpha1\"\nkind = \"Config\"\n\n[metadata]\nname = \"test\"\n\n[spec]\nprofile = \"default\"\n";
std::fs::write(&path, toml).unwrap();
let cfg = load_config(&path).unwrap();
assert_eq!(cfg.metadata.name, "test");
}
#[test]
fn load_config_missing_file() {
let result = load_config(std::path::Path::new("/nonexistent-12345/cfgd.yaml"));
let err = result.unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("config file not found"),
"expected 'config file not found' in error, got: {msg}"
);
assert!(
msg.contains("/nonexistent-12345/cfgd.yaml"),
"expected path in error, got: {msg}"
);
}
#[test]
fn test_ai_config_defaults() {
let yaml = r#"
apiVersion: cfgd.io/v1alpha1
kind: Config
metadata:
name: test
spec: {}
"#;
let config: CfgdConfig = serde_yaml::from_str(yaml).unwrap();
let ai = config.spec.ai.unwrap_or_default();
assert_eq!(ai.provider, "claude");
assert_eq!(ai.model, "claude-sonnet-4-6");
assert_eq!(ai.api_key_env, "ANTHROPIC_API_KEY");
}
#[test]
fn test_ai_config_custom() {
let yaml = r#"
apiVersion: cfgd.io/v1alpha1
kind: Config
metadata:
name: test
spec:
ai:
provider: claude
model: claude-opus-4-6
apiKeyEnv: MY_CLAUDE_KEY
"#;
let config: CfgdConfig = serde_yaml::from_str(yaml).unwrap();
let ai = config.spec.ai.unwrap_or_default();
assert_eq!(ai.model, "claude-opus-4-6");
assert_eq!(ai.api_key_env, "MY_CLAUDE_KEY");
}
#[test]
fn test_existing_config_without_ai_still_parses() {
let yaml = r#"
apiVersion: cfgd.io/v1alpha1
kind: Config
metadata:
name: my-workstation
spec:
profile: work
theme: default
"#;
let config: CfgdConfig = serde_yaml::from_str(yaml).unwrap();
assert!(config.spec.ai.is_none());
}
#[test]
fn three_level_inheritance() {
let dir = tempfile::tempdir().unwrap();
let profiles = dir.path().join("profiles");
std::fs::create_dir_all(&profiles).unwrap();
let grandparent = "apiVersion: cfgd.io/v1alpha1\nkind: Profile\nmetadata:\n name: grandparent\nspec:\n inherits: []\n modules: []\n env:\n - name: A\n value: '1'\n";
let parent = "apiVersion: cfgd.io/v1alpha1\nkind: Profile\nmetadata:\n name: parent\nspec:\n inherits:\n - grandparent\n modules: []\n env:\n - name: B\n value: '2'\n";
let child = "apiVersion: cfgd.io/v1alpha1\nkind: Profile\nmetadata:\n name: child\nspec:\n inherits:\n - parent\n modules: []\n env:\n - name: C\n value: '3'\n";
std::fs::write(profiles.join("grandparent.yaml"), grandparent).unwrap();
std::fs::write(profiles.join("parent.yaml"), parent).unwrap();
std::fs::write(profiles.join("child.yaml"), child).unwrap();
let resolved = resolve_profile("child", &profiles).unwrap();
let names: Vec<&str> = resolved
.merged
.env
.iter()
.map(|e| e.name.as_str())
.collect();
assert!(names.contains(&"A"));
assert!(names.contains(&"B"));
assert!(names.contains(&"C"));
}
#[test]
fn script_entry_deserialize_simple() {
let yaml = r#""echo hello""#;
let entry: ScriptEntry = serde_yaml::from_str(yaml).unwrap();
match entry {
ScriptEntry::Simple(s) => assert_eq!(s, "echo hello"),
_ => panic!("expected Simple variant"),
}
}
#[test]
fn script_entry_deserialize_full() {
let yaml = r#"
run: scripts/check.sh
timeout: 30s
continueOnError: true
"#;
let entry: ScriptEntry = serde_yaml::from_str(yaml).unwrap();
match entry {
ScriptEntry::Full {
run,
timeout,
continue_on_error,
..
} => {
assert_eq!(run, "scripts/check.sh");
assert_eq!(timeout, Some("30s".to_string()));
assert_eq!(continue_on_error, Some(true));
}
_ => panic!("expected Full variant"),
}
}
#[test]
fn script_spec_deserialize_all_hooks() {
let yaml = r#"
preApply:
- scripts/pre.sh
postApply:
- run: scripts/post.sh
timeout: 60s
preReconcile:
- scripts/reconcile-pre.sh
postReconcile:
- scripts/reconcile-post.sh
onDrift:
- scripts/drift.sh
onChange:
- run: systemctl restart myservice
continueOnError: true
"#;
let spec: ScriptSpec = serde_yaml::from_str(yaml).unwrap();
assert_eq!(spec.pre_apply.len(), 1);
assert_eq!(spec.post_apply.len(), 1);
assert_eq!(spec.pre_reconcile.len(), 1);
assert_eq!(spec.post_reconcile.len(), 1);
assert_eq!(spec.on_drift.len(), 1);
assert_eq!(spec.on_change.len(), 1);
}
#[test]
fn script_spec_backward_compat_empty() {
let yaml = "{}";
let spec: ScriptSpec = serde_yaml::from_str(yaml).unwrap();
assert!(spec.pre_apply.is_empty());
assert!(spec.post_apply.is_empty());
assert!(spec.pre_reconcile.is_empty());
assert!(spec.post_reconcile.is_empty());
assert!(spec.on_drift.is_empty());
assert!(spec.on_change.is_empty());
}
#[test]
fn encryption_mode_default_is_in_repo() {
let mode = EncryptionMode::default();
assert_eq!(mode, EncryptionMode::InRepo);
}
#[test]
fn managed_file_spec_encryption_in_repo() {
let yaml = r#"
source: dotfiles/.zshrc
target: ~/.zshrc
encryption:
backend: sops
mode: InRepo
"#;
let spec: ManagedFileSpec = serde_yaml::from_str(yaml).unwrap();
let enc = spec.encryption.expect("encryption should be Some");
assert_eq!(enc.backend, "sops");
assert_eq!(enc.mode, EncryptionMode::InRepo);
}
#[test]
fn managed_file_spec_encryption_always() {
let yaml = r#"
source: secrets/.env
target: ~/.env
encryption:
backend: age
mode: Always
"#;
let spec: ManagedFileSpec = serde_yaml::from_str(yaml).unwrap();
let enc = spec.encryption.expect("encryption should be Some");
assert_eq!(enc.backend, "age");
assert_eq!(enc.mode, EncryptionMode::Always);
}
#[test]
fn managed_file_spec_no_encryption() {
let yaml = r#"
source: dotfiles/.bashrc
target: ~/.bashrc
"#;
let spec: ManagedFileSpec = serde_yaml::from_str(yaml).unwrap();
assert!(spec.encryption.is_none());
}
#[test]
fn managed_file_spec_permissions() {
let yaml = r#"
source: dotfiles/.ssh/config
target: ~/.ssh/config
permissions: "600"
"#;
let spec: ManagedFileSpec = serde_yaml::from_str(yaml).unwrap();
assert_eq!(spec.permissions.as_deref(), Some("600"));
}
#[test]
fn managed_file_spec_permissions_absent() {
let yaml = r#"
source: dotfiles/.vimrc
target: ~/.vimrc
"#;
let spec: ManagedFileSpec = serde_yaml::from_str(yaml).unwrap();
assert!(spec.permissions.is_none());
}
#[test]
fn source_constraints_encryption() {
let yaml = r#"
noScripts: true
noSecretsRead: true
allowedTargetPaths: []
allowSystemChanges: false
requireSignedCommits: false
encryption:
requiredTargets:
- "~/.ssh/*"
- "~/.gnupg/*"
backend: sops
mode: InRepo
"#;
let sc: SourceConstraints = serde_yaml::from_str(yaml).unwrap();
let enc = sc.encryption.expect("encryption should be Some");
assert_eq!(enc.required_targets.len(), 2);
assert_eq!(enc.required_targets[0], "~/.ssh/*");
assert_eq!(enc.required_targets[1], "~/.gnupg/*");
assert_eq!(enc.backend.as_deref(), Some("sops"));
assert_eq!(enc.mode, Some(EncryptionMode::InRepo));
}
#[test]
fn source_constraints_no_encryption_defaults_none() {
let sc = SourceConstraints::default();
assert!(sc.encryption.is_none());
}
#[test]
fn source_constraints_encryption_required_targets_only() {
let yaml = r#"
encryption:
requiredTargets:
- "~/.aws/credentials"
"#;
let sc: SourceConstraints = serde_yaml::from_str(yaml).unwrap();
let enc = sc.encryption.expect("encryption should be Some");
assert_eq!(enc.required_targets.len(), 1);
assert!(enc.backend.is_none());
assert!(enc.mode.is_none());
}
#[test]
fn module_file_entry_with_encryption() {
let yaml = r#"
source: files/.gitconfig
target: ~/.gitconfig
encryption:
backend: sops
mode: InRepo
"#;
let entry: ModuleFileEntry = serde_yaml::from_str(yaml).unwrap();
let enc = entry.encryption.expect("encryption should be Some");
assert_eq!(enc.backend, "sops");
assert_eq!(enc.mode, EncryptionMode::InRepo);
}
#[test]
fn module_file_entry_no_encryption() {
let yaml = r#"
source: files/.tmux.conf
target: ~/.tmux.conf
"#;
let entry: ModuleFileEntry = serde_yaml::from_str(yaml).unwrap();
assert!(entry.encryption.is_none());
}
#[test]
fn encryption_spec_mode_defaults_to_in_repo_when_omitted() {
let yaml = r#"
backend: sops
"#;
let spec: EncryptionSpec = serde_yaml::from_str(yaml).unwrap();
assert_eq!(spec.backend, "sops");
assert_eq!(spec.mode, EncryptionMode::InRepo);
}
#[test]
fn secret_spec_with_envs_only() {
let yaml = r#"
source: op://vault/item/password
envs:
- DB_PASSWORD
"#;
let spec: SecretSpec = serde_yaml::from_str(yaml).unwrap();
assert_eq!(spec.source, "op://vault/item/password");
assert!(spec.target.is_none());
assert_eq!(spec.envs.as_ref().unwrap(), &["DB_PASSWORD"]);
}
#[test]
fn secret_spec_with_target_and_envs() {
let yaml = r#"
source: secrets/api-key.enc
target: ~/.config/app/key
envs:
- API_KEY
"#;
let spec: SecretSpec = serde_yaml::from_str(yaml).unwrap();
assert_eq!(spec.source, "secrets/api-key.enc");
assert_eq!(
spec.target.unwrap(),
std::path::PathBuf::from("~/.config/app/key")
);
assert_eq!(spec.envs.as_ref().unwrap(), &["API_KEY"]);
}
#[test]
fn secret_spec_with_target_only() {
let yaml = r#"
source: secrets/credentials.enc
target: ~/.config/app/credentials
"#;
let spec: SecretSpec = serde_yaml::from_str(yaml).unwrap();
assert_eq!(spec.source, "secrets/credentials.enc");
assert_eq!(
spec.target.unwrap(),
std::path::PathBuf::from("~/.config/app/credentials")
);
assert!(spec.envs.is_none());
}
#[test]
fn secret_spec_neither_target_nor_envs_fails_validation() {
let specs = vec![SecretSpec {
source: "secrets/orphan.enc".to_string(),
target: None,
template: None,
backend: None,
envs: None,
}];
let result = validate_secret_specs(&specs);
assert!(result.is_err());
let err_msg = result.unwrap_err().to_string();
assert!(
err_msg.contains("must have at least one of 'target' or 'envs'"),
"unexpected error: {}",
err_msg
);
}
#[test]
fn secret_spec_validation_passes_with_target() {
let specs = vec![SecretSpec {
source: "secrets/key.enc".to_string(),
target: Some(std::path::PathBuf::from("~/.ssh/key")),
template: None,
backend: None,
envs: None,
}];
validate_secret_specs(&specs).expect("validation should pass for spec with target");
assert_eq!(specs[0].source, "secrets/key.enc");
assert_eq!(
specs[0].target.as_deref(),
Some(std::path::Path::new("~/.ssh/key"))
);
assert!(specs[0].envs.is_none());
}
#[test]
fn secret_spec_validation_passes_with_envs() {
let specs = vec![SecretSpec {
source: "op://vault/item".to_string(),
target: None,
template: None,
backend: None,
envs: Some(vec!["SECRET_KEY".to_string()]),
}];
validate_secret_specs(&specs).expect("validation should pass for spec with envs");
assert_eq!(specs[0].source, "op://vault/item");
assert!(specs[0].target.is_none());
let envs = specs[0].envs.as_ref().expect("envs should be Some");
assert_eq!(envs.len(), 1);
assert_eq!(envs[0], "SECRET_KEY");
}
#[test]
fn policy_items_with_secrets() {
let yaml = r#"
secrets:
- source: op://vault/db/password
envs:
- DB_PASSWORD
- source: secrets/tls.enc
target: /etc/tls/cert.pem
"#;
let items: PolicyItems = serde_yaml::from_str(yaml).unwrap();
assert_eq!(items.secrets.len(), 2);
assert_eq!(items.secrets[0].source, "op://vault/db/password");
assert!(items.secrets[0].target.is_none());
assert_eq!(items.secrets[0].envs.as_ref().unwrap(), &["DB_PASSWORD"]);
assert_eq!(items.secrets[1].source, "secrets/tls.enc");
assert_eq!(
items.secrets[1].target.as_ref().unwrap(),
&std::path::PathBuf::from("/etc/tls/cert.pem")
);
assert!(items.secrets[1].envs.is_none());
}
#[test]
fn policy_items_default_has_empty_secrets() {
let items = PolicyItems::default();
assert!(items.secrets.is_empty());
}
#[test]
fn module_spec_system_field_deserializes() {
let yaml = r#"
apiVersion: cfgd.io/v1alpha1
kind: Module
metadata:
name: git-setup
spec:
system:
git:
user.name: Jane Doe
user.email: jane@example.com
sshKeys:
- path: ~/.ssh/id_ed25519.pub
comment: jane@example.com
"#;
let doc: ModuleDocument = serde_yaml::from_str(yaml).unwrap();
assert_eq!(doc.spec.system.len(), 2);
assert!(doc.spec.system.contains_key("git"));
assert!(doc.spec.system.contains_key("sshKeys"));
let git_val = &doc.spec.system["git"];
assert_eq!(
git_val["user.name"],
serde_yaml::Value::String("Jane Doe".into())
);
assert_eq!(
git_val["user.email"],
serde_yaml::Value::String("jane@example.com".into())
);
}
#[test]
fn module_spec_system_defaults_to_empty() {
let yaml = r#"
apiVersion: cfgd.io/v1alpha1
kind: Module
metadata:
name: nvim
spec:
packages:
- name: neovim
"#;
let doc: ModuleDocument = serde_yaml::from_str(yaml).unwrap();
assert!(doc.spec.system.is_empty());
}
#[test]
fn module_system_merges_into_profile_system() {
let profile_yaml = r#"
git:
user.name: Old Name
user.signingkey: ABC123
"#;
let module_yaml = r#"
git:
user.name: New Name
user.email: new@example.com
"#;
let mut profile_system: HashMap<String, serde_yaml::Value> =
serde_yaml::from_str(profile_yaml).unwrap();
let module_system: HashMap<String, serde_yaml::Value> =
serde_yaml::from_str(module_yaml).unwrap();
for (key, value) in &module_system {
crate::deep_merge_yaml(
profile_system
.entry(key.clone())
.or_insert(serde_yaml::Value::Null),
value,
);
}
let git = &profile_system["git"];
assert_eq!(
git["user.name"],
serde_yaml::Value::String("New Name".into())
);
assert_eq!(
git["user.email"],
serde_yaml::Value::String("new@example.com".into())
);
assert_eq!(
git["user.signingkey"],
serde_yaml::Value::String("ABC123".into())
);
}
#[test]
fn module_system_overrides_profile_on_conflict() {
let mut profile_system: HashMap<String, serde_yaml::Value> = {
let mut m = HashMap::new();
m.insert(
"git".to_string(),
serde_yaml::from_str("user.name: Profile Name").unwrap(),
);
m
};
let module_system: HashMap<String, serde_yaml::Value> = {
let mut m = HashMap::new();
m.insert(
"git".to_string(),
serde_yaml::from_str("user.name: Module Name").unwrap(),
);
m
};
for (key, value) in &module_system {
crate::deep_merge_yaml(
profile_system
.entry(key.clone())
.or_insert(serde_yaml::Value::Null),
value,
);
}
assert_eq!(
profile_system["git"]["user.name"],
serde_yaml::Value::String("Module Name".into())
);
}
#[test]
fn parse_full_compliance_config() {
let yaml = r#"
apiVersion: cfgd.io/v1alpha1
kind: Config
metadata:
name: test
spec:
compliance:
enabled: true
interval: 30m
retention: 90d
scope:
files: true
packages: false
system: true
secrets: false
watchPaths:
- /etc
- /usr/local
watchPackageManagers:
- brew
- apt
export:
format: Yaml
path: /var/lib/cfgd/compliance/
"#;
let config = parse_config(yaml, Path::new("cfgd.yaml")).unwrap();
let compliance = config.spec.compliance.as_ref().unwrap();
assert!(compliance.enabled);
assert_eq!(compliance.interval, "30m");
assert_eq!(compliance.retention, "90d");
assert!(compliance.scope.files);
assert!(!compliance.scope.packages);
assert!(compliance.scope.system);
assert!(!compliance.scope.secrets);
assert_eq!(compliance.scope.watch_paths, vec!["/etc", "/usr/local"]);
assert_eq!(compliance.scope.watch_package_managers, vec!["brew", "apt"]);
assert_eq!(compliance.export.format, ComplianceFormat::Yaml);
assert_eq!(compliance.export.path, "/var/lib/cfgd/compliance/");
}
#[test]
fn parse_compliance_defaults_from_enabled_only() {
let yaml = r#"
apiVersion: cfgd.io/v1alpha1
kind: Config
metadata:
name: test
spec:
compliance:
enabled: true
"#;
let config = parse_config(yaml, Path::new("cfgd.yaml")).unwrap();
let compliance = config.spec.compliance.as_ref().unwrap();
assert!(compliance.enabled);
assert_eq!(compliance.interval, "1h");
assert_eq!(compliance.retention, "30d");
assert!(compliance.scope.files);
assert!(compliance.scope.packages);
assert!(compliance.scope.system);
assert!(compliance.scope.secrets);
assert!(compliance.scope.watch_paths.is_empty());
assert!(compliance.scope.watch_package_managers.is_empty());
assert_eq!(compliance.export.format, ComplianceFormat::Json);
assert_eq!(compliance.export.path, "~/.local/share/cfgd/compliance/");
}
#[test]
fn parse_compliance_watch_paths_and_managers() {
let yaml = r#"
apiVersion: cfgd.io/v1alpha1
kind: Config
metadata:
name: test
spec:
compliance:
enabled: true
scope:
watchPaths:
- /home/user/.config
watchPackageManagers:
- cargo
- npm
"#;
let config = parse_config(yaml, Path::new("cfgd.yaml")).unwrap();
let scope = &config.spec.compliance.as_ref().unwrap().scope;
assert_eq!(scope.watch_paths, vec!["/home/user/.config"]);
assert_eq!(scope.watch_package_managers, vec!["cargo", "npm"]);
}
#[test]
fn compliance_format_defaults_to_json() {
let export = ComplianceExport::default();
assert_eq!(export.format, ComplianceFormat::Json);
}
#[test]
fn parse_complete_cfgd_yaml_with_compliance() {
let yaml = r#"
apiVersion: cfgd.io/v1alpha1
kind: Config
metadata:
name: workstation
spec:
profile: default
compliance:
enabled: true
interval: 1h
retention: 30d
scope:
files: true
packages: true
system: true
secrets: true
export:
format: Json
path: ~/.local/share/cfgd/compliance/
"#;
let config = parse_config(yaml, Path::new("cfgd.yaml")).unwrap();
assert_eq!(config.spec.profile.as_deref(), Some("default"));
let compliance = config.spec.compliance.as_ref().unwrap();
assert!(compliance.enabled);
assert_eq!(compliance.interval, "1h");
assert_eq!(compliance.retention, "30d");
assert!(compliance.scope.files);
assert!(compliance.scope.packages);
assert!(compliance.scope.system);
assert!(compliance.scope.secrets);
assert_eq!(compliance.export.format, ComplianceFormat::Json);
assert_eq!(compliance.export.path, "~/.local/share/cfgd/compliance/");
}
#[test]
fn compliance_absent_when_not_specified() {
let yaml = r#"
apiVersion: cfgd.io/v1alpha1
kind: Config
metadata:
name: test
spec:
profile: default
"#;
let config = parse_config(yaml, Path::new("cfgd.yaml")).unwrap();
assert!(config.spec.compliance.is_none());
}
#[test]
fn yaml_anchor_limit_rejects_bomb() {
let mut yaml = String::from("apiVersion: cfgd.io/v1alpha1\nkind: Config\n");
for i in 0..300 {
yaml.push_str(&format!("key{}: &anchor{} value{}\n", i, i, i));
}
let result = parse_config(&yaml, std::path::Path::new("bomb.yaml"));
assert!(result.is_err());
assert!(
result
.unwrap_err()
.to_string()
.contains("too many YAML anchors")
);
}
#[test]
fn yaml_anchor_limit_accepts_normal_config() {
let yaml = r#"
apiVersion: cfgd.io/v1alpha1
kind: Config
metadata:
name: test
spec:
profile: default
"#;
let result = parse_config(yaml, std::path::Path::new("cfgd.yaml"));
assert!(
result.is_ok(),
"normal config should parse: {:?}",
result.err()
);
let config = result.unwrap();
assert_eq!(config.metadata.name, "test");
assert_eq!(config.spec.profile.as_deref(), Some("default"));
assert_eq!(config.api_version, "cfgd.io/v1alpha1");
assert_eq!(config.kind, "Config");
assert!(config.spec.origin.is_empty(), "no origins configured");
assert!(config.spec.sources.is_empty(), "no sources configured");
}
#[test]
fn env_var_rejects_invalid_name_at_deserialization() {
let yaml = r#"
name: "MY_VAR; rm -rf /"
value: "safe"
"#;
let result: std::result::Result<EnvVar, _> = serde_yaml::from_str(yaml);
assert!(result.is_err());
assert!(
result
.unwrap_err()
.to_string()
.contains("invalid env var name")
);
}
#[test]
fn env_var_accepts_valid_name_at_deserialization() {
let yaml = r#"
name: "MY_VAR_123"
value: "hello"
"#;
let ev: EnvVar = serde_yaml::from_str(yaml).unwrap();
assert_eq!(ev.name, "MY_VAR_123");
assert_eq!(ev.value, "hello");
}
#[test]
fn alias_rejects_invalid_name_at_deserialization() {
let yaml = r#"
name: "my alias; evil"
command: "ls -la"
"#;
let result: std::result::Result<ShellAlias, _> = serde_yaml::from_str(yaml);
assert!(result.is_err());
assert!(
result
.unwrap_err()
.to_string()
.contains("invalid alias name")
);
}
#[test]
fn alias_accepts_valid_name_at_deserialization() {
let yaml = r#"
name: "ll"
command: "ls -la"
"#;
let alias: ShellAlias = serde_yaml::from_str(yaml).unwrap();
assert_eq!(alias.name, "ll");
assert_eq!(alias.command, "ls -la");
}
#[test]
#[serial_test::serial]
#[tracing_test::traced_test]
fn legacy_theme_subheader_emits_warning() {
let yaml = r##"
spec:
theme:
name: dracula
overrides:
subheader: "#ff79c6"
"##;
super::parse::warn_on_legacy_theme_keys(yaml);
assert!(
logs_contain("theme.overrides.subheader is no longer supported"),
"expected legacy-key warning to fire"
);
}
#[test]
#[serial_test::serial]
#[tracing_test::traced_test]
fn legacy_icon_success_emits_rename_warning() {
let yaml = r##"
spec:
theme:
name: dracula
overrides:
iconSuccess: "++"
"##;
super::parse::warn_on_legacy_theme_keys(yaml);
assert!(
logs_contain("iconSuccess is renamed to iconOk"),
"expected rename warning to fire"
);
}
#[test]
#[serial_test::serial]
#[tracing_test::traced_test]
fn modern_overrides_emit_no_warning() {
let yaml = r##"
spec:
theme:
name: dracula
overrides:
iconOk: "✔"
running: "#00ff00"
"##;
super::parse::warn_on_legacy_theme_keys(yaml);
assert!(!logs_contain("no longer supported"));
assert!(!logs_contain("renamed to"));
}