use super::*;
use std::path::Path;
use crate::config::{
ModuleFileEntry, ModuleLockEntry, ModuleLockfile, ModulePackageEntry, ModuleSpec, parse_module,
};
use crate::platform::Platform;
use crate::providers::{PackageManager, StubPackageManager as MockManager};
use crate::test_helpers::{
linux_ubuntu_platform, macos_platform, make_manager_map, make_test_modules, test_printer,
};
use super::git::resolve_subdir;
#[test]
fn load_modules_empty_dir() {
let dir = tempfile::tempdir().unwrap();
let result = load_modules(dir.path()).unwrap();
assert!(result.is_empty());
}
#[test]
fn load_modules_no_modules_dir() {
let dir = tempfile::tempdir().unwrap();
let result = load_modules(dir.path()).unwrap();
assert!(result.is_empty());
}
#[test]
fn load_single_module() {
let dir = tempfile::tempdir().unwrap();
let mod_dir = dir.path().join("modules").join("nvim");
std::fs::create_dir_all(&mod_dir).unwrap();
std::fs::write(
mod_dir.join("module.yaml"),
r#"
apiVersion: cfgd.io/v1alpha1
kind: Module
metadata:
name: nvim
spec:
depends: [node]
packages:
- name: neovim
minVersion: "0.9"
prefer: [brew, snap, apt]
aliases:
snap: nvim
- name: ripgrep
files:
- source: config/
target: ~/.config/nvim/
"#,
)
.unwrap();
let modules = load_modules(dir.path()).unwrap();
assert_eq!(modules.len(), 1);
let nvim = &modules["nvim"];
assert_eq!(nvim.name, "nvim");
assert_eq!(nvim.spec.depends, vec!["node"]);
assert_eq!(nvim.spec.packages.len(), 2);
assert_eq!(nvim.spec.packages[0].name, "neovim");
assert_eq!(nvim.spec.packages[0].min_version, Some("0.9".to_string()));
assert_eq!(nvim.spec.packages[0].prefer, vec!["brew", "snap", "apt"]);
assert_eq!(
nvim.spec.packages[0].aliases.get("snap"),
Some(&"nvim".to_string())
);
assert_eq!(nvim.spec.packages[1].name, "ripgrep");
assert_eq!(nvim.spec.files.len(), 1);
}
#[test]
fn load_module_name_mismatch_errors() {
let dir = tempfile::tempdir().unwrap();
let mod_dir = dir.path().join("modules").join("wrong-name");
std::fs::create_dir_all(&mod_dir).unwrap();
std::fs::write(
mod_dir.join("module.yaml"),
r#"
apiVersion: cfgd.io/v1alpha1
kind: Module
metadata:
name: actual-name
spec: {}
"#,
)
.unwrap();
let result = load_modules(dir.path());
assert!(result.is_err());
let err = result.unwrap_err();
assert!(err.to_string().contains("does not match"));
}
#[test]
fn load_module_wrong_kind_errors() {
let dir = tempfile::tempdir().unwrap();
let mod_dir = dir.path().join("modules").join("bad");
std::fs::create_dir_all(&mod_dir).unwrap();
std::fs::write(
mod_dir.join("module.yaml"),
r#"
apiVersion: cfgd.io/v1alpha1
kind: Profile
metadata:
name: bad
spec: {}
"#,
)
.unwrap();
let result = load_modules(dir.path());
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("Module"));
}
#[test]
fn dependency_order_single_no_deps() {
let modules = make_test_modules(&[("nvim", &[])]);
let order = resolve_dependency_order(&["nvim".into()], &modules).unwrap();
assert_eq!(order, vec!["nvim"]);
}
#[test]
fn dependency_order_linear_chain() {
let modules = make_test_modules(&[("a", &[]), ("b", &["a"]), ("c", &["b"])]);
let order = resolve_dependency_order(&["c".into()], &modules).unwrap();
assert_eq!(order, vec!["a", "b", "c"]);
}
#[test]
fn dependency_order_diamond() {
let modules = make_test_modules(&[
("base", &[]),
("left", &["base"]),
("right", &["base"]),
("top", &["left", "right"]),
]);
let order = resolve_dependency_order(&["top".into()], &modules).unwrap();
assert_eq!(order[0], "base");
assert!(order.contains(&"left".to_string()));
assert!(order.contains(&"right".to_string()));
assert_eq!(order.last().unwrap(), "top");
}
#[test]
fn dependency_order_cycle_detected() {
let modules = make_test_modules(&[("a", &["b"]), ("b", &["a"])]);
let result = resolve_dependency_order(&["a".into()], &modules);
assert!(result.is_err());
let err = result.unwrap_err();
assert!(err.to_string().contains("cycle"));
}
#[test]
fn dependency_order_missing_dependency() {
let modules = make_test_modules(&[("a", &["missing"])]);
let result = resolve_dependency_order(&["a".into()], &modules);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("missing"));
}
#[test]
fn dependency_order_module_not_found() {
let modules: HashMap<String, LoadedModule> = HashMap::new();
let result = resolve_dependency_order(&["nonexistent".into()], &modules);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("nonexistent"));
}
#[test]
fn dependency_order_multiple_requested() {
let modules = make_test_modules(&[("base", &[]), ("nvim", &["base"]), ("tmux", &["base"])]);
let order = resolve_dependency_order(&["nvim".into(), "tmux".into()], &modules).unwrap();
assert_eq!(order[0], "base");
assert!(order.contains(&"nvim".to_string()));
assert!(order.contains(&"tmux".to_string()));
assert_eq!(order.len(), 3);
}
#[test]
fn dependency_order_three_node_cycle() {
let modules = make_test_modules(&[("a", &["c"]), ("b", &["a"]), ("c", &["b"])]);
let result = resolve_dependency_order(&["a".into()], &modules);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("cycle"));
}
#[test]
fn resolve_package_simple_native() {
let brew = MockManager::new("brew").with_package("ripgrep", "14.1.0");
let managers = make_manager_map(&[("brew", &brew)]);
let platform = macos_platform();
let entry = ModulePackageEntry {
name: "ripgrep".into(),
min_version: None,
prefer: vec![],
aliases: HashMap::new(),
script: None,
deny: vec![],
platforms: vec![],
};
let result = resolve_package(&entry, "test", &platform, &managers)
.unwrap()
.unwrap();
assert_eq!(result.canonical_name, "ripgrep");
assert_eq!(result.resolved_name, "ripgrep");
assert_eq!(result.manager, "brew");
assert_eq!(result.version, Some("14.1.0".into()));
}
#[test]
fn resolve_package_with_prefer_list() {
let brew = MockManager::new("brew").unavailable();
let apt = MockManager::new("apt").with_package("neovim", "0.10.2");
let snap = MockManager::new("snap").with_package("nvim", "0.10.3");
let managers = make_manager_map(&[("brew", &brew), ("apt", &apt), ("snap", &snap)]);
let platform = linux_ubuntu_platform();
let entry = ModulePackageEntry {
name: "neovim".into(),
min_version: Some("0.9".into()),
prefer: vec!["brew".into(), "snap".into(), "apt".into()],
aliases: [("snap".to_string(), "nvim".to_string())]
.into_iter()
.collect(),
script: None,
deny: vec![],
platforms: vec![],
};
let result = resolve_package(&entry, "nvim", &platform, &managers)
.unwrap()
.unwrap();
assert_eq!(result.manager, "snap");
assert_eq!(result.resolved_name, "nvim"); assert_eq!(result.version, Some("0.10.3".into()));
}
#[test]
fn resolve_package_min_version_check() {
let apt = MockManager::new("apt").with_package("neovim", "0.6.1");
let snap = MockManager::new("snap").with_package("nvim", "0.10.2");
let managers = make_manager_map(&[("apt", &apt), ("snap", &snap)]);
let platform = linux_ubuntu_platform();
let entry = ModulePackageEntry {
name: "neovim".into(),
min_version: Some("0.9".into()),
prefer: vec!["apt".into(), "snap".into()],
aliases: [("snap".to_string(), "nvim".to_string())]
.into_iter()
.collect(),
script: None,
deny: vec![],
platforms: vec![],
};
let result = resolve_package(&entry, "nvim", &platform, &managers)
.unwrap()
.unwrap();
assert_eq!(result.manager, "snap");
assert_eq!(result.version, Some("0.10.2".into()));
}
#[test]
fn resolve_package_unresolvable() {
let apt = MockManager::new("apt").with_package("neovim", "0.6.1");
let managers = make_manager_map(&[("apt", &apt)]);
let platform = linux_ubuntu_platform();
let entry = ModulePackageEntry {
name: "neovim".into(),
min_version: Some("0.9".into()),
prefer: vec!["apt".into()],
aliases: HashMap::new(),
script: None,
deny: vec![],
platforms: vec![],
};
let result = resolve_package(&entry, "nvim", &platform, &managers);
assert!(result.is_err());
assert!(
result
.unwrap_err()
.to_string()
.contains("cannot be resolved")
);
}
#[test]
fn resolve_package_alias_applied() {
let apt = MockManager::new("apt").with_package("fd-find", "8.7.0");
let managers = make_manager_map(&[("apt", &apt)]);
let platform = linux_ubuntu_platform();
let entry = ModulePackageEntry {
name: "fd".into(),
min_version: None,
prefer: vec![],
aliases: [("apt".to_string(), "fd-find".to_string())]
.into_iter()
.collect(),
script: None,
deny: vec![],
platforms: vec![],
};
let result = resolve_package(&entry, "test", &platform, &managers)
.unwrap()
.unwrap();
assert_eq!(result.canonical_name, "fd");
assert_eq!(result.resolved_name, "fd-find");
assert_eq!(result.manager, "apt");
}
#[test]
fn resolve_package_alias_winget() {
let winget = MockManager::new("winget").with_package("Microsoft.VisualStudioCode", "1.85.0");
let managers = make_manager_map(&[("winget", &winget)]);
let platform = linux_ubuntu_platform();
let entry = ModulePackageEntry {
name: "vscode".to_string(),
min_version: None,
prefer: vec!["winget".to_string()],
aliases: [(
"winget".to_string(),
"Microsoft.VisualStudioCode".to_string(),
)]
.into_iter()
.collect(),
script: None,
deny: vec![],
platforms: vec![],
};
let result = resolve_package(&entry, "editor", &platform, &managers)
.unwrap()
.unwrap();
assert_eq!(result.canonical_name, "vscode");
assert_eq!(result.resolved_name, "Microsoft.VisualStudioCode");
assert_eq!(result.manager, "winget");
}
#[test]
fn resolve_package_alias_chocolatey() {
let choco = MockManager::new("chocolatey").with_package("nodejs.install", "21.4.0");
let managers = make_manager_map(&[("chocolatey", &choco)]);
let platform = linux_ubuntu_platform();
let entry = ModulePackageEntry {
name: "node".to_string(),
min_version: None,
prefer: vec!["chocolatey".to_string()],
aliases: [("chocolatey".to_string(), "nodejs.install".to_string())]
.into_iter()
.collect(),
script: None,
deny: vec![],
platforms: vec![],
};
let result = resolve_package(&entry, "runtime", &platform, &managers)
.unwrap()
.unwrap();
assert_eq!(result.canonical_name, "node");
assert_eq!(result.resolved_name, "nodejs.install");
assert_eq!(result.manager, "chocolatey");
}
#[test]
fn resolve_package_alias_scoop() {
let scoop = MockManager::new("scoop").with_package("rg", "14.1.0");
let managers = make_manager_map(&[("scoop", &scoop)]);
let platform = linux_ubuntu_platform();
let entry = ModulePackageEntry {
name: "ripgrep".to_string(),
min_version: None,
prefer: vec!["scoop".to_string()],
aliases: [("scoop".to_string(), "rg".to_string())]
.into_iter()
.collect(),
script: None,
deny: vec![],
platforms: vec![],
};
let result = resolve_package(&entry, "tools", &platform, &managers)
.unwrap()
.unwrap();
assert_eq!(result.canonical_name, "ripgrep");
assert_eq!(result.resolved_name, "rg");
assert_eq!(result.manager, "scoop");
}
#[test]
fn resolve_package_manager_not_registered() {
let managers: HashMap<String, &dyn PackageManager> = HashMap::new();
let platform = linux_ubuntu_platform();
let entry = ModulePackageEntry {
name: "ripgrep".into(),
min_version: None,
prefer: vec!["brew".into()],
aliases: HashMap::new(),
script: None,
deny: vec![],
platforms: vec![],
};
let result = resolve_package(&entry, "test", &platform, &managers);
let err = result.unwrap_err().to_string();
assert!(
err.contains("ripgrep"),
"error should mention the package name: {err}"
);
assert!(
err.contains("cannot be resolved"),
"error should indicate unresolvable: {err}"
);
}
#[test]
fn parse_git_source_plain_https() {
let src = parse_git_source("https://github.com/user/repo.git").unwrap();
assert_eq!(src.repo_url, "https://github.com/user/repo.git");
assert_eq!(src.tag, None);
assert_eq!(src.git_ref, None);
assert_eq!(src.subdir, None);
}
#[test]
fn parse_git_source_with_tag() {
let src = parse_git_source("https://github.com/user/repo.git@v2.1.0").unwrap();
assert_eq!(src.repo_url, "https://github.com/user/repo.git");
assert_eq!(src.tag, Some("v2.1.0".into()));
assert_eq!(src.git_ref, None);
assert_eq!(src.subdir, None);
}
#[test]
fn parse_git_source_with_ref() {
let src = parse_git_source("https://github.com/user/repo.git?ref=dev").unwrap();
assert_eq!(src.repo_url, "https://github.com/user/repo.git");
assert_eq!(src.tag, None);
assert_eq!(src.git_ref, Some("dev".into()));
assert_eq!(src.subdir, None);
}
#[test]
fn parse_git_source_with_subdir() {
let src = parse_git_source("https://github.com/user/repo.git//nvim").unwrap();
assert_eq!(src.repo_url, "https://github.com/user/repo.git");
assert_eq!(src.tag, None);
assert_eq!(src.git_ref, None);
assert_eq!(src.subdir, Some("nvim".into()));
}
#[test]
fn parse_git_source_subdir_with_tag() {
let src = parse_git_source("https://github.com/user/dotfiles.git//nvim@v3.0").unwrap();
assert_eq!(src.repo_url, "https://github.com/user/dotfiles.git");
assert_eq!(src.tag, Some("v3.0".into()));
assert_eq!(src.subdir, Some("nvim".into()));
}
#[test]
fn parse_git_source_ssh_with_tag() {
let src = parse_git_source("git@github.com:user/nvim-config.git@v2.1.0").unwrap();
assert_eq!(src.repo_url, "git@github.com:user/nvim-config.git");
assert_eq!(src.tag, Some("v2.1.0".into()));
}
#[test]
fn parse_git_source_ssh_with_ref() {
let src = parse_git_source("git@github.com:user/nvim-config.git?ref=main").unwrap();
assert_eq!(src.repo_url, "git@github.com:user/nvim-config.git");
assert_eq!(src.git_ref, Some("main".into()));
assert_eq!(src.tag, None);
}
#[test]
fn parse_git_source_not_git_url() {
let result = parse_git_source("config/");
let err = result.unwrap_err().to_string();
assert!(
err.contains("not a git URL"),
"error should say 'not a git URL': {err}"
);
assert!(
err.contains("config/"),
"error should include the invalid input: {err}"
);
}
#[test]
fn is_git_source_tests() {
assert!(is_git_source("https://github.com/user/repo.git"));
assert!(is_git_source("git@github.com:user/repo.git"));
assert!(is_git_source("ssh://git@github.com/user/repo.git"));
assert!(!is_git_source("config/"));
assert!(!is_git_source("../relative/path"));
assert!(!is_git_source("~/.config/nvim"));
}
#[test]
fn git_cache_dir_deterministic() {
let base = Path::new("/tmp/cache");
let dir1 = git_cache_dir(base, "https://github.com/user/repo.git");
let dir2 = git_cache_dir(base, "https://github.com/user/repo.git");
assert_eq!(dir1, dir2);
}
#[test]
fn git_cache_dir_different_urls() {
let base = Path::new("/tmp/cache");
let dir1 = git_cache_dir(base, "https://github.com/user/repo1.git");
let dir2 = git_cache_dir(base, "https://github.com/user/repo2.git");
assert_ne!(dir1, dir2);
}
#[test]
fn resolve_local_files() {
let dir = tempfile::tempdir().unwrap();
let config_dir = dir.path().join("config");
std::fs::create_dir_all(&config_dir).unwrap();
std::fs::write(config_dir.join("init.lua"), "-- test").unwrap();
let module = LoadedModule {
name: "nvim".into(),
spec: ModuleSpec {
files: vec![ModuleFileEntry {
source: "config/".into(),
target: "/home/user/.config/nvim/".into(),
strategy: None,
private: false,
encryption: None,
}],
..Default::default()
},
dir: dir.path().to_path_buf(),
};
let cache_dir = tempfile::tempdir().unwrap();
let printer = test_printer();
let resolved = resolve_module_files(&module, cache_dir.path(), &printer).unwrap();
assert_eq!(resolved.len(), 1);
assert_eq!(resolved[0].source, dir.path().join("config/"));
assert_eq!(
resolved[0].target,
PathBuf::from("/home/user/.config/nvim/")
);
assert!(!resolved[0].is_git_source);
}
#[test]
fn full_module_resolution() {
let dir = tempfile::tempdir().unwrap();
let node_dir = dir.path().join("modules").join("node");
std::fs::create_dir_all(&node_dir).unwrap();
std::fs::write(
node_dir.join("module.yaml"),
r#"
apiVersion: cfgd.io/v1alpha1
kind: Module
metadata:
name: node
spec:
packages:
- name: nodejs
aliases:
brew: node
"#,
)
.unwrap();
let nvim_dir = dir.path().join("modules").join("nvim");
std::fs::create_dir_all(&nvim_dir).unwrap();
std::fs::write(
nvim_dir.join("module.yaml"),
r#"
apiVersion: cfgd.io/v1alpha1
kind: Module
metadata:
name: nvim
spec:
depends: [node]
packages:
- name: neovim
- name: ripgrep
scripts:
postApply:
- nvim --headless "+Lazy! sync" +qa
"#,
)
.unwrap();
let brew = MockManager::new("brew")
.with_package("node", "20.0.0")
.with_package("neovim", "0.10.2")
.with_package("ripgrep", "14.1.0");
let managers = make_manager_map(&[("brew", &brew)]);
let platform = macos_platform();
let cache_dir = tempfile::tempdir().unwrap();
let printer = test_printer();
let resolved = resolve_modules(
&["nvim".into()],
dir.path(),
cache_dir.path(),
&platform,
&managers,
&printer,
)
.unwrap();
assert_eq!(resolved.len(), 2);
assert_eq!(resolved[0].name, "node");
assert_eq!(resolved[1].name, "nvim");
assert_eq!(resolved[0].packages.len(), 1);
assert_eq!(resolved[0].packages[0].canonical_name, "nodejs");
assert_eq!(resolved[0].packages[0].resolved_name, "node"); assert_eq!(resolved[0].packages[0].manager, "brew");
assert_eq!(resolved[1].packages.len(), 2);
assert_eq!(resolved[1].packages[0].canonical_name, "neovim");
assert_eq!(resolved[1].packages[1].canonical_name, "ripgrep");
assert_eq!(resolved[1].post_apply_scripts.len(), 1);
}
#[test]
fn parse_module_yaml() {
let yaml = r#"
apiVersion: cfgd.io/v1alpha1
kind: Module
metadata:
name: test-mod
spec:
depends: [a, b]
packages:
- name: foo
minVersion: "1.0"
prefer: [brew, apt]
aliases:
apt: foo-tools
- name: bar
files:
- source: config/
target: ~/.config/foo/
- source: https://github.com/user/repo.git@v1.0
target: ~/.config/bar/
scripts:
postApply:
- echo done
"#;
let doc = parse_module(yaml).unwrap();
assert_eq!(doc.metadata.name, "test-mod");
assert_eq!(doc.spec.depends, vec!["a", "b"]);
assert_eq!(doc.spec.packages.len(), 2);
assert_eq!(doc.spec.packages[0].name, "foo");
assert_eq!(doc.spec.packages[0].min_version, Some("1.0".into()));
assert_eq!(doc.spec.packages[0].prefer, vec!["brew", "apt"]);
assert_eq!(
doc.spec.packages[0].aliases.get("apt"),
Some(&"foo-tools".to_string())
);
assert_eq!(doc.spec.files.len(), 2);
assert_eq!(
doc.spec.files[1].source,
"https://github.com/user/repo.git@v1.0"
);
let scripts = doc.spec.scripts.unwrap();
assert_eq!(
scripts.post_apply,
vec![crate::config::ScriptEntry::Simple("echo done".to_string())]
);
}
#[test]
fn parse_module_minimal() {
let yaml = r#"
apiVersion: cfgd.io/v1alpha1
kind: Module
metadata:
name: minimal
spec: {}
"#;
let doc = parse_module(yaml).unwrap();
assert_eq!(doc.metadata.name, "minimal");
assert!(doc.spec.packages.is_empty());
assert!(doc.spec.files.is_empty());
assert!(doc.spec.depends.is_empty());
}
#[test]
fn profile_with_modules_field() {
let yaml = r#"
apiVersion: cfgd.io/v1alpha1
kind: Profile
metadata:
name: test
spec:
modules: [nvim, tmux, git]
packages:
brew:
formulae: [ripgrep]
"#;
let doc: crate::config::ProfileDocument = serde_yaml::from_str(yaml).unwrap();
assert_eq!(doc.spec.modules, vec!["nvim", "tmux", "git"]);
}
#[test]
fn resolve_package_script_manager() {
let managers: HashMap<String, &dyn PackageManager> = HashMap::new();
let platform = linux_ubuntu_platform();
let entry = ModulePackageEntry {
name: "rustup".into(),
min_version: None,
prefer: vec!["script".into()],
aliases: HashMap::new(),
script: Some(
"curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y".into(),
),
deny: vec![],
platforms: vec![],
};
let result = resolve_package(&entry, "test", &platform, &managers)
.unwrap()
.unwrap();
assert_eq!(result.manager, "script");
assert_eq!(result.canonical_name, "rustup");
assert_eq!(result.resolved_name, "rustup");
assert!(result.script.is_some());
assert!(result.script.unwrap().contains("rustup.rs"));
assert!(result.version.is_none());
}
#[test]
fn resolve_package_script_fallback() {
let brew = MockManager::new("brew").unavailable();
let managers = make_manager_map(&[("brew", &brew)]);
let platform = linux_ubuntu_platform();
let entry = ModulePackageEntry {
name: "neovim".into(),
min_version: None,
prefer: vec!["brew".into(), "script".into()],
aliases: HashMap::new(),
script: Some("scripts/install-neovim.sh".into()),
deny: vec![],
platforms: vec![],
};
let result = resolve_package(&entry, "nvim", &platform, &managers)
.unwrap()
.unwrap();
assert_eq!(result.manager, "script");
assert_eq!(result.script, Some("scripts/install-neovim.sh".into()));
}
#[test]
fn resolve_package_script_preferred_over_manager() {
let brew = MockManager::new("brew").with_package("neovim", "0.10.2");
let managers = make_manager_map(&[("brew", &brew)]);
let platform = macos_platform();
let entry = ModulePackageEntry {
name: "neovim".into(),
min_version: None,
prefer: vec!["script".into(), "brew".into()],
aliases: HashMap::new(),
script: Some("build-from-source.sh".into()),
deny: vec![],
platforms: vec![],
};
let result = resolve_package(&entry, "nvim", &platform, &managers)
.unwrap()
.unwrap();
assert_eq!(result.manager, "script");
}
#[test]
fn resolve_package_script_missing_errors() {
let managers: HashMap<String, &dyn PackageManager> = HashMap::new();
let platform = linux_ubuntu_platform();
let entry = ModulePackageEntry {
name: "rustup".into(),
min_version: None,
prefer: vec!["script".into()],
aliases: HashMap::new(),
script: None, deny: vec![],
platforms: vec![],
};
let result = resolve_package(&entry, "test", &platform, &managers);
assert!(result.is_err());
assert!(
result
.unwrap_err()
.to_string()
.contains("no 'script' field")
);
}
#[test]
fn resolve_package_platform_match_os() {
let apt = MockManager::new("apt").with_package("ripgrep", "14.0.0");
let managers = make_manager_map(&[("apt", &apt)]);
let platform = linux_ubuntu_platform();
let entry = ModulePackageEntry {
name: "ripgrep".into(),
min_version: None,
prefer: vec![],
aliases: HashMap::new(),
script: None,
deny: vec![],
platforms: vec!["linux".into()],
};
let result = resolve_package(&entry, "test", &platform, &managers).unwrap();
assert!(result.is_some());
assert_eq!(result.unwrap().manager, "apt");
}
#[test]
fn resolve_package_platform_skip_wrong_os() {
let apt = MockManager::new("apt").with_package("ripgrep", "14.0.0");
let managers = make_manager_map(&[("apt", &apt)]);
let platform = linux_ubuntu_platform();
let entry = ModulePackageEntry {
name: "coreutils".into(),
min_version: None,
prefer: vec!["brew".into()],
aliases: HashMap::new(),
script: None,
deny: vec![],
platforms: vec!["macos".into()], };
let result = resolve_package(&entry, "test", &platform, &managers).unwrap();
assert!(result.is_none());
}
#[test]
fn resolve_package_platform_match_distro() {
let apt = MockManager::new("apt").with_package("ripgrep", "14.0.0");
let managers = make_manager_map(&[("apt", &apt)]);
let platform = linux_ubuntu_platform();
let entry = ModulePackageEntry {
name: "ripgrep".into(),
min_version: None,
prefer: vec![],
aliases: HashMap::new(),
script: None,
deny: vec![],
platforms: vec!["ubuntu".into()],
};
let result = resolve_package(&entry, "test", &platform, &managers).unwrap();
assert!(result.is_some());
}
#[test]
fn resolve_package_platform_match_arch() {
let apt = MockManager::new("apt").with_package("ripgrep", "14.0.0");
let managers = make_manager_map(&[("apt", &apt)]);
let platform = Platform {
arch: crate::platform::Arch::Aarch64,
..linux_ubuntu_platform()
};
let entry = ModulePackageEntry {
name: "ripgrep".into(),
min_version: None,
prefer: vec![],
aliases: HashMap::new(),
script: None,
deny: vec![],
platforms: vec!["aarch64".into()],
};
let result = resolve_package(&entry, "test", &platform, &managers).unwrap();
assert!(result.is_some());
}
#[test]
fn resolve_package_platform_empty_matches_all() {
let brew = MockManager::new("brew").with_package("ripgrep", "14.0.0");
let managers = make_manager_map(&[("brew", &brew)]);
let platform = macos_platform();
let entry = ModulePackageEntry {
name: "ripgrep".into(),
min_version: None,
prefer: vec![],
aliases: HashMap::new(),
script: None,
deny: vec![],
platforms: vec![], };
let result = resolve_package(&entry, "test", &platform, &managers).unwrap();
assert!(result.is_some());
}
#[test]
fn resolve_module_packages_skips_filtered() {
let brew = MockManager::new("brew").with_package("ripgrep", "14.0.0");
let managers = make_manager_map(&[("brew", &brew)]);
let platform = macos_platform();
let module = LoadedModule {
name: "test".into(),
spec: ModuleSpec {
packages: vec![
ModulePackageEntry {
name: "ripgrep".into(),
min_version: None,
prefer: vec![],
aliases: HashMap::new(),
script: None,
deny: vec![],
platforms: vec![], },
ModulePackageEntry {
name: "apt-only-tool".into(),
min_version: None,
prefer: vec!["apt".into()],
aliases: HashMap::new(),
script: None,
deny: vec![],
platforms: vec!["linux".into()], },
],
..Default::default()
},
dir: PathBuf::from("/fake/test"),
};
let resolved = resolve_module_packages(&module, &platform, &managers).unwrap();
assert_eq!(resolved.len(), 1);
assert_eq!(resolved[0].canonical_name, "ripgrep");
}
#[test]
fn parse_module_with_script_and_platforms() {
let yaml = r#"
apiVersion: cfgd.io/v1alpha1
kind: Module
metadata:
name: rustup
spec:
packages:
- name: rustup
prefer: [script]
script: |
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
- name: sysctl-tweaks
prefer: [script]
script: scripts/apply-sysctl.sh
platforms: [linux]
"#;
let doc = parse_module(yaml).unwrap();
assert_eq!(doc.spec.packages.len(), 2);
let rustup = &doc.spec.packages[0];
assert_eq!(rustup.name, "rustup");
assert_eq!(rustup.prefer, vec!["script"]);
assert!(rustup.script.is_some());
assert!(rustup.script.as_ref().unwrap().contains("rustup.rs"));
assert!(rustup.platforms.is_empty());
let sysctl = &doc.spec.packages[1];
assert_eq!(sysctl.name, "sysctl-tweaks");
assert_eq!(sysctl.script, Some("scripts/apply-sysctl.sh".into()));
assert_eq!(sysctl.platforms, vec!["linux"]);
}
#[test]
fn lockfile_round_trip() {
let dir = tempfile::tempdir().unwrap();
let lockfile = ModuleLockfile {
modules: vec![ModuleLockEntry {
name: "nvim".into(),
url: "https://github.com/user/nvim-module.git@v1.0".into(),
pinned_ref: "v1.0".into(),
commit: "abc123def456".into(),
integrity: "sha256:deadbeef".into(),
subdir: None,
}],
};
save_lockfile(dir.path(), &lockfile).unwrap();
let loaded = load_lockfile(dir.path()).unwrap();
assert_eq!(loaded.modules.len(), 1);
assert_eq!(loaded.modules[0].name, "nvim");
assert_eq!(loaded.modules[0].pinned_ref, "v1.0");
assert_eq!(loaded.modules[0].commit, "abc123def456");
assert_eq!(loaded.modules[0].integrity, "sha256:deadbeef");
assert!(loaded.modules[0].subdir.is_none());
}
#[test]
fn lockfile_round_trip_with_subdir() {
let dir = tempfile::tempdir().unwrap();
let lockfile = ModuleLockfile {
modules: vec![ModuleLockEntry {
name: "tmux".into(),
url: "https://github.com/user/modules.git//tmux@v2.0".into(),
pinned_ref: "v2.0".into(),
commit: "789abc".into(),
integrity: "sha256:cafe".into(),
subdir: Some("tmux".into()),
}],
};
save_lockfile(dir.path(), &lockfile).unwrap();
let loaded = load_lockfile(dir.path()).unwrap();
assert_eq!(loaded.modules[0].subdir, Some("tmux".into()));
}
#[test]
fn load_lockfile_missing_returns_empty() {
let dir = tempfile::tempdir().unwrap();
let lockfile = load_lockfile(dir.path()).unwrap();
assert!(lockfile.modules.is_empty());
}
#[test]
fn hash_module_contents_deterministic() {
let dir = tempfile::tempdir().unwrap();
let mod_dir = dir.path().join("mymodule");
std::fs::create_dir_all(mod_dir.join("config")).unwrap();
std::fs::write(mod_dir.join("module.yaml"), "name: mymodule\n").unwrap();
std::fs::write(mod_dir.join("config/init.lua"), "-- nvim config\n").unwrap();
let hash1 = hash_module_contents(&mod_dir).unwrap();
let hash2 = hash_module_contents(&mod_dir).unwrap();
assert_eq!(hash1, hash2);
assert!(hash1.starts_with("sha256:"));
}
#[test]
fn hash_module_contents_changes_on_file_change() {
let dir = tempfile::tempdir().unwrap();
let mod_dir = dir.path().join("mymod");
std::fs::create_dir_all(&mod_dir).unwrap();
std::fs::write(mod_dir.join("module.yaml"), "v1\n").unwrap();
let hash1 = hash_module_contents(&mod_dir).unwrap();
std::fs::write(mod_dir.join("module.yaml"), "v2\n").unwrap();
let hash2 = hash_module_contents(&mod_dir).unwrap();
assert_ne!(hash1, hash2);
}
#[test]
fn hash_module_contents_skips_dot_git() {
let dir = tempfile::tempdir().unwrap();
let mod_dir = dir.path().join("mymod");
std::fs::create_dir_all(mod_dir.join(".git")).unwrap();
std::fs::write(mod_dir.join("module.yaml"), "content\n").unwrap();
std::fs::write(mod_dir.join(".git/HEAD"), "ref: refs/heads/main\n").unwrap();
let hash_with_git = hash_module_contents(&mod_dir).unwrap();
std::fs::remove_dir_all(mod_dir.join(".git")).unwrap();
let hash_without_git = hash_module_contents(&mod_dir).unwrap();
assert_eq!(hash_with_git, hash_without_git);
}
#[test]
fn verify_lockfile_integrity_success() {
let dir = tempfile::tempdir().unwrap();
let url = "https://example.com/fake.git@v1.0";
let expected_cache_dir = git_cache_dir(dir.path(), "https://example.com/fake.git");
std::fs::create_dir_all(&expected_cache_dir).unwrap();
std::fs::write(expected_cache_dir.join("module.yaml"), "test content\n").unwrap();
let actual_integrity = hash_module_contents(&expected_cache_dir).unwrap();
let entry = ModuleLockEntry {
name: "test".into(),
url: url.into(),
pinned_ref: "v1.0".into(),
commit: "abc".into(),
integrity: actual_integrity,
subdir: None,
};
let result = verify_lockfile_integrity(&entry, dir.path());
assert!(
result.is_ok(),
"integrity check should pass: {:?}",
result.unwrap_err()
);
std::fs::write(expected_cache_dir.join("module.yaml"), "tampered content\n").unwrap();
let tampered_result = verify_lockfile_integrity(&entry, dir.path());
let err = tampered_result.unwrap_err().to_string();
assert!(
err.contains("integrity check failed"),
"tampered module should fail integrity: {err}"
);
}
#[test]
fn verify_lockfile_integrity_mismatch() {
let dir = tempfile::tempdir().unwrap();
let url = "https://example.com/mod.git@v1.0";
let cache_dir = git_cache_dir(dir.path(), "https://example.com/mod.git");
std::fs::create_dir_all(&cache_dir).unwrap();
std::fs::write(cache_dir.join("module.yaml"), "tampered\n").unwrap();
let entry = ModuleLockEntry {
name: "test".into(),
url: url.into(),
pinned_ref: "v1.0".into(),
commit: "abc".into(),
integrity: "sha256:wrong".into(),
subdir: None,
};
let result = verify_lockfile_integrity(&entry, dir.path());
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(err.contains("integrity"));
}
#[test]
fn diff_module_specs_no_changes() {
let module = LoadedModule {
name: "test".into(),
spec: ModuleSpec {
depends: vec!["dep1".into()],
packages: vec![ModulePackageEntry {
name: "pkg1".into(),
min_version: Some("1.0".into()),
prefer: vec![],
aliases: HashMap::new(),
script: None,
deny: vec![],
platforms: vec![],
}],
files: vec![],
env: vec![],
aliases: vec![],
scripts: None,
system: HashMap::new(),
},
dir: PathBuf::from("/fake"),
};
let changes = diff_module_specs(&module, &module);
assert_eq!(changes, vec!["(no spec changes)"]);
}
#[test]
fn diff_module_specs_detects_changes() {
let old = LoadedModule {
name: "test".into(),
spec: ModuleSpec {
depends: vec!["dep1".into()],
packages: vec![
ModulePackageEntry {
name: "pkg1".into(),
min_version: Some("1.0".into()),
prefer: vec![],
aliases: HashMap::new(),
script: None,
deny: vec![],
platforms: vec![],
},
ModulePackageEntry {
name: "pkg2".into(),
min_version: None,
prefer: vec![],
aliases: HashMap::new(),
script: None,
deny: vec![],
platforms: vec![],
},
],
files: vec![ModuleFileEntry {
source: "config/".into(),
target: "~/.config/test/".into(),
strategy: None,
private: false,
encryption: None,
}],
env: vec![],
aliases: vec![],
scripts: None,
system: HashMap::new(),
},
dir: PathBuf::from("/fake"),
};
let new = LoadedModule {
name: "test".into(),
spec: ModuleSpec {
depends: vec!["dep1".into(), "dep2".into()],
packages: vec![
ModulePackageEntry {
name: "pkg1".into(),
min_version: Some("2.0".into()),
prefer: vec![],
aliases: HashMap::new(),
script: None,
deny: vec![],
platforms: vec![],
},
ModulePackageEntry {
name: "pkg3".into(),
min_version: None,
prefer: vec![],
aliases: HashMap::new(),
script: None,
deny: vec![],
platforms: vec![],
},
],
files: vec![ModuleFileEntry {
source: "config/".into(),
target: "~/.config/new/".into(),
strategy: None,
private: false,
encryption: None,
}],
env: vec![],
aliases: vec![],
scripts: None,
system: HashMap::new(),
},
dir: PathBuf::from("/fake"),
};
let changes = diff_module_specs(&old, &new);
assert!(changes.iter().any(|c| c.contains("+ dependency: dep2")));
assert!(changes.iter().any(|c| c.contains("+ package: pkg3")));
assert!(changes.iter().any(|c| c.contains("- package: pkg2")));
assert!(
changes
.iter()
.any(|c| c.contains("~ package 'pkg1': minVersion"))
);
assert!(changes.iter().any(|c| c.contains("+ file target")));
assert!(changes.iter().any(|c| c.contains("- file target")));
}
#[test]
fn extract_registry_name_https() {
assert_eq!(
extract_registry_name("https://github.com/cfgd-community/modules.git"),
Some("cfgd-community".into())
);
}
#[test]
fn extract_registry_name_ssh() {
assert_eq!(
extract_registry_name("git@github.com:myorg/modules.git"),
Some("myorg".into())
);
}
#[test]
fn extract_registry_name_non_github() {
assert_eq!(
extract_registry_name("https://gitlab.com/org/repo.git"),
None
);
}
#[test]
fn is_registry_ref_with_registry_module() {
assert!(is_registry_ref("community/tmux"));
assert!(is_registry_ref("myorg/nvim@v1.0"));
}
#[test]
fn is_registry_ref_bare_name() {
assert!(!is_registry_ref("tmux"));
}
#[test]
fn is_registry_ref_git_url() {
assert!(!is_registry_ref("https://github.com/user/repo.git"));
assert!(!is_registry_ref("git@github.com:user/repo.git"));
}
#[test]
fn parse_registry_ref_with_tag() {
let r = parse_registry_ref("community/tmux@v1.0").unwrap();
assert_eq!(r.registry, "community");
assert_eq!(r.module, "tmux");
assert_eq!(r.tag, Some("v1.0".into()));
}
#[test]
fn parse_registry_ref_without_tag() {
let r = parse_registry_ref("myorg/nvim").unwrap();
assert_eq!(r.registry, "myorg");
assert_eq!(r.module, "nvim");
assert!(r.tag.is_none());
}
#[test]
fn parse_registry_ref_invalid() {
assert!(parse_registry_ref("tmux").is_none());
assert!(parse_registry_ref("/tmux").is_none());
assert!(parse_registry_ref("community/").is_none());
assert!(parse_registry_ref("community/@v1").is_none());
assert!(parse_registry_ref("community/tmux@").is_none());
}
#[test]
fn group_module_tags_groups_by_module_prefix() {
use super::registry::group_module_tags;
let tags = ["tmux/v1.0.0", "tmux/v1.1.0", "nvim/v0.9.0"];
let map = group_module_tags(tags);
assert_eq!(map.len(), 2);
assert_eq!(map.get("tmux").unwrap(), &vec!["v1.0.0", "v1.1.0"]);
assert_eq!(map.get("nvim").unwrap(), &vec!["v0.9.0"]);
}
#[test]
fn group_module_tags_sorts_versions_semver_then_lexical() {
use super::registry::group_module_tags;
let tags = ["tmux/v1.10.0", "tmux/v1.2.0", "tmux/v1.9.0"];
let map = group_module_tags(tags);
assert_eq!(
map.get("tmux").unwrap(),
&vec!["v1.2.0", "v1.9.0", "v1.10.0"],
"must be semver-sorted, not lexical"
);
}
#[test]
fn group_module_tags_falls_back_to_lexical_for_non_semver() {
use super::registry::group_module_tags;
let tags = ["tmux/release-c", "tmux/release-a", "tmux/release-b"];
let map = group_module_tags(tags);
assert_eq!(
map.get("tmux").unwrap(),
&vec!["release-a", "release-b", "release-c"]
);
}
#[test]
fn group_module_tags_drops_unprefixed_tag_names() {
use super::registry::group_module_tags;
let tags = ["v1.0.0", "release-2024-01", "tmux/v1.0.0"];
let map = group_module_tags(tags);
assert_eq!(map.len(), 1, "non-module-prefixed tags must be dropped");
assert!(map.contains_key("tmux"));
}
#[test]
fn group_module_tags_handles_multi_slash_tag_names() {
use super::registry::group_module_tags;
let tags = ["myorg/group/v1.0.0"];
let map = group_module_tags(tags);
assert_eq!(map.get("myorg").unwrap(), &vec!["group/v1.0.0"]);
}
#[test]
fn group_module_tags_empty_input_returns_empty_map() {
use super::registry::group_module_tags;
let empty: [&str; 0] = [];
assert!(group_module_tags(empty).is_empty());
}
#[test]
fn group_module_tags_last_is_highest_version() {
use super::registry::group_module_tags;
let tags = ["tmux/v0.1.0", "tmux/v2.5.0", "tmux/v1.0.0"];
let map = group_module_tags(tags);
let versions = map.get("tmux").unwrap();
assert_eq!(
versions.last().map(String::as_str),
Some("v2.5.0"),
"last() must return the highest semver"
);
}
#[test]
fn resolve_profile_module_name_bare() {
assert_eq!(resolve_profile_module_name("tmux"), "tmux");
}
#[test]
fn load_locked_modules_merges_with_local() {
let dir = tempfile::tempdir().unwrap();
let mod_dir = dir.path().join("modules").join("local-mod");
std::fs::create_dir_all(&mod_dir).unwrap();
std::fs::write(
mod_dir.join("module.yaml"),
r#"
apiVersion: cfgd.io/v1alpha1
kind: Module
metadata:
name: local-mod
spec:
packages:
- name: local-pkg
"#,
)
.unwrap();
let lockfile = ModuleLockfile {
modules: vec![ModuleLockEntry {
name: "remote-mod".into(),
url: "https://example.com/remote.git@v1.0".into(),
pinned_ref: "v1.0".into(),
commit: "abc".into(),
integrity: "sha256:test".into(),
subdir: None,
}],
};
save_lockfile(dir.path(), &lockfile).unwrap();
let local = load_modules(dir.path()).unwrap();
assert_eq!(local.len(), 1);
assert!(local.contains_key("local-mod"));
}
#[test]
fn fetch_remote_module_rejects_unpinned() {
let dir = tempfile::tempdir().unwrap();
let printer = test_printer();
let result = fetch_remote_module("https://github.com/user/module.git", dir.path(), &printer);
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(err.contains("pinned ref"));
}
#[test]
fn fetch_remote_module_rejects_branch_ref() {
let dir = tempfile::tempdir().unwrap();
let printer = test_printer();
let result = fetch_remote_module(
"https://github.com/user/module.git?ref=main",
dir.path(),
&printer,
);
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(err.contains("pinned ref"));
}
#[test]
fn parse_git_source_ref_with_subdir() {
let src = parse_git_source("https://github.com/user/repo.git?ref=dev//subdir").unwrap();
assert_eq!(src.repo_url, "https://github.com/user/repo.git");
assert_eq!(src.git_ref.as_deref(), Some("dev"));
assert_eq!(src.subdir.as_deref(), Some("subdir"));
assert!(src.tag.is_none());
}
#[test]
fn parse_git_source_ref_with_subdir_and_tag() {
let src = parse_git_source("https://github.com/user/repo.git?ref=dev//subdir@v1.0").unwrap();
assert_eq!(src.repo_url, "https://github.com/user/repo.git");
assert_eq!(src.git_ref.as_deref(), Some("dev"));
assert_eq!(src.subdir.as_deref(), Some("subdir"));
assert_eq!(src.tag.as_deref(), Some("v1.0"));
}
#[test]
fn parse_git_source_ssh_no_dot_git_with_tag() {
let src = parse_git_source("git@github.com:user/repo@v2.0").unwrap();
assert_eq!(src.repo_url, "git@github.com:user/repo");
assert_eq!(src.tag.as_deref(), Some("v2.0"));
}
#[test]
fn parse_git_source_ssh_no_dot_git_no_tag() {
let src = parse_git_source("git@github.com:user/repo").unwrap();
assert_eq!(src.repo_url, "git@github.com:user/repo");
assert!(src.tag.is_none());
}
#[test]
fn hash_module_contents_empty_dir() {
let dir = tempfile::tempdir().unwrap();
let hash = hash_module_contents(dir.path()).unwrap();
assert!(hash.starts_with("sha256:"));
let hash2 = hash_module_contents(dir.path()).unwrap();
assert_eq!(hash, hash2);
}
#[test]
#[cfg(unix)]
fn hash_module_contents_skips_symlinks() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("real.txt"), "hello").unwrap();
std::os::unix::fs::symlink("/dev/null", dir.path().join("link.txt")).unwrap();
let hash_with_link = hash_module_contents(dir.path()).unwrap();
std::fs::remove_file(dir.path().join("link.txt")).unwrap();
let hash_without_link = hash_module_contents(dir.path()).unwrap();
assert_eq!(hash_with_link, hash_without_link);
}
#[test]
fn diff_module_specs_scripts_changed() {
let old = LoadedModule {
name: "test".into(),
spec: ModuleSpec {
depends: vec![],
packages: vec![],
files: vec![],
env: vec![],
aliases: vec![],
scripts: Some(crate::config::ScriptSpec {
post_apply: vec![crate::config::ScriptEntry::Simple("echo old".to_string())],
..Default::default()
}),
system: HashMap::new(),
},
dir: PathBuf::from("/tmp"),
};
let new = LoadedModule {
name: "test".into(),
spec: ModuleSpec {
depends: vec![],
packages: vec![],
files: vec![],
env: vec![],
aliases: vec![],
scripts: Some(crate::config::ScriptSpec {
post_apply: vec![crate::config::ScriptEntry::Simple("echo new".to_string())],
..Default::default()
}),
system: HashMap::new(),
},
dir: PathBuf::from("/tmp"),
};
let changes = diff_module_specs(&old, &new);
assert!(changes.iter().any(|c| c.contains("+ postApply script")));
assert!(changes.iter().any(|c| c.contains("- postApply script")));
}
#[test]
fn dependency_order_self_dependency_detected() {
let modules = make_test_modules(&[("a", &["a"])]);
let result = resolve_dependency_order(&["a".into()], &modules);
let err = result.unwrap_err().to_string();
assert!(err.contains("cycle"), "error should mention cycle: {err}");
assert!(
err.contains("a"),
"error should mention the cyclic module: {err}"
);
}
#[test]
fn resolve_package_deny_excludes_manager() {
let brew = MockManager::new("brew").with_package("ripgrep", "14.1.0");
let managers = make_manager_map(&[("brew", &brew)]);
let platform = macos_platform();
let entry = ModulePackageEntry {
name: "ripgrep".into(),
min_version: None,
prefer: vec![],
aliases: HashMap::new(),
script: None,
deny: vec!["brew".into()],
platforms: vec![],
};
let result = resolve_package(&entry, "test", &platform, &managers);
let err = result.unwrap_err().to_string();
assert!(
err.contains("ripgrep"),
"error should mention the package: {err}"
);
assert!(
err.contains("cannot be resolved"),
"error should indicate unresolvable: {err}"
);
}
#[test]
fn load_lockfile_malformed_yaml_errors() {
let dir = tempfile::tempdir().unwrap();
let lockfile_path = dir.path().join("modules.lock");
std::fs::write(&lockfile_path, "{{{{not valid yaml").unwrap();
let result = load_lockfile(dir.path());
assert!(result.is_err());
}
#[test]
fn extract_registry_name_https_github() {
assert_eq!(
extract_registry_name("https://github.com/myorg/cfgd-registry"),
Some("myorg".to_string())
);
}
#[test]
fn extract_registry_name_https_github_with_git_suffix() {
assert_eq!(
extract_registry_name("https://github.com/acme/modules.git"),
Some("acme".to_string())
);
}
#[test]
fn extract_registry_name_ssh_github() {
assert_eq!(
extract_registry_name("git@github.com:myorg/cfgd-registry.git"),
Some("myorg".to_string())
);
}
#[test]
fn extract_registry_name_http_github() {
assert_eq!(
extract_registry_name("http://github.com/testorg/repo"),
Some("testorg".to_string())
);
}
#[test]
fn extract_registry_name_non_github_returns_none() {
assert_eq!(extract_registry_name("https://gitlab.com/org/repo"), None);
}
#[test]
fn extract_registry_name_empty_returns_none() {
assert_eq!(extract_registry_name(""), None);
}
fn make_loaded_module(name: &str, spec: crate::config::ModuleSpec) -> LoadedModule {
LoadedModule {
name: name.to_string(),
spec,
dir: PathBuf::from("/fake"),
}
}
#[test]
fn diff_module_specs_no_changes_default() {
let spec = crate::config::ModuleSpec::default();
let old = make_loaded_module("test", spec.clone());
let new = make_loaded_module("test", spec);
let changes = diff_module_specs(&old, &new);
assert_eq!(changes, vec!["(no spec changes)".to_string()]);
}
#[test]
fn diff_module_specs_added_dependency() {
let old = make_loaded_module("test", crate::config::ModuleSpec::default());
let new_spec = crate::config::ModuleSpec {
depends: vec!["core".to_string()],
..Default::default()
};
let new = make_loaded_module("test", new_spec);
let changes = diff_module_specs(&old, &new);
assert!(changes.iter().any(|c| c.contains("+ dependency: core")));
}
#[test]
fn diff_module_specs_removed_dependency() {
let old_spec = crate::config::ModuleSpec {
depends: vec!["core".to_string()],
..Default::default()
};
let old = make_loaded_module("test", old_spec);
let new = make_loaded_module("test", crate::config::ModuleSpec::default());
let changes = diff_module_specs(&old, &new);
assert!(changes.iter().any(|c| c.contains("- dependency: core")));
}
#[test]
fn diff_module_specs_added_package() {
let old = make_loaded_module("test", crate::config::ModuleSpec::default());
let new_spec = crate::config::ModuleSpec {
packages: vec![crate::config::ModulePackageEntry {
name: "ripgrep".to_string(),
..Default::default()
}],
..Default::default()
};
let new = make_loaded_module("test", new_spec);
let changes = diff_module_specs(&old, &new);
assert!(changes.iter().any(|c| c.contains("+ package: ripgrep")));
}
#[test]
fn diff_module_specs_removed_package() {
let old_spec = crate::config::ModuleSpec {
packages: vec![crate::config::ModulePackageEntry {
name: "vim".to_string(),
..Default::default()
}],
..Default::default()
};
let old = make_loaded_module("test", old_spec);
let new = make_loaded_module("test", crate::config::ModuleSpec::default());
let changes = diff_module_specs(&old, &new);
assert!(changes.iter().any(|c| c.contains("- package: vim")));
}
#[test]
fn diff_module_specs_package_version_change() {
let old_spec = crate::config::ModuleSpec {
packages: vec![crate::config::ModulePackageEntry {
name: "kubectl".to_string(),
min_version: Some("1.28".to_string()),
..Default::default()
}],
..Default::default()
};
let old = make_loaded_module("test", old_spec);
let new_spec = crate::config::ModuleSpec {
packages: vec![crate::config::ModulePackageEntry {
name: "kubectl".to_string(),
min_version: Some("1.30".to_string()),
..Default::default()
}],
..Default::default()
};
let new = make_loaded_module("test", new_spec);
let changes = diff_module_specs(&old, &new);
assert!(
changes
.iter()
.any(|c| c.contains("kubectl") && c.contains("1.28") && c.contains("1.30"))
);
}
#[test]
fn diff_module_specs_added_file() {
let old = make_loaded_module("test", crate::config::ModuleSpec::default());
let new_spec = crate::config::ModuleSpec {
files: vec![crate::config::ModuleFileEntry {
source: "zshrc".to_string(),
target: "~/.zshrc".to_string(),
strategy: None,
private: false,
encryption: None,
}],
..Default::default()
};
let new = make_loaded_module("test", new_spec);
let changes = diff_module_specs(&old, &new);
assert!(
changes
.iter()
.any(|c| c.contains("+ file target: ~/.zshrc"))
);
}
#[test]
fn diff_module_specs_multiple_changes() {
let old_spec = crate::config::ModuleSpec {
depends: vec!["base".to_string()],
packages: vec![crate::config::ModulePackageEntry {
name: "vim".to_string(),
..Default::default()
}],
..Default::default()
};
let old = make_loaded_module("test", old_spec);
let new_spec = crate::config::ModuleSpec {
depends: vec!["core".to_string()],
packages: vec![crate::config::ModulePackageEntry {
name: "neovim".to_string(),
..Default::default()
}],
..Default::default()
};
let new = make_loaded_module("test", new_spec);
let changes = diff_module_specs(&old, &new);
assert!(
changes.len() >= 4,
"expected at least 4 changes, got {changes:?}"
);
}
#[test]
fn load_lockfile_nonexistent_returns_empty() {
let dir = tempfile::tempdir().unwrap();
let lockfile = load_lockfile(dir.path()).unwrap();
assert!(lockfile.modules.is_empty());
}
#[test]
fn save_and_load_lockfile_roundtrip() {
let dir = tempfile::tempdir().unwrap();
let lockfile = crate::config::ModuleLockfile {
modules: vec![crate::config::ModuleLockEntry {
name: "nvim".to_string(),
url: "https://github.com/user/nvim-config.git@v1.0".to_string(),
pinned_ref: "v1.0".to_string(),
commit: "abc123".to_string(),
integrity: "sha256:deadbeef".to_string(),
subdir: None,
}],
};
save_lockfile(dir.path(), &lockfile).unwrap();
let loaded = load_lockfile(dir.path()).unwrap();
assert_eq!(loaded.modules.len(), 1);
assert_eq!(loaded.modules[0].name, "nvim");
assert_eq!(loaded.modules[0].commit, "abc123");
assert_eq!(loaded.modules[0].integrity, "sha256:deadbeef");
}
#[test]
fn save_lockfile_overwrites_existing() {
let dir = tempfile::tempdir().unwrap();
let lock1 = crate::config::ModuleLockfile {
modules: vec![crate::config::ModuleLockEntry {
name: "old".to_string(),
url: "https://example.com/old.git@v1".to_string(),
pinned_ref: "v1".to_string(),
commit: "111".to_string(),
integrity: "sha256:aaa".to_string(),
subdir: None,
}],
};
save_lockfile(dir.path(), &lock1).unwrap();
let lock2 = crate::config::ModuleLockfile {
modules: vec![crate::config::ModuleLockEntry {
name: "new".to_string(),
url: "https://example.com/new.git@v2".to_string(),
pinned_ref: "v2".to_string(),
commit: "222".to_string(),
integrity: "sha256:bbb".to_string(),
subdir: Some("subdir".to_string()),
}],
};
save_lockfile(dir.path(), &lock2).unwrap();
let loaded = load_lockfile(dir.path()).unwrap();
assert_eq!(loaded.modules.len(), 1);
assert_eq!(loaded.modules[0].name, "new");
assert_eq!(loaded.modules[0].subdir, Some("subdir".to_string()));
}
#[test]
fn hash_module_contents_differs_on_content_change() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("file.txt"), "version 1").unwrap();
let h1 = hash_module_contents(dir.path()).unwrap();
std::fs::write(dir.path().join("file.txt"), "version 2").unwrap();
let h2 = hash_module_contents(dir.path()).unwrap();
assert_ne!(h1, h2);
}
#[test]
fn hash_module_contents_skips_git_dir() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("file.txt"), "content").unwrap();
std::fs::create_dir(dir.path().join(".git")).unwrap();
std::fs::write(dir.path().join(".git/HEAD"), "ref: refs/heads/main").unwrap();
let h1 = hash_module_contents(dir.path()).unwrap();
std::fs::write(dir.path().join(".git/HEAD"), "ref: refs/heads/dev").unwrap();
let h2 = hash_module_contents(dir.path()).unwrap();
assert_eq!(h1, h2);
}
#[test]
fn verify_lockfile_integrity_missing_cache_dir() {
let cache_base = tempfile::tempdir().unwrap();
let entry = crate::config::ModuleLockEntry {
name: "test".to_string(),
url: "https://github.com/user/repo.git@v1.0".to_string(),
pinned_ref: "v1.0".to_string(),
commit: "abc".to_string(),
integrity: "sha256:xxx".to_string(),
subdir: None,
};
let result = verify_lockfile_integrity(&entry, cache_base.path());
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(
err.contains("does not exist") || err.contains("update"),
"expected cache-not-found error, got: {err}"
);
}
#[test]
fn is_registry_ref_with_slash() {
assert!(is_registry_ref("community/tmux"));
assert!(is_registry_ref("myorg/nvim@v1.0"));
}
#[test]
fn is_registry_ref_local_name() {
assert!(!is_registry_ref("tmux"));
assert!(!is_registry_ref("nvim"));
}
#[test]
fn is_registry_ref_git_url_not_registry() {
assert!(!is_registry_ref("https://github.com/user/repo.git"));
assert!(!is_registry_ref("git@github.com:user/repo.git"));
}
#[test]
fn parse_registry_ref_basic() {
let r = parse_registry_ref("community/tmux").unwrap();
assert_eq!(r.registry, "community");
assert_eq!(r.module, "tmux");
assert_eq!(r.tag, None);
}
#[test]
fn parse_registry_ref_empty_registry() {
assert!(parse_registry_ref("/tmux").is_none());
}
#[test]
fn parse_registry_ref_empty_module() {
assert!(parse_registry_ref("community/").is_none());
}
#[test]
fn parse_registry_ref_empty_tag() {
assert!(parse_registry_ref("community/tmux@").is_none());
}
#[test]
fn parse_registry_ref_no_slash() {
assert!(parse_registry_ref("tmux").is_none());
}
#[test]
fn resolve_profile_module_name_local() {
assert_eq!(resolve_profile_module_name("tmux"), "tmux");
assert_eq!(resolve_profile_module_name("nvim"), "nvim");
}
#[test]
fn resolve_profile_module_name_registry_ref() {
assert_eq!(resolve_profile_module_name("community/tmux"), "tmux");
assert_eq!(resolve_profile_module_name("myorg/nvim"), "nvim");
}
#[test]
fn resolve_subdir_none_returns_base() {
let base = PathBuf::from("/cache/abc123");
let result = resolve_subdir(base.clone(), &None, "test", "url").unwrap();
assert_eq!(result, base);
}
#[test]
fn resolve_subdir_valid_path() {
let base = PathBuf::from("/cache/abc123");
let result = resolve_subdir(base, &Some("nvim".to_string()), "test", "url").unwrap();
assert_eq!(result, PathBuf::from("/cache/abc123/nvim"));
}
#[test]
fn resolve_subdir_traversal_rejected() {
let base = PathBuf::from("/cache/abc123");
let result = resolve_subdir(base, &Some("../escape".to_string()), "test", "url");
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("traversal"));
}
#[test]
fn load_module_missing_yaml_errors() {
let dir = tempfile::tempdir().unwrap();
let mod_dir = dir.path().join("mymod");
std::fs::create_dir(&mod_dir).unwrap();
let result = load_module(&mod_dir);
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(
err.contains("not found") || err.contains("mymod"),
"expected not-found error, got: {err}"
);
}
#[test]
fn load_module_valid() {
let dir = tempfile::tempdir().unwrap();
let mod_dir = dir.path().join("mymod");
std::fs::create_dir(&mod_dir).unwrap();
std::fs::write(
mod_dir.join("module.yaml"),
r#"
apiVersion: cfgd.io/v1alpha1
kind: Module
metadata:
name: mymod
spec:
packages:
- name: ripgrep
"#,
)
.unwrap();
let module = load_module(&mod_dir).unwrap();
assert_eq!(module.name, "mymod");
assert_eq!(module.spec.packages.len(), 1);
assert_eq!(module.spec.packages[0].name, "ripgrep");
}
#[test]
fn resolve_package_deny_skips_manager() {
let brew = MockManager::new("brew").with_package("ripgrep", "14.1.0");
let apt = MockManager::new("apt").with_package("ripgrep", "13.0.0");
let managers = make_manager_map(&[("brew", &brew), ("apt", &apt)]);
let platform = linux_ubuntu_platform();
let entry = crate::config::ModulePackageEntry {
name: "ripgrep".into(),
min_version: None,
prefer: vec!["brew".into(), "apt".into()],
aliases: HashMap::new(),
script: None,
deny: vec!["brew".into()],
platforms: vec![],
};
let result = resolve_package(&entry, "test", &platform, &managers)
.unwrap()
.unwrap();
assert_eq!(result.manager, "apt");
}
#[test]
fn resolve_package_platform_filter_skips() {
let brew = MockManager::new("brew").with_package("ripgrep", "14.1.0");
let managers = make_manager_map(&[("brew", &brew)]);
let platform = linux_ubuntu_platform();
let entry = crate::config::ModulePackageEntry {
name: "ripgrep".into(),
min_version: None,
prefer: vec!["brew".into()],
aliases: HashMap::new(),
script: None,
deny: vec![],
platforms: vec!["macos".to_string()], };
let result = resolve_package(&entry, "test", &platform, &managers).unwrap();
assert!(result.is_none());
}
#[test]
fn resolve_package_script_manager_with_deny() {
let managers: HashMap<String, &dyn PackageManager> = HashMap::new();
let platform = linux_ubuntu_platform();
let entry = crate::config::ModulePackageEntry {
name: "rustup".into(),
min_version: None,
prefer: vec!["script".into()],
aliases: HashMap::new(),
script: Some("curl -sSf https://sh.rustup.rs | sh".into()),
deny: vec![],
platforms: vec![],
};
let result = resolve_package(&entry, "test", &platform, &managers)
.unwrap()
.unwrap();
assert_eq!(result.manager, "script");
assert!(result.script.is_some());
assert_eq!(result.canonical_name, "rustup");
}
#[test]
fn resolve_package_script_no_script_field_errors() {
let managers: HashMap<String, &dyn PackageManager> = HashMap::new();
let platform = linux_ubuntu_platform();
let entry = crate::config::ModulePackageEntry {
name: "tool".into(),
min_version: None,
prefer: vec!["script".into()],
aliases: HashMap::new(),
script: None, deny: vec![],
platforms: vec![],
};
let result = resolve_package(&entry, "test", &platform, &managers);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("script"));
}
#[test]
fn git_cache_dir_uses_hash_prefix() {
let base = Path::new("/tmp/cache");
let dir = git_cache_dir(base, "https://github.com/user/repo.git");
assert!(dir.starts_with("/tmp/cache"));
let dirname = dir.file_name().unwrap().to_str().unwrap();
assert_eq!(dirname.len(), 32);
}
#[test]
fn parse_git_source_ref_and_subdir_combined() {
let src = parse_git_source("https://github.com/user/repo.git?ref=dev//nvim").unwrap();
assert_eq!(src.repo_url, "https://github.com/user/repo.git");
assert_eq!(src.git_ref, Some("dev".into()));
assert_eq!(src.subdir, Some("nvim".into()));
assert_eq!(src.tag, None);
}
#[test]
fn parse_git_source_no_git_extension_with_tag() {
let src = parse_git_source("https://github.com/user/repo@v1.0").unwrap();
assert_eq!(src.repo_url, "https://github.com/user/repo");
assert_eq!(src.tag, Some("v1.0".into()));
}
#[test]
fn resolve_module_files_local_relative() {
let dir = tempfile::tempdir().unwrap();
let mod_dir = dir.path().join("modules").join("mymod");
std::fs::create_dir_all(&mod_dir).unwrap();
std::fs::write(mod_dir.join("vimrc"), "set nocompat").unwrap();
let module = LoadedModule {
name: "mymod".into(),
spec: ModuleSpec {
files: vec![ModuleFileEntry {
source: "vimrc".into(),
target: "/tmp/test-target/.vimrc".into(),
strategy: None,
private: false,
encryption: None,
}],
..Default::default()
},
dir: mod_dir.clone(),
};
let printer = test_printer();
let cache_base = dir.path().join("cache");
let resolved = resolve_module_files(&module, &cache_base, &printer).unwrap();
assert_eq!(resolved.len(), 1);
assert_eq!(resolved[0].source, mod_dir.join("vimrc"));
assert_eq!(resolved[0].target, PathBuf::from("/tmp/test-target/.vimrc"));
assert!(!resolved[0].is_git_source);
assert!(resolved[0].strategy.is_none());
assert!(resolved[0].encryption.is_none());
}
#[test]
fn resolve_module_files_path_traversal_rejected() {
let dir = tempfile::tempdir().unwrap();
let mod_dir = dir.path().join("modules").join("evil");
std::fs::create_dir_all(&mod_dir).unwrap();
let module = LoadedModule {
name: "evil".into(),
spec: ModuleSpec {
files: vec![ModuleFileEntry {
source: "../../../etc/passwd".into(),
target: "/tmp/stolen".into(),
strategy: None,
private: false,
encryption: None,
}],
..Default::default()
},
dir: mod_dir,
};
let printer = test_printer();
let cache_base = dir.path().join("cache");
let result = resolve_module_files(&module, &cache_base, &printer);
assert!(result.is_err(), "path traversal should be rejected");
let err = result.unwrap_err().to_string();
assert!(
err.contains("traversal"),
"error should mention traversal: {err}"
);
}
#[test]
fn resolve_module_files_multiple_files() {
let dir = tempfile::tempdir().unwrap();
let mod_dir = dir.path().join("modules").join("multi");
std::fs::create_dir_all(&mod_dir).unwrap();
std::fs::write(mod_dir.join("bashrc"), "# bashrc").unwrap();
std::fs::write(mod_dir.join("zshrc"), "# zshrc").unwrap();
let module = LoadedModule {
name: "multi".into(),
spec: ModuleSpec {
files: vec![
ModuleFileEntry {
source: "bashrc".into(),
target: "/tmp/test-resolve/.bashrc".into(),
strategy: Some(crate::config::FileStrategy::Copy),
private: false,
encryption: None,
},
ModuleFileEntry {
source: "zshrc".into(),
target: "/tmp/test-resolve/.zshrc".into(),
strategy: Some(crate::config::FileStrategy::Symlink),
private: false,
encryption: None,
},
],
..Default::default()
},
dir: mod_dir.clone(),
};
let printer = test_printer();
let cache_base = dir.path().join("cache");
let resolved = resolve_module_files(&module, &cache_base, &printer).unwrap();
assert_eq!(resolved.len(), 2);
assert_eq!(resolved[0].source, mod_dir.join("bashrc"));
assert_eq!(
resolved[0].strategy,
Some(crate::config::FileStrategy::Copy)
);
assert_eq!(resolved[1].source, mod_dir.join("zshrc"));
assert_eq!(
resolved[1].strategy,
Some(crate::config::FileStrategy::Symlink)
);
}
#[test]
fn resolve_module_files_empty_spec() {
let dir = tempfile::tempdir().unwrap();
let mod_dir = dir.path().join("modules").join("empty");
std::fs::create_dir_all(&mod_dir).unwrap();
let module = LoadedModule {
name: "empty".into(),
spec: ModuleSpec::default(),
dir: mod_dir,
};
let printer = test_printer();
let cache_base = dir.path().join("cache");
let resolved = resolve_module_files(&module, &cache_base, &printer).unwrap();
assert!(
resolved.is_empty(),
"module with no files should resolve to empty list"
);
}
#[test]
fn resolve_module_files_symlink_escape_rejected() {
let dir = tempfile::tempdir().unwrap();
let mod_dir = dir.path().join("modules").join("tricky");
std::fs::create_dir_all(&mod_dir).unwrap();
let outside_file = dir.path().join("outside.txt");
std::fs::write(&outside_file, "escaped!").unwrap();
#[cfg(unix)]
std::os::unix::fs::symlink(&outside_file, mod_dir.join("escape.txt")).unwrap();
#[cfg(windows)]
std::os::windows::fs::symlink_file(&outside_file, mod_dir.join("escape.txt")).unwrap();
let module = LoadedModule {
name: "tricky".into(),
spec: ModuleSpec {
files: vec![ModuleFileEntry {
source: "escape.txt".into(),
target: "/tmp/test-tricky/out".into(),
strategy: None,
private: false,
encryption: None,
}],
..Default::default()
},
dir: mod_dir,
};
let printer = test_printer();
let cache_base = dir.path().join("cache");
let result = resolve_module_files(&module, &cache_base, &printer);
assert!(
result.is_err(),
"symlink escaping module directory should be rejected"
);
let err = result.unwrap_err().to_string();
assert!(
err.contains("outside"),
"error should mention resolving outside: {err}"
);
}
#[test]
fn dependency_order_empty_request() {
let modules = make_test_modules(&[("a", &[])]);
let order = resolve_dependency_order(&[], &modules).unwrap();
assert!(order.is_empty(), "empty request should yield empty order");
}
#[test]
fn dependency_order_deduplicated_request() {
let modules = make_test_modules(&[("a", &[])]);
let order = resolve_dependency_order(&["a".into(), "a".into()], &modules).unwrap();
assert_eq!(
order,
vec!["a"],
"duplicate requests should be deduplicated"
);
}
#[test]
fn dependency_order_request_includes_transitive_dep() {
let modules = make_test_modules(&[("base", &[]), ("top", &["base"])]);
let order = resolve_dependency_order(&["top".into(), "base".into()], &modules).unwrap();
assert_eq!(order, vec!["base", "top"]);
}
#[test]
fn dependency_order_independent_subgraphs() {
let modules = make_test_modules(&[("a1", &[]), ("a2", &["a1"]), ("b1", &[]), ("b2", &["b1"])]);
let order = resolve_dependency_order(&["a2".into(), "b2".into()], &modules).unwrap();
assert_eq!(order.len(), 4);
let pos_a1 = order.iter().position(|n| n == "a1").unwrap();
let pos_a2 = order.iter().position(|n| n == "a2").unwrap();
let pos_b1 = order.iter().position(|n| n == "b1").unwrap();
let pos_b2 = order.iter().position(|n| n == "b2").unwrap();
assert!(pos_a1 < pos_a2, "a1 must come before a2");
assert!(pos_b1 < pos_b2, "b1 must come before b2");
}
#[test]
fn dependency_order_deep_chain_within_limit() {
let names: Vec<String> = (0..50).map(|i| format!("mod{i:03}")).collect();
let mut modules = HashMap::new();
for (i, name) in names.iter().enumerate() {
let deps = if i > 0 {
vec![names[i - 1].clone()]
} else {
vec![]
};
modules.insert(
name.clone(),
LoadedModule {
name: name.clone(),
spec: ModuleSpec {
depends: deps,
..Default::default()
},
dir: PathBuf::from(format!("/fake/{name}")),
},
);
}
let order = resolve_dependency_order(&[names.last().unwrap().clone()], &modules).unwrap();
assert_eq!(order.len(), 50);
assert_eq!(order[0], "mod000");
assert_eq!(*order.last().unwrap(), "mod049");
}
#[test]
fn dependency_order_exceeds_depth_limit() {
let names: Vec<String> = (0..52).map(|i| format!("deep{i:03}")).collect();
let mut modules = HashMap::new();
for (i, name) in names.iter().enumerate() {
let deps = if i > 0 {
vec![names[i - 1].clone()]
} else {
vec![]
};
modules.insert(
name.clone(),
LoadedModule {
name: name.clone(),
spec: ModuleSpec {
depends: deps,
..Default::default()
},
dir: PathBuf::from(format!("/fake/{name}")),
},
);
}
let result = resolve_dependency_order(&[names.last().unwrap().clone()], &modules);
assert!(result.is_err(), "chain exceeding depth limit should fail");
let err = result.unwrap_err().to_string();
assert!(
err.contains("depth") || err.contains("cycle"),
"error should mention depth: {err}"
);
}
#[test]
fn load_all_modules_local_only() {
let dir = tempfile::tempdir().unwrap();
let mod_dir = dir.path().join("modules").join("shell");
std::fs::create_dir_all(&mod_dir).unwrap();
std::fs::write(
mod_dir.join("module.yaml"),
r#"
apiVersion: cfgd.io/v1alpha1
kind: Module
metadata:
name: shell
spec:
packages:
- name: zsh
"#,
)
.unwrap();
let cache_base = dir.path().join("cache");
std::fs::create_dir_all(&cache_base).unwrap();
let printer = test_printer();
let modules = load_all_modules(dir.path(), &cache_base, &printer).unwrap();
assert_eq!(modules.len(), 1);
assert!(modules.contains_key("shell"));
assert_eq!(modules["shell"].spec.packages.len(), 1);
assert_eq!(modules["shell"].spec.packages[0].name, "zsh");
}
#[test]
fn load_all_modules_with_empty_lockfile_returns_no_remote_modules() {
let dir = tempfile::tempdir().unwrap();
let cache_base = dir.path().join("cache");
std::fs::create_dir_all(&cache_base).unwrap();
std::fs::write(dir.path().join("modules.lock"), "modules: []\n").unwrap();
let printer = test_printer();
let modules = load_all_modules(dir.path(), &cache_base, &printer).unwrap();
assert!(
modules.is_empty(),
"empty lockfile with no local modules should produce empty result"
);
}
#[test]
fn load_all_modules_errors_when_locked_module_cache_missing() {
let dir = tempfile::tempdir().unwrap();
let cache_base = dir.path().join("cache");
std::fs::create_dir_all(&cache_base).unwrap();
std::fs::write(
dir.path().join("modules.lock"),
r#"modules:
- name: remote-mod
url: "https://github.com/example/modules.git"
pinnedRef: "v1.0.0"
commit: "abc123"
integrity: "sha256:deadbeef"
"#,
)
.unwrap();
let printer = test_printer();
let result = load_all_modules(dir.path(), &cache_base, &printer);
assert!(
result.is_err(),
"expected error when locked remote module has no cache"
);
}
#[test]
fn diff_module_specs_file_changes() {
let old = LoadedModule {
name: "mymod".into(),
spec: ModuleSpec {
files: vec![
ModuleFileEntry {
source: "old.conf".into(),
target: "~/.config/app/old.conf".into(),
strategy: None,
private: false,
encryption: None,
},
ModuleFileEntry {
source: "shared.conf".into(),
target: "~/.config/app/shared.conf".into(),
strategy: None,
private: false,
encryption: None,
},
],
..Default::default()
},
dir: PathBuf::from("/fake/mymod"),
};
let new = LoadedModule {
name: "mymod".into(),
spec: ModuleSpec {
files: vec![
ModuleFileEntry {
source: "new.conf".into(),
target: "~/.config/app/new.conf".into(),
strategy: None,
private: false,
encryption: None,
},
ModuleFileEntry {
source: "shared.conf".into(),
target: "~/.config/app/shared.conf".into(),
strategy: None,
private: false,
encryption: None,
},
],
..Default::default()
},
dir: PathBuf::from("/fake/mymod"),
};
let changes = diff_module_specs(&old, &new);
let joined = changes.join("\n");
assert!(
joined.contains("+ file target: ~/.config/app/new.conf"),
"should show added file: {joined}"
);
assert!(
joined.contains("- file target: ~/.config/app/old.conf"),
"should show removed file: {joined}"
);
assert!(
!joined.contains("shared.conf"),
"unchanged file should not appear: {joined}"
);
}
#[test]
fn diff_module_specs_env_changes_not_tracked() {
let old = LoadedModule {
name: "mymod".into(),
spec: ModuleSpec {
env: vec![crate::config::EnvVar {
name: "OLD".into(),
value: "1".into(),
}],
..Default::default()
},
dir: PathBuf::from("/fake/mymod"),
};
let new = LoadedModule {
name: "mymod".into(),
spec: ModuleSpec {
env: vec![crate::config::EnvVar {
name: "NEW".into(),
value: "2".into(),
}],
..Default::default()
},
dir: PathBuf::from("/fake/mymod"),
};
let changes = diff_module_specs(&old, &new);
assert_eq!(
changes,
vec!["(no spec changes)"],
"env-only change should show as no spec changes (env not diffed)"
);
}
#[test]
fn dependency_order_diamond_deterministic_ordering() {
let modules = make_test_modules(&[
("base", &[]),
("left", &["base"]),
("right", &["base"]),
("top", &["left", "right"]),
]);
let order = resolve_dependency_order(&["top".into()], &modules).unwrap();
assert_eq!(
order,
vec!["base", "left", "right", "top"],
"diamond should produce deterministic alphabetical ordering of peers"
);
}
#[test]
fn dependency_order_wide_fan_out() {
let modules = make_test_modules(&[
("leaf_a", &[]),
("leaf_b", &[]),
("leaf_c", &[]),
("leaf_d", &[]),
("root", &["leaf_a", "leaf_b", "leaf_c", "leaf_d"]),
]);
let order = resolve_dependency_order(&["root".into()], &modules).unwrap();
assert_eq!(order.len(), 5);
let root_pos = order.iter().position(|n| n == "root").unwrap();
assert_eq!(root_pos, 4, "root should be last");
assert_eq!(
&order[..4],
&["leaf_a", "leaf_b", "leaf_c", "leaf_d"],
"leaves should be sorted alphabetically"
);
}
#[test]
fn dependency_order_multiple_requested_shared_deps_no_duplicates() {
let modules = make_test_modules(&[
("shared", &[]),
("a", &["shared"]),
("b", &["shared"]),
("c", &["shared"]),
]);
let order = resolve_dependency_order(&["a".into(), "b".into(), "c".into()], &modules).unwrap();
assert_eq!(order.len(), 4, "should have 4 modules, no duplicates");
let shared_count = order.iter().filter(|n| n.as_str() == "shared").count();
assert_eq!(shared_count, 1, "shared should appear exactly once");
assert_eq!(order[0], "shared");
}
#[test]
fn dependency_order_missing_dep_error_mentions_both_module_and_dep() {
let modules = make_test_modules(&[("app", &["nonexistent"])]);
let result = resolve_dependency_order(&["app".into()], &modules);
let err = result.unwrap_err().to_string();
assert!(
err.contains("app"),
"error should mention the module: {err}"
);
assert!(
err.contains("nonexistent"),
"error should mention the missing dependency: {err}"
);
assert!(
err.contains("not available"),
"error should use 'not available' phrasing: {err}"
);
}
#[test]
fn dependency_order_not_found_error_message() {
let modules: HashMap<String, LoadedModule> = HashMap::new();
let result = resolve_dependency_order(&["ghost".into()], &modules);
let err = result.unwrap_err().to_string();
assert!(
err.contains("not found"),
"error should say 'not found': {err}"
);
assert!(
err.contains("ghost"),
"error should mention the module name: {err}"
);
}
#[test]
fn dependency_order_cycle_error_lists_cycle_members() {
let modules = make_test_modules(&[("x", &["y"]), ("y", &["z"]), ("z", &["x"])]);
let result = resolve_dependency_order(&["x".into()], &modules);
let err = result.unwrap_err().to_string();
assert!(err.contains("cycle"), "error should mention cycle: {err}");
assert!(
err.contains("x") && err.contains("y") && err.contains("z"),
"error should list all cycle members: {err}"
);
}
#[test]
fn dependency_order_partial_cycle_with_non_cyclic_nodes() {
let modules =
make_test_modules(&[("a", &[]), ("b", &["c"]), ("c", &["b"]), ("d", &["a", "b"])]);
let result = resolve_dependency_order(&["d".into()], &modules);
let err = result.unwrap_err().to_string();
assert!(
err.contains("cycle"),
"should detect the b<->c cycle: {err}"
);
}
#[test]
fn dependency_order_self_dep_mentions_module_name() {
let modules = make_test_modules(&[("selfref", &["selfref"])]);
let result = resolve_dependency_order(&["selfref".into()], &modules);
let err = result.unwrap_err().to_string();
assert!(
err.contains("selfref"),
"self-dependency error should name the module: {err}"
);
}
#[test]
fn dependency_order_complex_dag_preserves_ordering_constraints() {
let modules = make_test_modules(&[
("a", &[]),
("b", &["a"]),
("c", &["a", "b"]),
("d", &["b"]),
("e", &["c", "d"]),
]);
let order = resolve_dependency_order(&["e".into()], &modules).unwrap();
assert_eq!(order.len(), 5);
let pos = |n: &str| order.iter().position(|x| x == n).unwrap();
assert!(pos("a") < pos("b"), "a must come before b");
assert!(pos("a") < pos("c"), "a must come before c");
assert!(pos("b") < pos("c"), "b must come before c");
assert!(pos("b") < pos("d"), "b must come before d");
assert!(pos("c") < pos("e"), "c must come before e");
assert!(pos("d") < pos("e"), "d must come before e");
}
#[test]
fn resolve_module_packages_multiple_packages() {
let brew = MockManager::new("brew")
.with_package("ripgrep", "14.0.0")
.with_package("fd", "9.0.0")
.with_package("bat", "0.24.0");
let managers = make_manager_map(&[("brew", &brew)]);
let platform = macos_platform();
let module = LoadedModule {
name: "tools".into(),
spec: ModuleSpec {
packages: vec![
ModulePackageEntry {
name: "ripgrep".into(),
..Default::default()
},
ModulePackageEntry {
name: "fd".into(),
..Default::default()
},
ModulePackageEntry {
name: "bat".into(),
..Default::default()
},
],
..Default::default()
},
dir: PathBuf::from("/fake/tools"),
};
let resolved = resolve_module_packages(&module, &platform, &managers).unwrap();
assert_eq!(resolved.len(), 3);
assert_eq!(resolved[0].canonical_name, "ripgrep");
assert_eq!(resolved[1].canonical_name, "fd");
assert_eq!(resolved[2].canonical_name, "bat");
for pkg in &resolved {
assert_eq!(pkg.manager, "brew");
}
}
#[test]
fn resolve_module_packages_empty_packages() {
let managers: HashMap<String, &dyn PackageManager> = HashMap::new();
let platform = macos_platform();
let module = LoadedModule {
name: "empty".into(),
spec: ModuleSpec::default(),
dir: PathBuf::from("/fake/empty"),
};
let resolved = resolve_module_packages(&module, &platform, &managers).unwrap();
assert!(
resolved.is_empty(),
"module with no packages should resolve to empty"
);
}
#[test]
fn resolve_module_packages_mixed_platforms() {
let apt = MockManager::new("apt")
.with_package("ripgrep", "14.0.0")
.with_package("linux-tool", "1.0.0");
let managers = make_manager_map(&[("apt", &apt)]);
let platform = linux_ubuntu_platform();
let module = LoadedModule {
name: "mixed".into(),
spec: ModuleSpec {
packages: vec![
ModulePackageEntry {
name: "ripgrep".into(),
platforms: vec![], ..Default::default()
},
ModulePackageEntry {
name: "linux-tool".into(),
platforms: vec!["linux".into()],
..Default::default()
},
ModulePackageEntry {
name: "macos-only".into(),
platforms: vec!["macos".into()],
prefer: vec!["brew".into()],
..Default::default()
},
],
..Default::default()
},
dir: PathBuf::from("/fake/mixed"),
};
let resolved = resolve_module_packages(&module, &platform, &managers).unwrap();
assert_eq!(
resolved.len(),
2,
"macOS-only package should be filtered out on Linux"
);
assert_eq!(resolved[0].canonical_name, "ripgrep");
assert_eq!(resolved[1].canonical_name, "linux-tool");
}
#[test]
fn load_module_oversized_yaml_rejected() {
let dir = tempfile::tempdir().unwrap();
let mod_dir = dir.path().join("huge");
std::fs::create_dir(&mod_dir).unwrap();
let mod_dir = dir.path().join("normal");
std::fs::create_dir(&mod_dir).unwrap();
std::fs::write(
mod_dir.join("module.yaml"),
r#"
apiVersion: cfgd.io/v1alpha1
kind: Module
metadata:
name: normal
spec: {}
"#,
)
.unwrap();
let module = load_module(&mod_dir).unwrap();
assert_eq!(module.name, "normal");
}
#[test]
fn resolve_profile_module_name_with_tag() {
assert_eq!(
resolve_profile_module_name("community/tmux@v1.0"),
"tmux@v1.0"
);
}
#[test]
fn resolve_profile_module_name_git_url_unchanged() {
let url = "https://github.com/user/repo.git";
assert_eq!(resolve_profile_module_name(url), url);
}
#[test]
fn diff_module_specs_scripts_none_to_some() {
let old = make_loaded_module("test", ModuleSpec::default());
let new_spec = ModuleSpec {
scripts: Some(crate::config::ScriptSpec {
post_apply: vec![crate::config::ScriptEntry::Simple("echo hello".to_string())],
..Default::default()
}),
..Default::default()
};
let new = make_loaded_module("test", new_spec);
let changes = diff_module_specs(&old, &new);
assert!(
changes
.iter()
.any(|c| c.contains("+ postApply script: echo hello")),
"should detect added script: {changes:?}"
);
}
#[test]
fn diff_module_specs_scripts_some_to_none() {
let old_spec = ModuleSpec {
scripts: Some(crate::config::ScriptSpec {
post_apply: vec![crate::config::ScriptEntry::Simple(
"echo goodbye".to_string(),
)],
..Default::default()
}),
..Default::default()
};
let old = make_loaded_module("test", old_spec);
let new = make_loaded_module("test", ModuleSpec::default());
let changes = diff_module_specs(&old, &new);
assert!(
changes
.iter()
.any(|c| c.contains("- postApply script: echo goodbye")),
"should detect removed script: {changes:?}"
);
}
#[test]
fn diff_module_specs_system_changes_not_tracked() {
let old = make_loaded_module(
"test",
ModuleSpec {
system: [(
"sysctl".to_string(),
serde_yaml::Value::String("old".into()),
)]
.into_iter()
.collect(),
..Default::default()
},
);
let new = make_loaded_module(
"test",
ModuleSpec {
system: [(
"sysctl".to_string(),
serde_yaml::Value::String("new".into()),
)]
.into_iter()
.collect(),
..Default::default()
},
);
let changes = diff_module_specs(&old, &new);
assert_eq!(
changes,
vec!["(no spec changes)"],
"system changes are not tracked by diff"
);
}
#[test]
fn extract_registry_name_ssh_scheme_url() {
assert_eq!(
extract_registry_name("ssh://git@github.com/myorg/repo.git"),
None,
"ssh:// URLs should not match the github extraction"
);
}
#[test]
fn extract_registry_name_trailing_slash() {
assert_eq!(
extract_registry_name("https://github.com/myorg/"),
Some("myorg".to_string())
);
}
#[test]
fn resolve_package_bootstrappable_manager() {
let mgr = MockManager::new("cargo").unavailable().bootstrappable();
let managers = make_manager_map(&[("cargo", &mgr)]);
let platform = linux_ubuntu_platform();
let entry = ModulePackageEntry {
name: "ripgrep".into(),
prefer: vec!["cargo".into()],
..Default::default()
};
let result = resolve_package(&entry, "test", &platform, &managers)
.unwrap()
.unwrap();
assert_eq!(result.manager, "cargo");
assert_eq!(result.canonical_name, "ripgrep");
assert!(
result.version.is_none(),
"bootstrappable manager should not have version"
);
}
#[test]
fn resolve_package_deny_script_still_works() {
let brew = MockManager::new("brew").with_package("tool", "1.0.0");
let managers = make_manager_map(&[("brew", &brew)]);
let platform = macos_platform();
let entry = ModulePackageEntry {
name: "tool".into(),
prefer: vec!["brew".into(), "script".into()],
deny: vec!["brew".into()],
script: Some("install.sh".into()),
..Default::default()
};
let result = resolve_package(&entry, "test", &platform, &managers)
.unwrap()
.unwrap();
assert_eq!(result.manager, "script", "should fall through to script");
}
#[test]
fn resolve_package_deny_script_also_denied() {
let managers: HashMap<String, &dyn PackageManager> = HashMap::new();
let platform = linux_ubuntu_platform();
let entry = ModulePackageEntry {
name: "tool".into(),
prefer: vec!["script".into()],
deny: vec!["script".into()],
script: Some("install.sh".into()),
..Default::default()
};
let result = resolve_package(&entry, "test", &platform, &managers);
assert!(
result.is_err(),
"denying script should make package unresolvable"
);
}
#[test]
fn lockfile_multiple_entries_roundtrip() {
let dir = tempfile::tempdir().unwrap();
let lockfile = ModuleLockfile {
modules: vec![
ModuleLockEntry {
name: "nvim".into(),
url: "https://github.com/user/nvim.git@v1.0".into(),
pinned_ref: "v1.0".into(),
commit: "aaa111".into(),
integrity: "sha256:aaaa".into(),
subdir: None,
},
ModuleLockEntry {
name: "tmux".into(),
url: "https://github.com/user/tmux.git@v2.0".into(),
pinned_ref: "v2.0".into(),
commit: "bbb222".into(),
integrity: "sha256:bbbb".into(),
subdir: Some("tmux-config".into()),
},
ModuleLockEntry {
name: "zsh".into(),
url: "https://github.com/user/zsh.git@v3.0".into(),
pinned_ref: "v3.0".into(),
commit: "ccc333".into(),
integrity: "sha256:cccc".into(),
subdir: None,
},
],
};
save_lockfile(dir.path(), &lockfile).unwrap();
let loaded = load_lockfile(dir.path()).unwrap();
assert_eq!(loaded.modules.len(), 3);
assert_eq!(loaded.modules[0].name, "nvim");
assert_eq!(loaded.modules[1].name, "tmux");
assert_eq!(loaded.modules[1].subdir, Some("tmux-config".into()));
assert_eq!(loaded.modules[2].name, "zsh");
assert_eq!(loaded.modules[2].commit, "ccc333");
}
#[test]
fn hash_module_contents_nested_dirs() {
let dir = tempfile::tempdir().unwrap();
let nested = dir.path().join("config").join("lua").join("plugins");
std::fs::create_dir_all(&nested).unwrap();
std::fs::write(dir.path().join("module.yaml"), "name: test\n").unwrap();
std::fs::write(nested.join("init.lua"), "-- plugins\n").unwrap();
std::fs::write(dir.path().join("config").join("options.lua"), "-- opts\n").unwrap();
let hash = hash_module_contents(dir.path()).unwrap();
assert!(hash.starts_with("sha256:"));
let hash2 = hash_module_contents(dir.path()).unwrap();
assert_eq!(hash, hash2);
std::fs::write(nested.join("extra.lua"), "-- extra\n").unwrap();
let hash3 = hash_module_contents(dir.path()).unwrap();
assert_ne!(hash, hash3, "adding a file should change the hash");
}
#[test]
fn verify_lockfile_integrity_with_subdir() {
let cache_base = tempfile::tempdir().unwrap();
let url = "https://example.com/multi.git@v1.0";
let cache_dir = git_cache_dir(cache_base.path(), "https://example.com/multi.git");
let subdir_path = cache_dir.join("nvim");
std::fs::create_dir_all(&subdir_path).unwrap();
std::fs::write(subdir_path.join("module.yaml"), "test content\n").unwrap();
let actual_integrity = hash_module_contents(&subdir_path).unwrap();
let entry = ModuleLockEntry {
name: "nvim".into(),
url: url.into(),
pinned_ref: "v1.0".into(),
commit: "abc".into(),
integrity: actual_integrity,
subdir: Some("nvim".into()),
};
let result = verify_lockfile_integrity(&entry, cache_base.path());
assert!(
result.is_ok(),
"integrity check with subdir should pass: {:?}",
result.unwrap_err()
);
}
#[test]
fn load_modules_skips_files_in_modules_dir() {
let dir = tempfile::tempdir().unwrap();
let modules_dir = dir.path().join("modules");
std::fs::create_dir_all(&modules_dir).unwrap();
std::fs::write(modules_dir.join("README.md"), "# modules").unwrap();
std::fs::create_dir(modules_dir.join("empty-dir")).unwrap();
let valid = modules_dir.join("valid");
std::fs::create_dir(&valid).unwrap();
std::fs::write(
valid.join("module.yaml"),
r#"
apiVersion: cfgd.io/v1alpha1
kind: Module
metadata:
name: valid
spec: {}
"#,
)
.unwrap();
let modules = load_modules(dir.path()).unwrap();
assert_eq!(modules.len(), 1, "should only load the valid module");
assert!(modules.contains_key("valid"));
}
#[test]
fn is_git_source_edge_cases() {
assert!(is_git_source("http://github.com/user/repo.git"));
assert!(!is_git_source(""));
assert!(!is_git_source("/absolute/path"));
assert!(!is_git_source("relative/path"));
assert!(is_git_source("ssh://user@host/repo"));
}
#[test]
fn parse_git_source_ssh_scheme_with_tag() {
let src = parse_git_source("ssh://git@github.com/user/repo.git@v1.0").unwrap();
assert_eq!(src.repo_url, "ssh://git@github.com/user/repo.git");
assert_eq!(src.tag, Some("v1.0".into()));
}
#[test]
fn parse_git_source_ssh_scheme_with_subdir() {
let src = parse_git_source("ssh://git@github.com/user/repo.git//config@v2.0").unwrap();
assert_eq!(src.repo_url, "ssh://git@github.com/user/repo.git");
assert_eq!(src.subdir, Some("config".into()));
assert_eq!(src.tag, Some("v2.0".into()));
}
#[test]
fn resolve_subdir_nested_path() {
let base = PathBuf::from("/cache/abc123");
let result = resolve_subdir(base, &Some("configs/nvim".to_string()), "test", "url").unwrap();
assert_eq!(result, PathBuf::from("/cache/abc123/configs/nvim"));
}
#[test]
fn diff_module_specs_prefer_list_change_not_tracked() {
let old = make_loaded_module(
"test",
ModuleSpec {
packages: vec![ModulePackageEntry {
name: "neovim".into(),
prefer: vec!["brew".into()],
..Default::default()
}],
..Default::default()
},
);
let new = make_loaded_module(
"test",
ModuleSpec {
packages: vec![ModulePackageEntry {
name: "neovim".into(),
prefer: vec!["apt".into(), "snap".into()],
..Default::default()
}],
..Default::default()
},
);
let changes = diff_module_specs(&old, &new);
assert_eq!(
changes,
vec!["(no spec changes)"],
"prefer list changes are not tracked"
);
}
#[test]
fn dependency_order_exceeds_module_count_limit() {
let mut modules = HashMap::new();
let mut requested = Vec::new();
for i in 0..501 {
let name = format!("mod{i:04}");
modules.insert(
name.clone(),
LoadedModule {
name: name.clone(),
spec: ModuleSpec::default(),
dir: PathBuf::from(format!("/fake/{name}")),
},
);
requested.push(name);
}
let result = resolve_dependency_order(&requested, &modules);
assert!(
result.is_err(),
"should fail when exceeding module count limit"
);
let err = result.unwrap_err().to_string();
assert!(
err.contains("500") || err.contains("exceeds"),
"error should mention the limit: {err}"
);
}
mod git_fixture_tests {
use super::super::git::{
TagSignatureStatus, check_tag_signature, fetch_git_source, get_head_commit_sha, open_repo,
};
use crate::modules::GitSource;
use crate::output::Printer;
use std::path::Path;
fn init_repo_with_commit(dir: &Path) -> (git2::Repository, git2::Oid) {
let repo = git2::Repository::init(dir).unwrap();
std::fs::write(dir.join("README.md"), "hi").unwrap();
let mut index = repo.index().unwrap();
index.add_path(std::path::Path::new("README.md")).unwrap();
index.write().unwrap();
let tree_id = index.write_tree().unwrap();
let tree = repo.find_tree(tree_id).unwrap();
let sig = git2::Signature::now("t", "t@example.com").unwrap();
let commit_id = repo
.commit(Some("HEAD"), &sig, &sig, "initial", &tree, &[])
.unwrap();
drop(tree);
(repo, commit_id)
}
fn add_commit(repo: &git2::Repository, rel_path: &str, contents: &str) -> git2::Oid {
let workdir = repo.workdir().unwrap();
if let Some(parent) = std::path::Path::new(rel_path).parent()
&& !parent.as_os_str().is_empty()
{
std::fs::create_dir_all(workdir.join(parent)).unwrap();
}
std::fs::write(workdir.join(rel_path), contents).unwrap();
let mut index = repo.index().unwrap();
index.add_path(std::path::Path::new(rel_path)).unwrap();
index.write().unwrap();
let tree_id = index.write_tree().unwrap();
let tree = repo.find_tree(tree_id).unwrap();
let parent_commit = repo.head().unwrap().peel_to_commit().unwrap();
let sig = git2::Signature::now("t", "t@example.com").unwrap();
repo.commit(
Some("HEAD"),
&sig,
&sig,
&format!("add {rel_path}"),
&tree,
&[&parent_commit],
)
.unwrap()
}
fn file_url(p: &Path) -> String {
crate::test_helpers::file_url(&p)
}
#[test]
fn get_head_commit_sha_returns_real_head_sha() {
let dir = tempfile::tempdir().unwrap();
let (_repo, commit_id) = init_repo_with_commit(dir.path());
let sha = get_head_commit_sha(dir.path()).unwrap();
assert_eq!(sha, commit_id.to_string());
assert_eq!(sha.len(), 40);
assert!(sha.chars().all(|c| c.is_ascii_hexdigit()));
}
#[test]
fn get_head_commit_sha_errors_when_path_is_not_a_repo() {
let dir = tempfile::tempdir().unwrap();
let err = get_head_commit_sha(dir.path()).unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("cannot open repo") || msg.contains("repo"),
"error should reference repo-open failure, got: {msg}"
);
}
#[test]
fn get_head_commit_sha_errors_when_repo_has_no_commits_yet() {
let dir = tempfile::tempdir().unwrap();
let _repo = git2::Repository::init(dir.path()).unwrap();
let err = get_head_commit_sha(dir.path()).unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("HEAD") || msg.contains("head"),
"error must point at HEAD-read failure, distinct from 'not a repo': {msg}"
);
}
#[test]
fn open_repo_errors_with_module_and_url_context() {
let dir = tempfile::tempdir().unwrap();
let err = match open_repo(dir.path(), "my-mod", "https://example/x.git") {
Ok(_) => panic!("expected open_repo to fail on empty dir"),
Err(e) => e,
};
let msg = err.to_string();
assert!(
msg.contains("my-mod") || msg.contains("example") || msg.contains("repo"),
"error must mention module name, url, or open failure: {msg}"
);
}
#[test]
fn check_tag_signature_returns_tag_not_found_for_missing_tag() {
let dir = tempfile::tempdir().unwrap();
let (_repo, _) = init_repo_with_commit(dir.path());
let status = check_tag_signature(dir.path(), "no-such-tag", "mod").unwrap();
assert_eq!(status, TagSignatureStatus::TagNotFound);
}
#[test]
fn check_tag_signature_returns_lightweight_for_unannotated_tag() {
let dir = tempfile::tempdir().unwrap();
let (repo, commit_id) = init_repo_with_commit(dir.path());
let commit = repo.find_commit(commit_id).unwrap();
repo.tag_lightweight("v1.0", commit.as_object(), false)
.unwrap();
let status = check_tag_signature(dir.path(), "v1.0", "mod").unwrap();
assert_eq!(status, TagSignatureStatus::LightweightTag);
}
#[test]
fn check_tag_signature_returns_unsigned_for_annotated_tag_without_signature() {
let dir = tempfile::tempdir().unwrap();
let (repo, commit_id) = init_repo_with_commit(dir.path());
let commit = repo.find_commit(commit_id).unwrap();
let sig = git2::Signature::now("t", "t@example.com").unwrap();
repo.tag("v1.0", commit.as_object(), &sig, "release notes", false)
.unwrap();
let status = check_tag_signature(dir.path(), "v1.0", "mod").unwrap();
assert_eq!(status, TagSignatureStatus::Unsigned);
}
#[test]
fn check_tag_signature_detects_pgp_signature_in_annotated_tag_message() {
let dir = tempfile::tempdir().unwrap();
let (repo, commit_id) = init_repo_with_commit(dir.path());
let commit = repo.find_commit(commit_id).unwrap();
let sig = git2::Signature::now("t", "t@example.com").unwrap();
let msg = "release\n\n-----BEGIN PGP SIGNATURE-----\nfake-sig-bytes\n-----END PGP SIGNATURE-----\n";
repo.tag("v1.0", commit.as_object(), &sig, msg, false)
.unwrap();
let status = check_tag_signature(dir.path(), "v1.0", "mod").unwrap();
assert_eq!(status, TagSignatureStatus::SignaturePresent);
}
#[test]
fn check_tag_signature_detects_ssh_signature_in_annotated_tag_message() {
let dir = tempfile::tempdir().unwrap();
let (repo, commit_id) = init_repo_with_commit(dir.path());
let commit = repo.find_commit(commit_id).unwrap();
let sig = git2::Signature::now("t", "t@example.com").unwrap();
let msg = "release\n\n-----BEGIN SSH SIGNATURE-----\nfake\n-----END SSH SIGNATURE-----\n";
repo.tag("v1.0", commit.as_object(), &sig, msg, false)
.unwrap();
let status = check_tag_signature(dir.path(), "v1.0", "mod").unwrap();
assert_eq!(status, TagSignatureStatus::SignaturePresent);
}
#[test]
fn checkout_ref_via_fetch_git_source_with_no_tag_or_ref_is_noop() {
let dir = tempfile::tempdir().unwrap();
let (_repo, commit_id) = init_repo_with_commit(dir.path());
let _src = GitSource {
repo_url: dir.path().display().to_string(),
tag: None,
git_ref: None,
subdir: None,
};
let head_after = get_head_commit_sha(dir.path()).unwrap();
assert_eq!(head_after, commit_id.to_string());
assert!(dir.path().join("README.md").exists());
}
#[test]
fn fetch_git_source_clones_local_file_url_into_cache() {
let src_dir = tempfile::tempdir().unwrap();
let (_repo, src_commit) = init_repo_with_commit(src_dir.path());
let cache = tempfile::tempdir().unwrap();
let (printer, _) = Printer::for_test();
let git_src = GitSource {
repo_url: file_url(src_dir.path()),
tag: None,
git_ref: None,
subdir: None,
};
let dest = fetch_git_source(&git_src, cache.path(), "mymod", &printer).unwrap();
assert!(
dest.join(".git").exists() || dest.join("HEAD").exists(),
"clone destination should contain a git repo"
);
assert!(dest.join("README.md").exists());
let cloned_head = get_head_commit_sha(&dest).unwrap();
assert_eq!(cloned_head, src_commit.to_string());
}
#[test]
fn fetch_git_source_second_call_takes_fetch_existing_repo_path() {
let src_dir = tempfile::tempdir().unwrap();
let (src_repo, first_commit) = init_repo_with_commit(src_dir.path());
let cache = tempfile::tempdir().unwrap();
let (printer, _) = Printer::for_test();
let git_src = GitSource {
repo_url: file_url(src_dir.path()),
tag: None,
git_ref: None,
subdir: None,
};
let dest1 = fetch_git_source(&git_src, cache.path(), "mymod", &printer).unwrap();
assert_eq!(
get_head_commit_sha(&dest1).unwrap(),
first_commit.to_string()
);
let _second = add_commit(&src_repo, "second.txt", "second");
let dest2 = fetch_git_source(&git_src, cache.path(), "mymod", &printer).unwrap();
assert_eq!(dest2, dest1, "cache path should be deterministic");
assert_eq!(
get_head_commit_sha(&dest2).unwrap(),
first_commit.to_string()
);
}
#[test]
fn fetch_git_source_checks_out_tag_after_clone() {
let src_dir = tempfile::tempdir().unwrap();
let (src_repo, first_commit) = init_repo_with_commit(src_dir.path());
let first_commit_obj = src_repo.find_commit(first_commit).unwrap();
src_repo
.tag_lightweight("v1.0", first_commit_obj.as_object(), false)
.unwrap();
let _tip = add_commit(&src_repo, "later.txt", "after tag");
let cache = tempfile::tempdir().unwrap();
let (printer, _) = Printer::for_test();
let git_src = GitSource {
repo_url: file_url(src_dir.path()),
tag: Some("v1.0".to_string()),
git_ref: None,
subdir: None,
};
let dest = fetch_git_source(&git_src, cache.path(), "mymod", &printer).unwrap();
let cloned_head = get_head_commit_sha(&dest).unwrap();
assert_eq!(
cloned_head,
first_commit.to_string(),
"tag should pin HEAD to the tagged commit, not the branch tip"
);
}
#[test]
fn fetch_git_source_returns_subdir_path_when_requested() {
let src_dir = tempfile::tempdir().unwrap();
let (src_repo, _) = init_repo_with_commit(src_dir.path());
let _ = add_commit(&src_repo, "docs/index.md", "# docs");
let cache = tempfile::tempdir().unwrap();
let (printer, _) = Printer::for_test();
let git_src = GitSource {
repo_url: file_url(src_dir.path()),
tag: None,
git_ref: None,
subdir: Some("docs".to_string()),
};
let dest = fetch_git_source(&git_src, cache.path(), "mymod", &printer).unwrap();
assert!(
dest.ends_with("docs"),
"returned path should end in the requested subdir, got: {dest:?}"
);
assert!(dest.join("index.md").exists());
}
#[test]
fn fetch_git_source_errors_with_module_name_when_tag_is_unknown() {
let src_dir = tempfile::tempdir().unwrap();
let (_repo, _) = init_repo_with_commit(src_dir.path());
let cache = tempfile::tempdir().unwrap();
let (printer, _) = Printer::for_test();
let git_src = GitSource {
repo_url: file_url(src_dir.path()),
tag: Some("does-not-exist-v99".to_string()),
git_ref: None,
subdir: None,
};
let err = fetch_git_source(&git_src, cache.path(), "mymod", &printer).unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("does-not-exist-v99") || msg.contains("cannot find ref"),
"error must reference the missing tag or ref-resolution failure: {msg}"
);
}
#[test]
fn fetch_git_source_errors_when_repo_url_is_unreachable_path() {
let bogus_src = tempfile::tempdir().unwrap();
let bogus_path = bogus_src.path().join("nonexistent-repo");
let cache = tempfile::tempdir().unwrap();
let (printer, _) = Printer::for_test();
let git_src = GitSource {
repo_url: crate::test_helpers::file_url(&bogus_path),
tag: None,
git_ref: None,
subdir: None,
};
let err = fetch_git_source(&git_src, cache.path(), "mymod", &printer).unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("mymod") || msg.contains("nonexistent-repo") || msg.contains("clone"),
"error must mention module/url/clone-failure: {msg}"
);
}
#[test]
fn fetch_git_source_checks_out_branch_via_git_ref() {
let src_dir = tempfile::tempdir().unwrap();
let (src_repo, first_commit) = init_repo_with_commit(src_dir.path());
let _tip = add_commit(&src_repo, "advance.txt", "advance");
let first_commit_obj = src_repo.find_commit(first_commit).unwrap();
src_repo.branch("topic", &first_commit_obj, false).unwrap();
let cache = tempfile::tempdir().unwrap();
let (printer, _) = Printer::for_test();
let git_src = GitSource {
repo_url: file_url(src_dir.path()),
tag: None,
git_ref: Some("topic".to_string()),
subdir: None,
};
let dest = fetch_git_source(&git_src, cache.path(), "mymod", &printer).unwrap();
let cloned_head = get_head_commit_sha(&dest).unwrap();
assert_eq!(
cloned_head,
first_commit.to_string(),
"git_ref=topic should land HEAD on the topic-branch tip (first commit)"
);
}
#[test]
fn fetch_git_source_cache_path_is_deterministic_for_same_url() {
let src_dir = tempfile::tempdir().unwrap();
let (_repo, _) = init_repo_with_commit(src_dir.path());
let cache = tempfile::tempdir().unwrap();
let (printer, _) = Printer::for_test();
let git_src = GitSource {
repo_url: file_url(src_dir.path()),
tag: None,
git_ref: None,
subdir: None,
};
let dest1 = fetch_git_source(&git_src, cache.path(), "mymod", &printer).unwrap();
let dest2 = fetch_git_source(&git_src, cache.path(), "mymod", &printer).unwrap();
assert_eq!(dest1, dest2);
assert!(dest1.starts_with(cache.path()));
}
fn make_registry_source_with_module(
src_dir: &Path,
mod_name: &str,
version: &str,
description: &str,
) -> git2::Oid {
let src_repo = git2::Repository::init(src_dir).unwrap();
let module_rel = format!("modules/{mod_name}/module.yaml");
let module_yaml = format!(
"apiVersion: cfgd.io/v1alpha1\nkind: Module\nmetadata:\n name: {mod_name}\n description: {description}\nspec: {{}}\n"
);
std::fs::create_dir_all(src_dir.join("modules").join(mod_name)).unwrap();
std::fs::write(src_dir.join(&module_rel), &module_yaml).unwrap();
let mut index = src_repo.index().unwrap();
index.add_path(std::path::Path::new(&module_rel)).unwrap();
index.write().unwrap();
let tree_id = index.write_tree().unwrap();
let tree = src_repo.find_tree(tree_id).unwrap();
let sig = git2::Signature::now("t", "t@example.com").unwrap();
let commit_id = src_repo
.commit(Some("HEAD"), &sig, &sig, "add module", &tree, &[])
.unwrap();
let commit = src_repo.find_commit(commit_id).unwrap();
src_repo
.tag_lightweight(&format!("{mod_name}/v{version}"), commit.as_object(), false)
.unwrap();
drop(tree);
commit_id
}
#[test]
fn fetch_registry_modules_discovers_module_yaml_under_modules_dir() {
use crate::config::ModuleRegistryEntry;
use crate::modules::registry::fetch_registry_modules;
let src_dir = tempfile::tempdir().unwrap();
make_registry_source_with_module(src_dir.path(), "foo", "1.0.0", "Foo module");
let cache = tempfile::tempdir().unwrap();
let (printer, _) = Printer::for_test();
let registry = ModuleRegistryEntry {
name: "test-registry".to_string(),
url: file_url(src_dir.path()),
};
let modules = fetch_registry_modules(®istry, cache.path(), &printer).unwrap();
assert_eq!(modules.len(), 1, "expected one discovered module");
let m = &modules[0];
assert_eq!(m.name, "foo");
assert_eq!(m.description, "Foo module");
assert_eq!(m.registry, "test-registry");
assert_eq!(m.tags, vec!["v1.0.0".to_string()]);
}
#[test]
fn fetch_registry_modules_errors_when_repo_has_no_modules_dir() {
use crate::config::ModuleRegistryEntry;
use crate::modules::registry::fetch_registry_modules;
let src_dir = tempfile::tempdir().unwrap();
let (_repo, _commit) = init_repo_with_commit(src_dir.path());
let cache = tempfile::tempdir().unwrap();
let (printer, _) = Printer::for_test();
let registry = ModuleRegistryEntry {
name: "no-modules".to_string(),
url: file_url(src_dir.path()),
};
let err = fetch_registry_modules(®istry, cache.path(), &printer)
.expect_err("registry without modules/ dir should fail");
let msg = err.to_string();
assert!(
msg.contains("modules/") || msg.contains("no modules"),
"error should reference missing modules/ dir, got: {msg}"
);
}
#[test]
fn fetch_registry_modules_returns_multiple_modules_sorted_by_name() {
use crate::config::ModuleRegistryEntry;
use crate::modules::registry::fetch_registry_modules;
let src_dir = tempfile::tempdir().unwrap();
make_registry_source_with_module(src_dir.path(), "zeta", "0.1.0", "Zeta");
let src_repo = git2::Repository::open(src_dir.path()).unwrap();
let module_rel = "modules/alpha/module.yaml";
std::fs::create_dir_all(src_dir.path().join("modules/alpha")).unwrap();
std::fs::write(
src_dir.path().join(module_rel),
"apiVersion: cfgd.io/v1alpha1\nkind: Module\nmetadata:\n name: alpha\n description: A\nspec: {}\n",
)
.unwrap();
let mut index = src_repo.index().unwrap();
index.add_path(std::path::Path::new(module_rel)).unwrap();
index.write().unwrap();
let tree_id = index.write_tree().unwrap();
let tree = src_repo.find_tree(tree_id).unwrap();
let parent = src_repo.head().unwrap().peel_to_commit().unwrap();
let sig = git2::Signature::now("t", "t@example.com").unwrap();
let commit_id = src_repo
.commit(Some("HEAD"), &sig, &sig, "add alpha", &tree, &[&parent])
.unwrap();
let commit = src_repo.find_commit(commit_id).unwrap();
src_repo
.tag_lightweight("alpha/v2.0.0", commit.as_object(), false)
.unwrap();
drop(tree);
let cache = tempfile::tempdir().unwrap();
let (printer, _) = Printer::for_test();
let registry = ModuleRegistryEntry {
name: "multi-reg".to_string(),
url: file_url(src_dir.path()),
};
let modules = fetch_registry_modules(®istry, cache.path(), &printer).unwrap();
let names: Vec<&str> = modules.iter().map(|m| m.name.as_str()).collect();
assert_eq!(
names,
vec!["alpha", "zeta"],
"modules must be sorted alphabetically"
);
}
#[test]
fn latest_module_version_returns_highest_semver_tag() {
use crate::config::ModuleRegistryEntry;
use crate::modules::registry::{fetch_registry_modules, latest_module_version};
let src_dir = tempfile::tempdir().unwrap();
make_registry_source_with_module(src_dir.path(), "foo", "1.0.0", "Foo");
let cache = tempfile::tempdir().unwrap();
let (printer, _) = Printer::for_test();
let registry = ModuleRegistryEntry {
name: "for-latest".to_string(),
url: file_url(src_dir.path()),
};
let _ = fetch_registry_modules(®istry, cache.path(), &printer).unwrap();
let latest = latest_module_version(®istry, "foo", cache.path()).unwrap();
assert_eq!(latest.as_deref(), Some("v1.0.0"));
let unknown = latest_module_version(®istry, "ghost", cache.path()).unwrap();
assert!(unknown.is_none());
}
}