1use std::collections::{HashMap, HashSet, VecDeque};
10use std::path::{Path, PathBuf};
11
12use serde::Serialize;
13
14use crate::config::{EnvVar, ModulePackageEntry, ModuleSpec, ShellAlias, parse_module};
15use crate::errors::{ConfigError, ModuleError, Result};
16use crate::platform::Platform;
17use crate::providers::PackageManager;
18
19#[derive(Debug, Clone, Serialize)]
25pub struct ResolvedPackage {
26 pub canonical_name: String,
28 pub resolved_name: String,
30 pub manager: String,
32 pub version: Option<String>,
34 pub script: Option<String>,
36}
37
38#[derive(Debug, Clone, Serialize)]
40pub struct ResolvedFile {
41 pub source: PathBuf,
43 pub target: PathBuf,
45 pub is_git_source: bool,
47 pub strategy: Option<crate::config::FileStrategy>,
49 pub encryption: Option<crate::config::EncryptionSpec>,
51}
52
53#[derive(Debug, Clone, Serialize)]
55pub struct ResolvedModule {
56 pub name: String,
57 pub packages: Vec<ResolvedPackage>,
58 pub files: Vec<ResolvedFile>,
59 pub env: Vec<EnvVar>,
60 pub aliases: Vec<ShellAlias>,
61 pub system: HashMap<String, serde_yaml::Value>,
64 pub pre_apply_scripts: Vec<crate::config::ScriptEntry>,
65 pub post_apply_scripts: Vec<crate::config::ScriptEntry>,
66 pub pre_reconcile_scripts: Vec<crate::config::ScriptEntry>,
67 pub post_reconcile_scripts: Vec<crate::config::ScriptEntry>,
68 pub on_change_scripts: Vec<crate::config::ScriptEntry>,
69 pub depends: Vec<String>,
70 pub dir: PathBuf,
72}
73
74#[derive(Debug, Clone, Serialize)]
80pub struct LoadedModule {
81 pub name: String,
82 pub spec: ModuleSpec,
83 pub dir: PathBuf,
84}
85
86pub fn load_modules(config_dir: &Path) -> Result<HashMap<String, LoadedModule>> {
93 let modules_dir = config_dir.join("modules");
94 if !modules_dir.is_dir() {
95 return Ok(HashMap::new());
96 }
97
98 let mut modules = HashMap::new();
99 let entries = std::fs::read_dir(&modules_dir).map_err(|e| ConfigError::Invalid {
100 message: format!(
101 "cannot read modules directory {}: {e}",
102 modules_dir.display()
103 ),
104 })?;
105
106 for entry in entries {
107 let entry = entry.map_err(|e| ConfigError::Invalid {
108 message: format!("cannot read modules directory entry: {e}"),
109 })?;
110 let path = entry.path();
111 if !path.is_dir() {
112 continue;
113 }
114
115 let module_yaml = path.join("module.yaml");
116 if !module_yaml.exists() {
117 continue;
118 }
119
120 let name = path
121 .file_name()
122 .and_then(|n| n.to_str())
123 .ok_or_else(|| ConfigError::Invalid {
124 message: format!("invalid module directory name: {}", path.display()),
125 })?
126 .to_string();
127
128 let contents = std::fs::read_to_string(&module_yaml).map_err(|e| ConfigError::Invalid {
129 message: format!("cannot read module file {}: {e}", module_yaml.display()),
130 })?;
131
132 let doc = parse_module(&contents)?;
133
134 if doc.metadata.name != name {
135 return Err(ModuleError::InvalidSpec {
136 name: name.clone(),
137 message: format!(
138 "module directory '{}' does not match metadata.name '{}'",
139 name, doc.metadata.name
140 ),
141 }
142 .into());
143 }
144
145 modules.insert(
146 name.clone(),
147 LoadedModule {
148 name,
149 spec: doc.spec,
150 dir: path,
151 },
152 );
153 }
154
155 Ok(modules)
156}
157
158pub fn load_module(module_dir: &Path) -> Result<LoadedModule> {
160 let module_yaml = module_dir.join("module.yaml");
161 if !module_yaml.exists() {
162 let name = module_dir
163 .file_name()
164 .and_then(|n| n.to_str())
165 .ok_or_else(|| ModuleError::InvalidSpec {
166 name: module_dir.display().to_string(),
167 message: "invalid module directory name".into(),
168 })?
169 .to_string();
170 return Err(ModuleError::NotFound { name }.into());
171 }
172
173 const MAX_MODULE_SIZE: u64 = 10 * 1024 * 1024; if let Ok(meta) = std::fs::metadata(&module_yaml)
176 && meta.len() > MAX_MODULE_SIZE
177 {
178 return Err(ModuleError::InvalidSpec {
179 name: module_yaml.display().to_string(),
180 message: format!(
181 "module file too large ({} bytes, max {})",
182 meta.len(),
183 MAX_MODULE_SIZE
184 ),
185 }
186 .into());
187 }
188
189 let contents = std::fs::read_to_string(&module_yaml).map_err(|e| ConfigError::Invalid {
190 message: format!("cannot read module file {}: {e}", module_yaml.display()),
191 })?;
192
193 let doc = parse_module(&contents)?;
194 let name = doc.metadata.name.clone();
195
196 Ok(LoadedModule {
197 name,
198 spec: doc.spec,
199 dir: module_dir.to_path_buf(),
200 })
201}
202
203pub fn resolve_dependency_order(
210 requested: &[String],
211 all_modules: &HashMap<String, LoadedModule>,
212) -> Result<Vec<String>> {
213 const MAX_MODULES: usize = 500;
215 const MAX_DEPENDENCY_DEPTH: usize = 50;
216
217 let mut needed: HashSet<String> = HashSet::new();
219 let mut queue: VecDeque<(String, usize)> = requested.iter().map(|r| (r.clone(), 0)).collect();
220
221 while let Some((name, depth)) = queue.pop_front() {
222 if needed.contains(&name) {
223 continue;
224 }
225
226 if depth > MAX_DEPENDENCY_DEPTH {
227 return Err(ModuleError::DependencyCycle {
228 chain: vec![format!(
229 "dependency depth exceeds {} (at '{}')",
230 MAX_DEPENDENCY_DEPTH, name
231 )],
232 }
233 .into());
234 }
235
236 if needed.len() >= MAX_MODULES {
237 return Err(ModuleError::DependencyCycle {
238 chain: vec![format!("total module count exceeds {} limit", MAX_MODULES)],
239 }
240 .into());
241 }
242
243 let module = all_modules
244 .get(&name)
245 .ok_or_else(|| ModuleError::NotFound { name: name.clone() })?;
246
247 needed.insert(name.clone());
248
249 for dep in &module.spec.depends {
250 if !all_modules.contains_key(dep) {
251 return Err(ModuleError::MissingDependency {
252 module: name.clone(),
253 dependency: dep.clone(),
254 }
255 .into());
256 }
257 if !needed.contains(dep) {
258 queue.push_back((dep.clone(), depth + 1));
259 }
260 }
261 }
262
263 let mut in_degree: HashMap<String, usize> = HashMap::new();
265 let mut dependents: HashMap<String, Vec<String>> = HashMap::new();
266
267 for name in &needed {
268 in_degree.entry(name.clone()).or_insert(0);
269 let module = &all_modules[name];
270 for dep in &module.spec.depends {
271 if needed.contains(dep) {
272 *in_degree.entry(name.clone()).or_insert(0) += 1;
273 dependents
274 .entry(dep.clone())
275 .or_default()
276 .push(name.clone());
277 }
278 }
279 }
280
281 let mut queue: VecDeque<String> = in_degree
283 .iter()
284 .filter(|(_, deg)| **deg == 0)
285 .map(|(name, _)| name.clone())
286 .collect();
287
288 let mut sorted_initial: Vec<String> = queue.drain(..).collect();
290 sorted_initial.sort();
291 queue.extend(sorted_initial);
292
293 let mut order = Vec::new();
294
295 while let Some(name) = queue.pop_front() {
296 order.push(name.clone());
297
298 if let Some(deps) = dependents.get(&name) {
299 let mut next: Vec<String> = Vec::new();
300 for dep in deps {
301 if let Some(deg) = in_degree.get_mut(dep) {
302 *deg -= 1;
303 if *deg == 0 {
304 next.push(dep.clone());
305 }
306 }
307 }
308 next.sort();
310 queue.extend(next);
311 }
312 }
313
314 if order.len() != needed.len() {
315 let ordered: HashSet<&str> = order.iter().map(|s| s.as_str()).collect();
317 let in_cycle: Vec<String> = needed
318 .into_iter()
319 .filter(|n| !ordered.contains(n.as_str()))
320 .collect();
321 return Err(ModuleError::DependencyCycle { chain: in_cycle }.into());
322 }
323
324 Ok(order)
325}
326
327pub fn resolve_package(
342 entry: &ModulePackageEntry,
343 module_name: &str,
344 platform: &Platform,
345 managers: &HashMap<String, &dyn PackageManager>,
346) -> Result<Option<ResolvedPackage>> {
347 if !platform.matches_any(&entry.platforms) {
349 return Ok(None);
350 }
351
352 let candidates: Vec<String> = if entry.prefer.is_empty() {
353 vec![platform.native_manager().to_string()]
354 } else {
355 entry.prefer.clone()
356 };
357
358 let candidates: Vec<String> = candidates
360 .into_iter()
361 .filter(|c| !entry.deny.contains(c))
362 .collect();
363
364 for candidate in &candidates {
365 if candidate == "script" {
367 let script = entry
368 .script
369 .as_ref()
370 .ok_or_else(|| ModuleError::InvalidSpec {
371 name: module_name.to_string(),
372 message: format!(
373 "package '{}' has 'script' in prefer list but no 'script' field defined",
374 entry.name
375 ),
376 })?;
377 return Ok(Some(ResolvedPackage {
378 canonical_name: entry.name.clone(),
379 resolved_name: entry.name.clone(),
380 manager: "script".to_string(),
381 version: None,
382 script: Some(script.clone()),
383 }));
384 }
385
386 let mgr = match managers.get(candidate.as_str()) {
387 Some(m) => *m,
388 None => continue,
389 };
390
391 let bootstrappable = !mgr.is_available() && mgr.can_bootstrap();
392 if !mgr.is_available() && !bootstrappable {
393 continue;
394 }
395
396 let resolved_name = entry
397 .aliases
398 .get(candidate)
399 .cloned()
400 .unwrap_or_else(|| entry.name.clone());
401
402 if bootstrappable {
405 return Ok(Some(ResolvedPackage {
406 canonical_name: entry.name.clone(),
407 resolved_name,
408 manager: candidate.clone(),
409 version: None,
410 script: None,
411 }));
412 }
413
414 if let Some(ref min_ver) = entry.min_version {
415 match mgr.available_version(&resolved_name) {
416 Ok(Some(ver)) => {
417 if !crate::version_satisfies(&ver, &format!(">={min_ver}")) {
418 continue;
419 }
420 return Ok(Some(ResolvedPackage {
421 canonical_name: entry.name.clone(),
422 resolved_name,
423 manager: candidate.clone(),
424 version: Some(ver),
425 script: None,
426 }));
427 }
428 Ok(None) => continue,
429 Err(_) => continue,
430 }
431 } else {
432 let version = mgr.available_version(&resolved_name).ok().flatten();
434 return Ok(Some(ResolvedPackage {
435 canonical_name: entry.name.clone(),
436 resolved_name,
437 manager: candidate.clone(),
438 version,
439 script: None,
440 }));
441 }
442 }
443
444 Err(ModuleError::UnresolvablePackage {
445 module: module_name.to_string(),
446 package: entry.name.clone(),
447 min_version: entry.min_version.clone().unwrap_or_else(|| "any".into()),
448 }
449 .into())
450}
451
452pub fn resolve_module_packages(
455 module: &LoadedModule,
456 platform: &Platform,
457 managers: &HashMap<String, &dyn PackageManager>,
458) -> Result<Vec<ResolvedPackage>> {
459 let mut resolved = Vec::new();
460 for entry in &module.spec.packages {
461 if let Some(pkg) = resolve_package(entry, &module.name, platform, managers)? {
462 resolved.push(pkg);
463 }
464 }
465 Ok(resolved)
466}
467
468#[derive(Debug, Clone, PartialEq, Eq)]
474pub struct GitSource {
475 pub repo_url: String,
477 pub tag: Option<String>,
479 pub git_ref: Option<String>,
481 pub subdir: Option<String>,
483}
484
485pub fn is_git_source(source: &str) -> bool {
487 source.starts_with("https://")
488 || source.starts_with("http://")
489 || source.starts_with("git@")
490 || source.starts_with("ssh://")
491}
492
493pub fn is_registry_ref(name: &str) -> bool {
496 name.contains('/') && !is_git_source(name)
497}
498
499pub struct RegistryRef {
501 pub registry: String,
502 pub module: String,
503 pub tag: Option<String>,
504}
505
506pub fn parse_registry_ref(input: &str) -> Option<RegistryRef> {
509 let (registry, remainder) = input.split_once('/')?;
511 if registry.is_empty() || remainder.is_empty() {
512 return None;
513 }
514
515 let (module, tag) = match remainder.split_once('@') {
517 Some((m, t)) if !m.is_empty() && !t.is_empty() => (m.to_string(), Some(t.to_string())),
518 Some((_, _)) => return None, None => (remainder.to_string(), None),
520 };
521
522 Some(RegistryRef {
523 registry: registry.to_string(),
524 module,
525 tag,
526 })
527}
528
529pub fn resolve_profile_module_name(profile_ref: &str) -> &str {
537 if is_registry_ref(profile_ref) {
538 profile_ref
539 .split_once('/')
540 .map(|(_, m)| m)
541 .unwrap_or(profile_ref)
542 } else {
543 profile_ref
544 }
545}
546
547pub fn parse_git_source(source: &str) -> Result<GitSource> {
557 if !is_git_source(source) {
558 return Err(ModuleError::InvalidSpec {
559 name: source.to_string(),
560 message: "not a git URL".into(),
561 }
562 .into());
563 }
564
565 let mut url = source.to_string();
566 let mut tag = None;
567 let mut git_ref = None;
568 let mut subdir = None;
569
570 if let Some(ref_pos) = url.find("?ref=") {
573 let after_ref = &url[ref_pos + 5..];
574 let end = after_ref.find("//").unwrap_or(after_ref.len());
575 let ref_val = after_ref[..end].to_string();
576 let remainder = &after_ref[end..];
577 url = format!("{}{}", &url[..ref_pos], remainder);
578 git_ref = Some(ref_val);
579 }
580
581 let search_start = url.find("://").map(|p| p + 3).unwrap_or(0);
584 if let Some(rel_pos) = url[search_start..].find("//") {
585 let subdir_pos = search_start + rel_pos;
586 let subdir_part = url[subdir_pos + 2..].to_string();
587 url = url[..subdir_pos].to_string();
588
589 if let Some(at_pos) = subdir_part.rfind('@') {
591 subdir = Some(subdir_part[..at_pos].to_string());
592 tag = Some(subdir_part[at_pos + 1..].to_string());
593 } else {
594 subdir = Some(subdir_part);
595 }
596 } else {
597 if let Some(git_suffix_pos) = url.find(".git") {
601 let after_git = &url[git_suffix_pos + 4..];
602 if let Some(at_pos) = after_git.find('@') {
603 tag = Some(after_git[at_pos + 1..].to_string());
604 url = url[..git_suffix_pos + 4].to_string();
605 }
606 } else if let Some(at_pos) = url.rfind('@') {
607 let skip_to = if url.starts_with("git@") {
611 url.find('@').map(|p| p + 1).unwrap_or(0)
612 } else {
613 url.find("://").map(|p| p + 3).unwrap_or(0)
614 };
615 if at_pos > skip_to {
616 tag = Some(url[at_pos + 1..].to_string());
617 url = url[..at_pos].to_string();
618 }
619 }
620 }
621
622 Ok(GitSource {
623 repo_url: url,
624 tag,
625 git_ref,
626 subdir,
627 })
628}
629
630pub fn git_cache_dir(cache_base: &Path, repo_url: &str) -> PathBuf {
633 let hash = crate::sha256_hex(repo_url.as_bytes());
634 cache_base.join(&hash[..32])
635}
636
637pub fn default_module_cache_dir() -> Result<PathBuf> {
639 let base = directories::BaseDirs::new().ok_or_else(|| ModuleError::GitFetchFailed {
640 module: String::new(),
641 url: String::new(),
642 message: "cannot determine home directory".into(),
643 })?;
644 Ok(base.cache_dir().join("cfgd").join("modules"))
645}
646
647fn resolve_subdir(
649 base: PathBuf,
650 subdir: &Option<String>,
651 module: &str,
652 url: &str,
653) -> Result<PathBuf> {
654 match subdir {
655 Some(sub) => {
656 crate::validate_no_traversal(std::path::Path::new(sub)).map_err(|_| {
657 ModuleError::GitFetchFailed {
658 module: module.to_string(),
659 url: url.to_string(),
660 message: format!("subdir contains path traversal: {sub}"),
661 }
662 })?;
663 Ok(base.join(sub))
664 }
665 None => Ok(base),
666 }
667}
668
669pub fn fetch_git_source(
678 git_src: &GitSource,
679 cache_base: &Path,
680 module_name: &str,
681 printer: &crate::output::Printer,
682) -> Result<PathBuf> {
683 let cache_dir = git_cache_dir(cache_base, &git_src.repo_url);
684
685 if cache_dir.join(".git").exists() || cache_dir.join("HEAD").exists() {
686 fetch_existing_repo(&cache_dir, git_src, module_name, printer)?;
687 } else {
688 clone_repo(&cache_dir, git_src, module_name, printer)?;
689 }
690
691 checkout_ref(&cache_dir, git_src, module_name)?;
692
693 resolve_subdir(cache_dir, &git_src.subdir, module_name, &git_src.repo_url)
694}
695
696fn open_repo(path: &Path, module: &str, url: &str) -> Result<git2::Repository> {
698 git2::Repository::open(path).map_err(|e| {
699 ModuleError::GitFetchFailed {
700 module: module.to_string(),
701 url: url.to_string(),
702 message: format!("cannot open repo: {e}"),
703 }
704 .into()
705 })
706}
707
708fn git_fetch_options<'a>() -> git2::FetchOptions<'a> {
710 let mut callbacks = git2::RemoteCallbacks::new();
711 callbacks.credentials(crate::git_ssh_credentials);
712 let mut fetch_opts = git2::FetchOptions::new();
713 fetch_opts.remote_callbacks(callbacks);
714 fetch_opts
715}
716
717fn clone_repo(
718 dest: &Path,
719 git_src: &GitSource,
720 module_name: &str,
721 printer: &crate::output::Printer,
722) -> Result<()> {
723 if let Some(parent) = dest.parent() {
724 std::fs::create_dir_all(parent).map_err(|e| ModuleError::GitFetchFailed {
725 module: module_name.to_string(),
726 url: git_src.repo_url.clone(),
727 message: format!("cannot create cache directory: {e}"),
728 })?;
729 }
730
731 let mut cmd = crate::git_cmd_safe(Some(&git_src.repo_url), None);
733 cmd.args(["clone", &git_src.repo_url, &dest.display().to_string()]);
734 cmd.stdout(std::process::Stdio::piped());
735 cmd.stderr(std::process::Stdio::piped());
736
737 let label = format!("Cloning module '{}'", module_name);
738 let cli_result = printer.run_with_output(&mut cmd, &label);
739 if matches!(&cli_result, Ok(output) if output.status.success()) {
740 return Ok(());
741 }
742
743 let _ = std::fs::remove_dir_all(dest);
745 if let Some(parent) = dest.parent() {
746 let _ = std::fs::create_dir_all(parent);
747 }
748
749 let spinner = printer.spinner(&format!("Cloning module '{}' (libgit2)...", module_name));
751
752 let result = git2::build::RepoBuilder::new()
753 .fetch_options(git_fetch_options())
754 .clone(&git_src.repo_url, dest)
755 .map_err(|e| ModuleError::GitFetchFailed {
756 module: module_name.to_string(),
757 url: git_src.repo_url.clone(),
758 message: e.to_string(),
759 });
760
761 spinner.finish_and_clear();
762 result?;
763
764 Ok(())
765}
766
767fn fetch_existing_repo(
768 repo_path: &Path,
769 git_src: &GitSource,
770 module_name: &str,
771 printer: &crate::output::Printer,
772) -> Result<()> {
773 let mut cmd = crate::git_cmd_safe(Some(&git_src.repo_url), None);
775 cmd.args(["-C", &repo_path.display().to_string(), "fetch", "origin"]);
776 cmd.stdout(std::process::Stdio::piped());
777 cmd.stderr(std::process::Stdio::piped());
778
779 let label = format!("Fetching module '{}'", module_name);
780 let cli_result = printer.run_with_output(&mut cmd, &label);
781 if matches!(&cli_result, Ok(output) if output.status.success()) {
782 return Ok(());
783 }
784
785 let spinner = printer.spinner(&format!("Fetching module '{}' (libgit2)...", module_name));
787
788 let repo = open_repo(repo_path, module_name, &git_src.repo_url)?;
789
790 let mut remote = repo
791 .find_remote("origin")
792 .map_err(|e| ModuleError::GitFetchFailed {
793 module: module_name.to_string(),
794 url: git_src.repo_url.clone(),
795 message: format!("no 'origin' remote: {e}"),
796 })?;
797
798 let refspecs: Vec<String> = remote
799 .refspecs()
800 .filter_map(|rs| rs.str().map(String::from))
801 .collect();
802 let refspec_strs: Vec<&str> = refspecs.iter().map(|s| s.as_str()).collect();
803
804 let fetch_result = remote
805 .fetch(&refspec_strs, Some(&mut git_fetch_options()), None)
806 .map_err(|e| ModuleError::GitFetchFailed {
807 module: module_name.to_string(),
808 url: git_src.repo_url.clone(),
809 message: format!("fetch failed: {e}"),
810 });
811
812 spinner.finish_and_clear();
813 fetch_result?;
814
815 Ok(())
816}
817
818fn checkout_ref(repo_path: &Path, git_src: &GitSource, module_name: &str) -> Result<()> {
819 let repo = open_repo(repo_path, module_name, &git_src.repo_url)?;
820
821 let target_ref = git_src.tag.as_deref().or(git_src.git_ref.as_deref());
822
823 let Some(ref_name) = target_ref else {
824 return Ok(());
826 };
827
828 let obj = repo
830 .revparse_single(&format!("refs/tags/{ref_name}"))
831 .or_else(|_| repo.revparse_single(&format!("refs/remotes/origin/{ref_name}")))
832 .or_else(|_| repo.revparse_single(ref_name))
833 .map_err(|e| ModuleError::GitFetchFailed {
834 module: module_name.to_string(),
835 url: git_src.repo_url.clone(),
836 message: format!("cannot find ref '{ref_name}': {e}"),
837 })?;
838
839 let commit = obj
841 .peel_to_commit()
842 .map_err(|e| ModuleError::GitFetchFailed {
843 module: module_name.to_string(),
844 url: git_src.repo_url.clone(),
845 message: format!("ref '{ref_name}' does not point to a commit: {e}"),
846 })?;
847
848 repo.set_head_detached(commit.id())
849 .map_err(|e| ModuleError::GitFetchFailed {
850 module: module_name.to_string(),
851 url: git_src.repo_url.clone(),
852 message: format!("cannot detach HEAD to '{ref_name}': {e}"),
853 })?;
854
855 repo.checkout_head(Some(git2::build::CheckoutBuilder::new().force()))
856 .map_err(|e| ModuleError::GitFetchFailed {
857 module: module_name.to_string(),
858 url: git_src.repo_url.clone(),
859 message: format!("checkout failed for '{ref_name}': {e}"),
860 })?;
861
862 Ok(())
863}
864
865pub fn resolve_module_files(
873 module: &LoadedModule,
874 cache_base: &Path,
875 printer: &crate::output::Printer,
876) -> Result<Vec<ResolvedFile>> {
877 let mut resolved = Vec::new();
878
879 for entry in &module.spec.files {
880 if is_git_source(&entry.source) {
881 let git_src = parse_git_source(&entry.source)?;
882 let local_path = fetch_git_source(&git_src, cache_base, &module.name, printer)?;
883
884 resolved.push(ResolvedFile {
885 source: local_path,
886 target: crate::expand_tilde(Path::new(&entry.target)),
887 is_git_source: true,
888 strategy: entry.strategy,
889 encryption: entry.encryption.clone(),
890 });
891 } else {
892 let rel = std::path::Path::new(&entry.source);
894 crate::validate_no_traversal(rel).map_err(|_| ModuleError::InvalidSpec {
895 name: module.name.clone(),
896 message: format!("file source contains path traversal: {}", entry.source),
897 })?;
898 let source = module.dir.join(rel);
899 if source.exists()
902 && let (Ok(canonical_src), Ok(canonical_dir)) =
903 (source.canonicalize(), module.dir.canonicalize())
904 && !canonical_src.starts_with(&canonical_dir)
905 {
906 return Err(ModuleError::InvalidSpec {
907 name: module.name.clone(),
908 message: format!(
909 "file source '{}' resolves outside module directory",
910 entry.source
911 ),
912 }
913 .into());
914 }
915 resolved.push(ResolvedFile {
916 source,
917 target: crate::expand_tilde(Path::new(&entry.target)),
918 is_git_source: false,
919 strategy: entry.strategy,
920 encryption: entry.encryption.clone(),
921 });
922 }
923 }
924
925 Ok(resolved)
926}
927
928pub fn resolve_modules(
935 requested: &[String],
936 config_dir: &Path,
937 cache_base: &Path,
938 platform: &Platform,
939 managers: &HashMap<String, &dyn PackageManager>,
940 printer: &crate::output::Printer,
941) -> Result<Vec<ResolvedModule>> {
942 let all_modules = load_all_modules(config_dir, cache_base, printer)?;
943
944 let resolved_names: Vec<String> = requested
946 .iter()
947 .map(|r| resolve_profile_module_name(r).to_string())
948 .collect();
949
950 let order = resolve_dependency_order(&resolved_names, &all_modules)?;
951
952 let mut resolved = Vec::new();
953 for name in &order {
954 let module = &all_modules[name];
955 let packages = resolve_module_packages(module, platform, managers)?;
956 let files = resolve_module_files(module, cache_base, printer)?;
957
958 let scripts = module.spec.scripts.as_ref();
959 let pre_apply_scripts = scripts.map(|s| s.pre_apply.clone()).unwrap_or_default();
960 let post_apply_scripts = scripts.map(|s| s.post_apply.clone()).unwrap_or_default();
961 let pre_reconcile_scripts = scripts.map(|s| s.pre_reconcile.clone()).unwrap_or_default();
962 let post_reconcile_scripts = scripts
963 .map(|s| s.post_reconcile.clone())
964 .unwrap_or_default();
965 let on_change_scripts = scripts.map(|s| s.on_change.clone()).unwrap_or_default();
966
967 if let Some(ref scripts) = module.spec.scripts
969 && !scripts.on_drift.is_empty()
970 {
971 tracing::warn!(
972 "module '{}' defines onDrift scripts, but onDrift is profile-level only — these will be ignored",
973 name
974 );
975 }
976
977 resolved.push(ResolvedModule {
978 name: name.clone(),
979 packages,
980 files,
981 env: module.spec.env.clone(),
982 aliases: module.spec.aliases.clone(),
983 system: module.spec.system.clone(),
984 pre_apply_scripts,
985 post_apply_scripts,
986 pre_reconcile_scripts,
987 post_reconcile_scripts,
988 on_change_scripts,
989 depends: module.spec.depends.clone(),
990 dir: module.dir.clone(),
991 });
992 }
993
994 Ok(resolved)
995}
996
997use crate::config::{ModuleLockEntry, ModuleLockfile, ModuleRegistryEntry};
1002
1003pub fn load_lockfile(config_dir: &Path) -> Result<ModuleLockfile> {
1006 let lockfile_path = config_dir.join("modules.lock");
1007 if !lockfile_path.exists() {
1008 return Ok(ModuleLockfile::default());
1009 }
1010 let contents = std::fs::read_to_string(&lockfile_path).map_err(|e| ConfigError::Invalid {
1011 message: format!("cannot read lockfile {}: {e}", lockfile_path.display()),
1012 })?;
1013 let lockfile: ModuleLockfile = serde_yaml::from_str(&contents).map_err(ConfigError::from)?;
1014 Ok(lockfile)
1015}
1016
1017pub fn save_lockfile(config_dir: &Path, lockfile: &ModuleLockfile) -> Result<()> {
1020 let lockfile_path = config_dir.join("modules.lock");
1021 let contents = serde_yaml::to_string(lockfile).map_err(ConfigError::from)?;
1022 crate::atomic_write_str(&lockfile_path, &contents).map_err(|e| ConfigError::Invalid {
1023 message: format!("cannot write lockfile {}: {e}", lockfile_path.display()),
1024 })?;
1025 Ok(())
1026}
1027
1028pub fn hash_module_contents(module_dir: &Path) -> Result<String> {
1031 let mut entries: Vec<(String, Vec<u8>)> = Vec::new();
1032 collect_files_for_hash(module_dir, module_dir, &mut entries)?;
1033 entries.sort_by(|a, b| a.0.cmp(&b.0));
1034
1035 let mut hasher_input = Vec::new();
1036 for (rel_path, content) in &entries {
1037 hasher_input.extend_from_slice(rel_path.as_bytes());
1038 hasher_input.push(0);
1039 hasher_input.extend_from_slice(content);
1040 hasher_input.push(0);
1041 }
1042
1043 Ok(format!("sha256:{}", crate::sha256_hex(&hasher_input)))
1044}
1045
1046fn collect_files_for_hash(
1047 base: &Path,
1048 current: &Path,
1049 entries: &mut Vec<(String, Vec<u8>)>,
1050) -> Result<()> {
1051 if !current.is_dir() {
1052 return Ok(());
1053 }
1054 let dir_entries = std::fs::read_dir(current)?;
1055
1056 for entry in dir_entries {
1057 let entry = entry?;
1058 let path = entry.path();
1059 if path.file_name().is_some_and(|n| n == ".git") {
1061 continue;
1062 }
1063 let meta = std::fs::symlink_metadata(&path)?;
1066 if meta.is_symlink() {
1067 continue;
1068 }
1069 if meta.is_dir() {
1070 collect_files_for_hash(base, &path, entries)?;
1071 } else {
1072 let rel = path
1073 .strip_prefix(base)
1074 .unwrap_or(&path)
1075 .to_string_lossy()
1076 .to_string();
1077 let content = std::fs::read(&path)?;
1078 entries.push((rel, content));
1079 }
1080 }
1081 Ok(())
1082}
1083
1084#[derive(Debug, Clone)]
1086pub struct FetchedRemoteModule {
1087 pub module: LoadedModule,
1088 pub commit: String,
1089 pub integrity: String,
1090}
1091
1092pub fn fetch_remote_module(
1097 url: &str,
1098 cache_base: &Path,
1099 printer: &crate::output::Printer,
1100) -> Result<FetchedRemoteModule> {
1101 let git_src = parse_git_source(url)?;
1102
1103 if git_src.git_ref.is_some() {
1107 return Err(ModuleError::UnpinnedRemoteModule {
1108 name: url.to_string(),
1109 }
1110 .into());
1111 }
1112 if git_src.tag.is_none() {
1113 return Err(ModuleError::UnpinnedRemoteModule {
1114 name: url.to_string(),
1115 }
1116 .into());
1117 }
1118
1119 let local_path = fetch_git_source(&git_src, cache_base, "remote", printer)?;
1120
1121 let repo_dir = git_cache_dir(cache_base, &git_src.repo_url);
1123 let commit = get_head_commit_sha(&repo_dir)?;
1124
1125 let module = load_module(&local_path)?;
1127
1128 let integrity = hash_module_contents(&local_path)?;
1130
1131 Ok(FetchedRemoteModule {
1132 module,
1133 commit,
1134 integrity,
1135 })
1136}
1137
1138pub fn get_head_commit_sha(repo_path: &Path) -> Result<String> {
1140 let path_str = repo_path.display().to_string();
1141 let repo = open_repo(repo_path, &path_str, &path_str)?;
1142 let head = repo.head().map_err(|e| ModuleError::GitFetchFailed {
1143 module: path_str.clone(),
1144 url: path_str.clone(),
1145 message: format!("cannot read HEAD: {e}"),
1146 })?;
1147 let commit = head
1148 .peel_to_commit()
1149 .map_err(|e| ModuleError::GitFetchFailed {
1150 module: path_str.clone(),
1151 url: path_str,
1152 message: format!("HEAD is not a commit: {e}"),
1153 })?;
1154 Ok(commit.id().to_string())
1155}
1156
1157pub fn verify_lockfile_integrity(lock_entry: &ModuleLockEntry, cache_base: &Path) -> Result<()> {
1159 let git_src = parse_git_source(&lock_entry.url)?;
1160 let local_path = resolve_subdir(
1161 git_cache_dir(cache_base, &git_src.repo_url),
1162 &lock_entry.subdir,
1163 &lock_entry.name,
1164 &lock_entry.url,
1165 )?;
1166
1167 if !local_path.exists() {
1168 return Err(ModuleError::GitFetchFailed {
1169 module: lock_entry.name.clone(),
1170 url: lock_entry.url.clone(),
1171 message: "cached module directory does not exist — run 'cfgd module update'".into(),
1172 }
1173 .into());
1174 }
1175
1176 let actual_integrity = hash_module_contents(&local_path)?;
1177 if actual_integrity != lock_entry.integrity {
1178 return Err(ModuleError::IntegrityMismatch {
1179 name: lock_entry.name.clone(),
1180 expected: lock_entry.integrity.clone(),
1181 actual: actual_integrity,
1182 }
1183 .into());
1184 }
1185
1186 Ok(())
1187}
1188
1189pub fn load_locked_modules(
1192 config_dir: &Path,
1193 cache_base: &Path,
1194 modules: &mut HashMap<String, LoadedModule>,
1195 printer: &crate::output::Printer,
1196) -> Result<()> {
1197 let lockfile = load_lockfile(config_dir)?;
1198
1199 for entry in &lockfile.modules {
1200 if modules.contains_key(&entry.name) {
1202 continue;
1203 }
1204
1205 let git_src = parse_git_source(&entry.url)?;
1206
1207 let pinned_src = GitSource {
1209 repo_url: git_src.repo_url.clone(),
1210 tag: Some(entry.pinned_ref.clone()),
1211 git_ref: None,
1212 subdir: entry.subdir.clone(),
1213 };
1214
1215 let local_path = fetch_git_source(&pinned_src, cache_base, &entry.name, printer)?;
1217
1218 verify_lockfile_integrity(entry, cache_base)?;
1220
1221 let module = load_module(&local_path)?;
1223 modules.insert(entry.name.clone(), module);
1224 }
1225
1226 Ok(())
1227}
1228
1229pub fn load_all_modules(
1231 config_dir: &Path,
1232 cache_base: &Path,
1233 printer: &crate::output::Printer,
1234) -> Result<HashMap<String, LoadedModule>> {
1235 let mut modules = load_modules(config_dir)?;
1236 load_locked_modules(config_dir, cache_base, &mut modules, printer)?;
1237 Ok(modules)
1238}
1239
1240#[derive(Debug, Clone, PartialEq, Eq)]
1242pub enum TagSignatureStatus {
1243 LightweightTag,
1245 Unsigned,
1247 SignaturePresent,
1249 TagNotFound,
1251}
1252
1253pub fn check_tag_signature(
1259 repo_path: &Path,
1260 tag_name: &str,
1261 module_name: &str,
1262) -> Result<TagSignatureStatus> {
1263 let repo = open_repo(repo_path, module_name, "")?;
1264
1265 let tag_ref = match repo.revparse_single(&format!("refs/tags/{tag_name}")) {
1266 Ok(obj) => obj,
1267 Err(_) => return Ok(TagSignatureStatus::TagNotFound),
1268 };
1269
1270 let tag = match tag_ref.as_tag() {
1271 Some(t) => t,
1272 None => return Ok(TagSignatureStatus::LightweightTag),
1273 };
1274
1275 let message = match tag.message() {
1276 Some(m) => m,
1277 None => return Ok(TagSignatureStatus::Unsigned),
1278 };
1279
1280 if message.contains("-----BEGIN PGP SIGNATURE-----")
1281 || message.contains("-----BEGIN SSH SIGNATURE-----")
1282 {
1283 Ok(TagSignatureStatus::SignaturePresent)
1284 } else {
1285 Ok(TagSignatureStatus::Unsigned)
1286 }
1287}
1288
1289#[derive(Debug, Clone)]
1295pub struct RegistryModule {
1296 pub name: String,
1298 pub description: String,
1300 pub registry: String,
1302 pub tags: Vec<String>,
1304}
1305
1306pub fn extract_registry_name(url: &str) -> Option<String> {
1309 if let Some(rest) = url
1311 .strip_prefix("https://github.com/")
1312 .or_else(|| url.strip_prefix("http://github.com/"))
1313 {
1314 return rest.split('/').next().map(|s| s.to_string());
1315 }
1316 if let Some(rest) = url.strip_prefix("git@github.com:") {
1318 return rest.split('/').next().map(|s| s.to_string());
1319 }
1320 None
1321}
1322
1323pub fn fetch_registry_modules(
1328 registry: &ModuleRegistryEntry,
1329 cache_base: &Path,
1330 printer: &crate::output::Printer,
1331) -> Result<Vec<RegistryModule>> {
1332 let git_src = GitSource {
1333 repo_url: registry.url.clone(),
1334 tag: None,
1335 git_ref: None,
1336 subdir: None,
1337 };
1338
1339 let cache_dir = git_cache_dir(cache_base, ®istry.url);
1340
1341 if cache_dir.join(".git").exists() || cache_dir.join("HEAD").exists() {
1343 fetch_existing_repo(&cache_dir, &git_src, ®istry.name, printer)?;
1344 } else {
1345 clone_repo(&cache_dir, &git_src, ®istry.name, printer)?;
1346 }
1347
1348 let modules_dir = cache_dir.join("modules");
1349 if !modules_dir.is_dir() {
1350 return Err(ModuleError::SourceFetchFailed {
1351 url: registry.url.clone(),
1352 message: "registry repo has no modules/ directory".into(),
1353 }
1354 .into());
1355 }
1356
1357 let module_tags = list_module_tags(&cache_dir, ®istry.name)?;
1359
1360 let mut found = Vec::new();
1362 let entries = std::fs::read_dir(&modules_dir)?;
1363 for entry in entries {
1364 let entry = entry?;
1365 let path = entry.path();
1366 if !path.is_dir() {
1367 continue;
1368 }
1369 let module_yaml = path.join("module.yaml");
1370 if !module_yaml.exists() {
1371 continue;
1372 }
1373 let mod_name = match path.file_name().and_then(|n| n.to_str()) {
1374 Some(n) => n.to_string(),
1375 None => continue,
1376 };
1377
1378 let description = std::fs::read_to_string(&module_yaml)
1380 .ok()
1381 .and_then(|c| crate::config::parse_module(&c).ok())
1382 .and_then(|doc| doc.metadata.description.clone())
1383 .unwrap_or_default();
1384
1385 let tags = module_tags.get(&mod_name).cloned().unwrap_or_default();
1387
1388 found.push(RegistryModule {
1389 name: mod_name,
1390 description,
1391 registry: registry.name.clone(),
1392 tags,
1393 });
1394 }
1395
1396 found.sort_by(|a, b| a.name.cmp(&b.name));
1397 Ok(found)
1398}
1399
1400fn list_module_tags(repo_path: &Path, source_name: &str) -> Result<HashMap<String, Vec<String>>> {
1404 let repo = open_repo(repo_path, source_name, "")?;
1405 let mut result: HashMap<String, Vec<String>> = HashMap::new();
1406
1407 let tag_names = repo
1408 .tag_names(None)
1409 .map_err(|e| ModuleError::GitFetchFailed {
1410 module: source_name.to_string(),
1411 url: String::new(),
1412 message: format!("cannot list tags: {e}"),
1413 })?;
1414
1415 for tag_name in tag_names.iter().flatten() {
1416 if let Some((module, version)) = tag_name.split_once('/') {
1417 result
1418 .entry(module.to_string())
1419 .or_default()
1420 .push(version.to_string());
1421 }
1422 }
1423
1424 for tags in result.values_mut() {
1426 tags.sort_by(|a, b| {
1427 let av = crate::parse_loose_version(a);
1428 let bv = crate::parse_loose_version(b);
1429 match (av, bv) {
1430 (Some(av), Some(bv)) => av.cmp(&bv),
1431 _ => a.cmp(b),
1432 }
1433 });
1434 }
1435
1436 Ok(result)
1437}
1438
1439pub fn latest_module_version(
1442 registry: &ModuleRegistryEntry,
1443 module_name: &str,
1444 cache_base: &Path,
1445) -> Result<Option<String>> {
1446 let cache_dir = git_cache_dir(cache_base, ®istry.url);
1447 let tags = list_module_tags(&cache_dir, ®istry.name)?;
1448 Ok(tags.get(module_name).and_then(|t| t.last()).cloned())
1449}
1450
1451pub fn diff_module_specs(old: &LoadedModule, new: &LoadedModule) -> Vec<String> {
1453 let mut changes = Vec::new();
1454
1455 let old_deps: HashSet<&str> = old.spec.depends.iter().map(|s| s.as_str()).collect();
1457 let new_deps: HashSet<&str> = new.spec.depends.iter().map(|s| s.as_str()).collect();
1458 for dep in new_deps.difference(&old_deps) {
1459 changes.push(format!("+ dependency: {dep}"));
1460 }
1461 for dep in old_deps.difference(&new_deps) {
1462 changes.push(format!("- dependency: {dep}"));
1463 }
1464
1465 let old_pkgs: HashSet<&str> = old.spec.packages.iter().map(|p| p.name.as_str()).collect();
1467 let new_pkgs: HashSet<&str> = new.spec.packages.iter().map(|p| p.name.as_str()).collect();
1468 for pkg in new_pkgs.difference(&old_pkgs) {
1469 changes.push(format!("+ package: {pkg}"));
1470 }
1471 for pkg in old_pkgs.difference(&new_pkgs) {
1472 changes.push(format!("- package: {pkg}"));
1473 }
1474
1475 for new_pkg in &new.spec.packages {
1477 if let Some(old_pkg) = old.spec.packages.iter().find(|p| p.name == new_pkg.name)
1478 && old_pkg.min_version != new_pkg.min_version
1479 {
1480 changes.push(format!(
1481 "~ package '{}': minVersion {} -> {}",
1482 new_pkg.name,
1483 old_pkg.min_version.as_deref().unwrap_or("(none)"),
1484 new_pkg.min_version.as_deref().unwrap_or("(none)")
1485 ));
1486 }
1487 }
1488
1489 let old_files: HashSet<&str> = old.spec.files.iter().map(|f| f.target.as_str()).collect();
1491 let new_files: HashSet<&str> = new.spec.files.iter().map(|f| f.target.as_str()).collect();
1492 for file in new_files.difference(&old_files) {
1493 changes.push(format!("+ file target: {file}"));
1494 }
1495 for file in old_files.difference(&new_files) {
1496 changes.push(format!("- file target: {file}"));
1497 }
1498
1499 let old_scripts: Vec<&str> = old
1501 .spec
1502 .scripts
1503 .as_ref()
1504 .map(|s| s.post_apply.iter().map(|e| e.run_str()).collect())
1505 .unwrap_or_default();
1506 let new_scripts: Vec<&str> = new
1507 .spec
1508 .scripts
1509 .as_ref()
1510 .map(|s| s.post_apply.iter().map(|e| e.run_str()).collect())
1511 .unwrap_or_default();
1512 let old_script_set: HashSet<&str> = old_scripts.into_iter().collect();
1513 let new_script_set: HashSet<&str> = new_scripts.into_iter().collect();
1514 for script in new_script_set.difference(&old_script_set) {
1515 changes.push(format!("+ postApply script: {script}"));
1516 }
1517 for script in old_script_set.difference(&new_script_set) {
1518 changes.push(format!("- postApply script: {script}"));
1519 }
1520
1521 if changes.is_empty() {
1522 changes.push("(no spec changes)".to_string());
1523 }
1524
1525 changes
1526}
1527
1528#[cfg(test)]
1533mod tests {
1534 use super::*;
1535
1536 use crate::config::ModuleFileEntry;
1537 use crate::providers::StubPackageManager as MockManager;
1538 use crate::test_helpers::{
1539 linux_ubuntu_platform, macos_platform, make_manager_map, make_test_modules, test_printer,
1540 };
1541
1542 #[test]
1545 fn load_modules_empty_dir() {
1546 let dir = tempfile::tempdir().unwrap();
1547 let result = load_modules(dir.path()).unwrap();
1548 assert!(result.is_empty());
1549 }
1550
1551 #[test]
1552 fn load_modules_no_modules_dir() {
1553 let dir = tempfile::tempdir().unwrap();
1554 let result = load_modules(dir.path()).unwrap();
1556 assert!(result.is_empty());
1557 }
1558
1559 #[test]
1560 fn load_single_module() {
1561 let dir = tempfile::tempdir().unwrap();
1562 let mod_dir = dir.path().join("modules").join("nvim");
1563 std::fs::create_dir_all(&mod_dir).unwrap();
1564 std::fs::write(
1565 mod_dir.join("module.yaml"),
1566 r#"
1567apiVersion: cfgd.io/v1alpha1
1568kind: Module
1569metadata:
1570 name: nvim
1571spec:
1572 depends: [node]
1573 packages:
1574 - name: neovim
1575 minVersion: "0.9"
1576 prefer: [brew, snap, apt]
1577 aliases:
1578 snap: nvim
1579 - name: ripgrep
1580 files:
1581 - source: config/
1582 target: ~/.config/nvim/
1583"#,
1584 )
1585 .unwrap();
1586
1587 let modules = load_modules(dir.path()).unwrap();
1588 assert_eq!(modules.len(), 1);
1589 let nvim = &modules["nvim"];
1590 assert_eq!(nvim.name, "nvim");
1591 assert_eq!(nvim.spec.depends, vec!["node"]);
1592 assert_eq!(nvim.spec.packages.len(), 2);
1593 assert_eq!(nvim.spec.packages[0].name, "neovim");
1594 assert_eq!(nvim.spec.packages[0].min_version, Some("0.9".to_string()));
1595 assert_eq!(nvim.spec.packages[0].prefer, vec!["brew", "snap", "apt"]);
1596 assert_eq!(
1597 nvim.spec.packages[0].aliases.get("snap"),
1598 Some(&"nvim".to_string())
1599 );
1600 assert_eq!(nvim.spec.packages[1].name, "ripgrep");
1601 assert_eq!(nvim.spec.files.len(), 1);
1602 }
1603
1604 #[test]
1605 fn load_module_name_mismatch_errors() {
1606 let dir = tempfile::tempdir().unwrap();
1607 let mod_dir = dir.path().join("modules").join("wrong-name");
1608 std::fs::create_dir_all(&mod_dir).unwrap();
1609 std::fs::write(
1610 mod_dir.join("module.yaml"),
1611 r#"
1612apiVersion: cfgd.io/v1alpha1
1613kind: Module
1614metadata:
1615 name: actual-name
1616spec: {}
1617"#,
1618 )
1619 .unwrap();
1620
1621 let result = load_modules(dir.path());
1622 assert!(result.is_err());
1623 let err = result.unwrap_err();
1624 assert!(err.to_string().contains("does not match"));
1625 }
1626
1627 #[test]
1628 fn load_module_wrong_kind_errors() {
1629 let dir = tempfile::tempdir().unwrap();
1630 let mod_dir = dir.path().join("modules").join("bad");
1631 std::fs::create_dir_all(&mod_dir).unwrap();
1632 std::fs::write(
1633 mod_dir.join("module.yaml"),
1634 r#"
1635apiVersion: cfgd.io/v1alpha1
1636kind: Profile
1637metadata:
1638 name: bad
1639spec: {}
1640"#,
1641 )
1642 .unwrap();
1643
1644 let result = load_modules(dir.path());
1645 assert!(result.is_err());
1646 assert!(result.unwrap_err().to_string().contains("Module"));
1647 }
1648
1649 #[test]
1652 fn dependency_order_single_no_deps() {
1653 let modules = make_test_modules(&[("nvim", &[])]);
1654 let order = resolve_dependency_order(&["nvim".into()], &modules).unwrap();
1655 assert_eq!(order, vec!["nvim"]);
1656 }
1657
1658 #[test]
1659 fn dependency_order_linear_chain() {
1660 let modules = make_test_modules(&[("a", &[]), ("b", &["a"]), ("c", &["b"])]);
1661 let order = resolve_dependency_order(&["c".into()], &modules).unwrap();
1662 assert_eq!(order, vec!["a", "b", "c"]);
1663 }
1664
1665 #[test]
1666 fn dependency_order_diamond() {
1667 let modules = make_test_modules(&[
1668 ("base", &[]),
1669 ("left", &["base"]),
1670 ("right", &["base"]),
1671 ("top", &["left", "right"]),
1672 ]);
1673 let order = resolve_dependency_order(&["top".into()], &modules).unwrap();
1674 assert_eq!(order[0], "base");
1676 assert!(order.contains(&"left".to_string()));
1677 assert!(order.contains(&"right".to_string()));
1678 assert_eq!(order.last().unwrap(), "top");
1679 }
1680
1681 #[test]
1682 fn dependency_order_cycle_detected() {
1683 let modules = make_test_modules(&[("a", &["b"]), ("b", &["a"])]);
1684 let result = resolve_dependency_order(&["a".into()], &modules);
1685 assert!(result.is_err());
1686 let err = result.unwrap_err();
1687 assert!(err.to_string().contains("cycle"));
1688 }
1689
1690 #[test]
1691 fn dependency_order_missing_dependency() {
1692 let modules = make_test_modules(&[("a", &["missing"])]);
1693 let result = resolve_dependency_order(&["a".into()], &modules);
1694 assert!(result.is_err());
1695 assert!(result.unwrap_err().to_string().contains("missing"));
1696 }
1697
1698 #[test]
1699 fn dependency_order_module_not_found() {
1700 let modules: HashMap<String, LoadedModule> = HashMap::new();
1701 let result = resolve_dependency_order(&["nonexistent".into()], &modules);
1702 assert!(result.is_err());
1703 assert!(result.unwrap_err().to_string().contains("nonexistent"));
1704 }
1705
1706 #[test]
1707 fn dependency_order_multiple_requested() {
1708 let modules = make_test_modules(&[("base", &[]), ("nvim", &["base"]), ("tmux", &["base"])]);
1709 let order = resolve_dependency_order(&["nvim".into(), "tmux".into()], &modules).unwrap();
1710 assert_eq!(order[0], "base");
1711 assert!(order.contains(&"nvim".to_string()));
1712 assert!(order.contains(&"tmux".to_string()));
1713 assert_eq!(order.len(), 3);
1714 }
1715
1716 #[test]
1717 fn dependency_order_three_node_cycle() {
1718 let modules = make_test_modules(&[("a", &["c"]), ("b", &["a"]), ("c", &["b"])]);
1719 let result = resolve_dependency_order(&["a".into()], &modules);
1720 assert!(result.is_err());
1721 assert!(result.unwrap_err().to_string().contains("cycle"));
1722 }
1723
1724 #[test]
1727 fn resolve_package_simple_native() {
1728 let brew = MockManager::new("brew").with_package("ripgrep", "14.1.0");
1729 let managers = make_manager_map(&[("brew", &brew)]);
1730 let platform = macos_platform();
1731
1732 let entry = ModulePackageEntry {
1733 name: "ripgrep".into(),
1734 min_version: None,
1735 prefer: vec![],
1736 aliases: HashMap::new(),
1737 script: None,
1738 deny: vec![],
1739 platforms: vec![],
1740 };
1741
1742 let result = resolve_package(&entry, "test", &platform, &managers)
1743 .unwrap()
1744 .unwrap();
1745 assert_eq!(result.canonical_name, "ripgrep");
1746 assert_eq!(result.resolved_name, "ripgrep");
1747 assert_eq!(result.manager, "brew");
1748 assert_eq!(result.version, Some("14.1.0".into()));
1749 }
1750
1751 #[test]
1752 fn resolve_package_with_prefer_list() {
1753 let brew = MockManager::new("brew").unavailable();
1754 let apt = MockManager::new("apt").with_package("neovim", "0.10.2");
1755 let snap = MockManager::new("snap").with_package("nvim", "0.10.3");
1756 let managers = make_manager_map(&[("brew", &brew), ("apt", &apt), ("snap", &snap)]);
1757 let platform = linux_ubuntu_platform();
1758
1759 let entry = ModulePackageEntry {
1760 name: "neovim".into(),
1761 min_version: Some("0.9".into()),
1762 prefer: vec!["brew".into(), "snap".into(), "apt".into()],
1763 aliases: [("snap".to_string(), "nvim".to_string())]
1764 .into_iter()
1765 .collect(),
1766 script: None,
1767 deny: vec![],
1768 platforms: vec![],
1769 };
1770
1771 let result = resolve_package(&entry, "nvim", &platform, &managers)
1773 .unwrap()
1774 .unwrap();
1775 assert_eq!(result.manager, "snap");
1776 assert_eq!(result.resolved_name, "nvim"); assert_eq!(result.version, Some("0.10.3".into()));
1778 }
1779
1780 #[test]
1781 fn resolve_package_min_version_check() {
1782 let apt = MockManager::new("apt").with_package("neovim", "0.6.1");
1783 let snap = MockManager::new("snap").with_package("nvim", "0.10.2");
1784 let managers = make_manager_map(&[("apt", &apt), ("snap", &snap)]);
1785 let platform = linux_ubuntu_platform();
1786
1787 let entry = ModulePackageEntry {
1788 name: "neovim".into(),
1789 min_version: Some("0.9".into()),
1790 prefer: vec!["apt".into(), "snap".into()],
1791 aliases: [("snap".to_string(), "nvim".to_string())]
1792 .into_iter()
1793 .collect(),
1794 script: None,
1795 deny: vec![],
1796 platforms: vec![],
1797 };
1798
1799 let result = resolve_package(&entry, "nvim", &platform, &managers)
1801 .unwrap()
1802 .unwrap();
1803 assert_eq!(result.manager, "snap");
1804 assert_eq!(result.version, Some("0.10.2".into()));
1805 }
1806
1807 #[test]
1808 fn resolve_package_unresolvable() {
1809 let apt = MockManager::new("apt").with_package("neovim", "0.6.1");
1810 let managers = make_manager_map(&[("apt", &apt)]);
1811 let platform = linux_ubuntu_platform();
1812
1813 let entry = ModulePackageEntry {
1814 name: "neovim".into(),
1815 min_version: Some("0.9".into()),
1816 prefer: vec!["apt".into()],
1817 aliases: HashMap::new(),
1818 script: None,
1819 deny: vec![],
1820 platforms: vec![],
1821 };
1822
1823 let result = resolve_package(&entry, "nvim", &platform, &managers);
1824 assert!(result.is_err());
1825 assert!(
1826 result
1827 .unwrap_err()
1828 .to_string()
1829 .contains("cannot be resolved")
1830 );
1831 }
1832
1833 #[test]
1834 fn resolve_package_alias_applied() {
1835 let apt = MockManager::new("apt").with_package("fd-find", "8.7.0");
1836 let managers = make_manager_map(&[("apt", &apt)]);
1837 let platform = linux_ubuntu_platform();
1838
1839 let entry = ModulePackageEntry {
1840 name: "fd".into(),
1841 min_version: None,
1842 prefer: vec![],
1843 aliases: [("apt".to_string(), "fd-find".to_string())]
1844 .into_iter()
1845 .collect(),
1846 script: None,
1847 deny: vec![],
1848 platforms: vec![],
1849 };
1850
1851 let result = resolve_package(&entry, "test", &platform, &managers)
1852 .unwrap()
1853 .unwrap();
1854 assert_eq!(result.canonical_name, "fd");
1855 assert_eq!(result.resolved_name, "fd-find");
1856 assert_eq!(result.manager, "apt");
1857 }
1858
1859 #[test]
1860 fn resolve_package_alias_winget() {
1861 let winget =
1862 MockManager::new("winget").with_package("Microsoft.VisualStudioCode", "1.85.0");
1863 let managers = make_manager_map(&[("winget", &winget)]);
1864 let platform = linux_ubuntu_platform(); let entry = ModulePackageEntry {
1867 name: "vscode".to_string(),
1868 min_version: None,
1869 prefer: vec!["winget".to_string()],
1870 aliases: [(
1871 "winget".to_string(),
1872 "Microsoft.VisualStudioCode".to_string(),
1873 )]
1874 .into_iter()
1875 .collect(),
1876 script: None,
1877 deny: vec![],
1878 platforms: vec![],
1879 };
1880
1881 let result = resolve_package(&entry, "editor", &platform, &managers)
1882 .unwrap()
1883 .unwrap();
1884 assert_eq!(result.canonical_name, "vscode");
1885 assert_eq!(result.resolved_name, "Microsoft.VisualStudioCode");
1886 assert_eq!(result.manager, "winget");
1887 }
1888
1889 #[test]
1890 fn resolve_package_alias_chocolatey() {
1891 let choco = MockManager::new("chocolatey").with_package("nodejs.install", "21.4.0");
1892 let managers = make_manager_map(&[("chocolatey", &choco)]);
1893 let platform = linux_ubuntu_platform();
1894
1895 let entry = ModulePackageEntry {
1896 name: "node".to_string(),
1897 min_version: None,
1898 prefer: vec!["chocolatey".to_string()],
1899 aliases: [("chocolatey".to_string(), "nodejs.install".to_string())]
1900 .into_iter()
1901 .collect(),
1902 script: None,
1903 deny: vec![],
1904 platforms: vec![],
1905 };
1906
1907 let result = resolve_package(&entry, "runtime", &platform, &managers)
1908 .unwrap()
1909 .unwrap();
1910 assert_eq!(result.canonical_name, "node");
1911 assert_eq!(result.resolved_name, "nodejs.install");
1912 assert_eq!(result.manager, "chocolatey");
1913 }
1914
1915 #[test]
1916 fn resolve_package_alias_scoop() {
1917 let scoop = MockManager::new("scoop").with_package("rg", "14.1.0");
1918 let managers = make_manager_map(&[("scoop", &scoop)]);
1919 let platform = linux_ubuntu_platform();
1920
1921 let entry = ModulePackageEntry {
1922 name: "ripgrep".to_string(),
1923 min_version: None,
1924 prefer: vec!["scoop".to_string()],
1925 aliases: [("scoop".to_string(), "rg".to_string())]
1926 .into_iter()
1927 .collect(),
1928 script: None,
1929 deny: vec![],
1930 platforms: vec![],
1931 };
1932
1933 let result = resolve_package(&entry, "tools", &platform, &managers)
1934 .unwrap()
1935 .unwrap();
1936 assert_eq!(result.canonical_name, "ripgrep");
1937 assert_eq!(result.resolved_name, "rg");
1938 assert_eq!(result.manager, "scoop");
1939 }
1940
1941 #[test]
1942 fn resolve_package_manager_not_registered() {
1943 let managers: HashMap<String, &dyn PackageManager> = HashMap::new();
1944 let platform = linux_ubuntu_platform();
1945
1946 let entry = ModulePackageEntry {
1947 name: "ripgrep".into(),
1948 min_version: None,
1949 prefer: vec!["brew".into()],
1950 aliases: HashMap::new(),
1951 script: None,
1952 deny: vec![],
1953 platforms: vec![],
1954 };
1955
1956 let result = resolve_package(&entry, "test", &platform, &managers);
1958 let err = result.unwrap_err().to_string();
1959 assert!(
1960 err.contains("ripgrep"),
1961 "error should mention the package name: {err}"
1962 );
1963 assert!(
1964 err.contains("cannot be resolved"),
1965 "error should indicate unresolvable: {err}"
1966 );
1967 }
1968
1969 #[test]
1972 fn parse_git_source_plain_https() {
1973 let src = parse_git_source("https://github.com/user/repo.git").unwrap();
1974 assert_eq!(src.repo_url, "https://github.com/user/repo.git");
1975 assert_eq!(src.tag, None);
1976 assert_eq!(src.git_ref, None);
1977 assert_eq!(src.subdir, None);
1978 }
1979
1980 #[test]
1981 fn parse_git_source_with_tag() {
1982 let src = parse_git_source("https://github.com/user/repo.git@v2.1.0").unwrap();
1983 assert_eq!(src.repo_url, "https://github.com/user/repo.git");
1984 assert_eq!(src.tag, Some("v2.1.0".into()));
1985 assert_eq!(src.git_ref, None);
1986 assert_eq!(src.subdir, None);
1987 }
1988
1989 #[test]
1990 fn parse_git_source_with_ref() {
1991 let src = parse_git_source("https://github.com/user/repo.git?ref=dev").unwrap();
1992 assert_eq!(src.repo_url, "https://github.com/user/repo.git");
1993 assert_eq!(src.tag, None);
1994 assert_eq!(src.git_ref, Some("dev".into()));
1995 assert_eq!(src.subdir, None);
1996 }
1997
1998 #[test]
1999 fn parse_git_source_with_subdir() {
2000 let src = parse_git_source("https://github.com/user/repo.git//nvim").unwrap();
2001 assert_eq!(src.repo_url, "https://github.com/user/repo.git");
2002 assert_eq!(src.tag, None);
2003 assert_eq!(src.git_ref, None);
2004 assert_eq!(src.subdir, Some("nvim".into()));
2005 }
2006
2007 #[test]
2008 fn parse_git_source_subdir_with_tag() {
2009 let src = parse_git_source("https://github.com/user/dotfiles.git//nvim@v3.0").unwrap();
2010 assert_eq!(src.repo_url, "https://github.com/user/dotfiles.git");
2011 assert_eq!(src.tag, Some("v3.0".into()));
2012 assert_eq!(src.subdir, Some("nvim".into()));
2013 }
2014
2015 #[test]
2016 fn parse_git_source_ssh_with_tag() {
2017 let src = parse_git_source("git@github.com:user/nvim-config.git@v2.1.0").unwrap();
2018 assert_eq!(src.repo_url, "git@github.com:user/nvim-config.git");
2019 assert_eq!(src.tag, Some("v2.1.0".into()));
2020 }
2021
2022 #[test]
2023 fn parse_git_source_ssh_with_ref() {
2024 let src = parse_git_source("git@github.com:user/nvim-config.git?ref=main").unwrap();
2025 assert_eq!(src.repo_url, "git@github.com:user/nvim-config.git");
2026 assert_eq!(src.git_ref, Some("main".into()));
2027 assert_eq!(src.tag, None);
2028 }
2029
2030 #[test]
2031 fn parse_git_source_not_git_url() {
2032 let result = parse_git_source("config/");
2033 let err = result.unwrap_err().to_string();
2034 assert!(
2035 err.contains("not a git URL"),
2036 "error should say 'not a git URL': {err}"
2037 );
2038 assert!(
2039 err.contains("config/"),
2040 "error should include the invalid input: {err}"
2041 );
2042 }
2043
2044 #[test]
2045 fn is_git_source_tests() {
2046 assert!(is_git_source("https://github.com/user/repo.git"));
2047 assert!(is_git_source("git@github.com:user/repo.git"));
2048 assert!(is_git_source("ssh://git@github.com/user/repo.git"));
2049 assert!(!is_git_source("config/"));
2050 assert!(!is_git_source("../relative/path"));
2051 assert!(!is_git_source("~/.config/nvim"));
2052 }
2053
2054 #[test]
2057 fn git_cache_dir_deterministic() {
2058 let base = Path::new("/tmp/cache");
2059 let dir1 = git_cache_dir(base, "https://github.com/user/repo.git");
2060 let dir2 = git_cache_dir(base, "https://github.com/user/repo.git");
2061 assert_eq!(dir1, dir2);
2062 }
2063
2064 #[test]
2065 fn git_cache_dir_different_urls() {
2066 let base = Path::new("/tmp/cache");
2067 let dir1 = git_cache_dir(base, "https://github.com/user/repo1.git");
2068 let dir2 = git_cache_dir(base, "https://github.com/user/repo2.git");
2069 assert_ne!(dir1, dir2);
2070 }
2071
2072 #[test]
2075 fn resolve_local_files() {
2076 let dir = tempfile::tempdir().unwrap();
2077 let config_dir = dir.path().join("config");
2078 std::fs::create_dir_all(&config_dir).unwrap();
2079 std::fs::write(config_dir.join("init.lua"), "-- test").unwrap();
2080
2081 let module = LoadedModule {
2082 name: "nvim".into(),
2083 spec: ModuleSpec {
2084 files: vec![ModuleFileEntry {
2085 source: "config/".into(),
2086 target: "/home/user/.config/nvim/".into(),
2087 strategy: None,
2088 private: false,
2089 encryption: None,
2090 }],
2091 ..Default::default()
2092 },
2093 dir: dir.path().to_path_buf(),
2094 };
2095
2096 let cache_dir = tempfile::tempdir().unwrap();
2097 let printer = test_printer();
2098 let resolved = resolve_module_files(&module, cache_dir.path(), &printer).unwrap();
2099 assert_eq!(resolved.len(), 1);
2100 assert_eq!(resolved[0].source, dir.path().join("config/"));
2101 assert_eq!(
2102 resolved[0].target,
2103 PathBuf::from("/home/user/.config/nvim/")
2104 );
2105 assert!(!resolved[0].is_git_source);
2106 }
2107
2108 #[test]
2111 fn full_module_resolution() {
2112 let dir = tempfile::tempdir().unwrap();
2113
2114 let node_dir = dir.path().join("modules").join("node");
2116 std::fs::create_dir_all(&node_dir).unwrap();
2117 std::fs::write(
2118 node_dir.join("module.yaml"),
2119 r#"
2120apiVersion: cfgd.io/v1alpha1
2121kind: Module
2122metadata:
2123 name: node
2124spec:
2125 packages:
2126 - name: nodejs
2127 aliases:
2128 brew: node
2129"#,
2130 )
2131 .unwrap();
2132
2133 let nvim_dir = dir.path().join("modules").join("nvim");
2134 std::fs::create_dir_all(&nvim_dir).unwrap();
2135 std::fs::write(
2136 nvim_dir.join("module.yaml"),
2137 r#"
2138apiVersion: cfgd.io/v1alpha1
2139kind: Module
2140metadata:
2141 name: nvim
2142spec:
2143 depends: [node]
2144 packages:
2145 - name: neovim
2146 - name: ripgrep
2147 scripts:
2148 postApply:
2149 - nvim --headless "+Lazy! sync" +qa
2150"#,
2151 )
2152 .unwrap();
2153
2154 let brew = MockManager::new("brew")
2155 .with_package("node", "20.0.0")
2156 .with_package("neovim", "0.10.2")
2157 .with_package("ripgrep", "14.1.0");
2158
2159 let managers = make_manager_map(&[("brew", &brew)]);
2160 let platform = macos_platform();
2161
2162 let cache_dir = tempfile::tempdir().unwrap();
2163 let printer = test_printer();
2164
2165 let resolved = resolve_modules(
2166 &["nvim".into()],
2167 dir.path(),
2168 cache_dir.path(),
2169 &platform,
2170 &managers,
2171 &printer,
2172 )
2173 .unwrap();
2174
2175 assert_eq!(resolved.len(), 2);
2177 assert_eq!(resolved[0].name, "node");
2178 assert_eq!(resolved[1].name, "nvim");
2179
2180 assert_eq!(resolved[0].packages.len(), 1);
2182 assert_eq!(resolved[0].packages[0].canonical_name, "nodejs");
2183 assert_eq!(resolved[0].packages[0].resolved_name, "node"); assert_eq!(resolved[0].packages[0].manager, "brew");
2185
2186 assert_eq!(resolved[1].packages.len(), 2);
2188 assert_eq!(resolved[1].packages[0].canonical_name, "neovim");
2189 assert_eq!(resolved[1].packages[1].canonical_name, "ripgrep");
2190
2191 assert_eq!(resolved[1].post_apply_scripts.len(), 1);
2193 }
2194
2195 #[test]
2198 fn parse_module_yaml() {
2199 let yaml = r#"
2200apiVersion: cfgd.io/v1alpha1
2201kind: Module
2202metadata:
2203 name: test-mod
2204spec:
2205 depends: [a, b]
2206 packages:
2207 - name: foo
2208 minVersion: "1.0"
2209 prefer: [brew, apt]
2210 aliases:
2211 apt: foo-tools
2212 - name: bar
2213 files:
2214 - source: config/
2215 target: ~/.config/foo/
2216 - source: https://github.com/user/repo.git@v1.0
2217 target: ~/.config/bar/
2218 scripts:
2219 postApply:
2220 - echo done
2221"#;
2222 let doc = parse_module(yaml).unwrap();
2223 assert_eq!(doc.metadata.name, "test-mod");
2224 assert_eq!(doc.spec.depends, vec!["a", "b"]);
2225 assert_eq!(doc.spec.packages.len(), 2);
2226 assert_eq!(doc.spec.packages[0].name, "foo");
2227 assert_eq!(doc.spec.packages[0].min_version, Some("1.0".into()));
2228 assert_eq!(doc.spec.packages[0].prefer, vec!["brew", "apt"]);
2229 assert_eq!(
2230 doc.spec.packages[0].aliases.get("apt"),
2231 Some(&"foo-tools".to_string())
2232 );
2233 assert_eq!(doc.spec.files.len(), 2);
2234 assert_eq!(
2235 doc.spec.files[1].source,
2236 "https://github.com/user/repo.git@v1.0"
2237 );
2238 let scripts = doc.spec.scripts.unwrap();
2239 assert_eq!(
2240 scripts.post_apply,
2241 vec![crate::config::ScriptEntry::Simple("echo done".to_string())]
2242 );
2243 }
2244
2245 #[test]
2246 fn parse_module_minimal() {
2247 let yaml = r#"
2248apiVersion: cfgd.io/v1alpha1
2249kind: Module
2250metadata:
2251 name: minimal
2252spec: {}
2253"#;
2254 let doc = parse_module(yaml).unwrap();
2255 assert_eq!(doc.metadata.name, "minimal");
2256 assert!(doc.spec.packages.is_empty());
2257 assert!(doc.spec.files.is_empty());
2258 assert!(doc.spec.depends.is_empty());
2259 }
2260
2261 #[test]
2264 fn profile_with_modules_field() {
2265 let yaml = r#"
2266apiVersion: cfgd.io/v1alpha1
2267kind: Profile
2268metadata:
2269 name: test
2270spec:
2271 modules: [nvim, tmux, git]
2272 packages:
2273 brew:
2274 formulae: [ripgrep]
2275"#;
2276 let doc: crate::config::ProfileDocument = serde_yaml::from_str(yaml).unwrap();
2277 assert_eq!(doc.spec.modules, vec!["nvim", "tmux", "git"]);
2278 }
2279
2280 #[test]
2283 fn resolve_package_script_manager() {
2284 let managers: HashMap<String, &dyn PackageManager> = HashMap::new();
2285 let platform = linux_ubuntu_platform();
2286
2287 let entry = ModulePackageEntry {
2288 name: "rustup".into(),
2289 min_version: None,
2290 prefer: vec!["script".into()],
2291 aliases: HashMap::new(),
2292 script: Some(
2293 "curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y".into(),
2294 ),
2295 deny: vec![],
2296 platforms: vec![],
2297 };
2298
2299 let result = resolve_package(&entry, "test", &platform, &managers)
2300 .unwrap()
2301 .unwrap();
2302 assert_eq!(result.manager, "script");
2303 assert_eq!(result.canonical_name, "rustup");
2304 assert_eq!(result.resolved_name, "rustup");
2305 assert!(result.script.is_some());
2306 assert!(result.script.unwrap().contains("rustup.rs"));
2307 assert!(result.version.is_none());
2308 }
2309
2310 #[test]
2311 fn resolve_package_script_fallback() {
2312 let brew = MockManager::new("brew").unavailable();
2314 let managers = make_manager_map(&[("brew", &brew)]);
2315 let platform = linux_ubuntu_platform();
2316
2317 let entry = ModulePackageEntry {
2318 name: "neovim".into(),
2319 min_version: None,
2320 prefer: vec!["brew".into(), "script".into()],
2321 aliases: HashMap::new(),
2322 script: Some("scripts/install-neovim.sh".into()),
2323 deny: vec![],
2324 platforms: vec![],
2325 };
2326
2327 let result = resolve_package(&entry, "nvim", &platform, &managers)
2328 .unwrap()
2329 .unwrap();
2330 assert_eq!(result.manager, "script");
2331 assert_eq!(result.script, Some("scripts/install-neovim.sh".into()));
2332 }
2333
2334 #[test]
2335 fn resolve_package_script_preferred_over_manager() {
2336 let brew = MockManager::new("brew").with_package("neovim", "0.10.2");
2338 let managers = make_manager_map(&[("brew", &brew)]);
2339 let platform = macos_platform();
2340
2341 let entry = ModulePackageEntry {
2342 name: "neovim".into(),
2343 min_version: None,
2344 prefer: vec!["script".into(), "brew".into()],
2345 aliases: HashMap::new(),
2346 script: Some("build-from-source.sh".into()),
2347 deny: vec![],
2348 platforms: vec![],
2349 };
2350
2351 let result = resolve_package(&entry, "nvim", &platform, &managers)
2352 .unwrap()
2353 .unwrap();
2354 assert_eq!(result.manager, "script");
2355 }
2356
2357 #[test]
2358 fn resolve_package_script_missing_errors() {
2359 let managers: HashMap<String, &dyn PackageManager> = HashMap::new();
2360 let platform = linux_ubuntu_platform();
2361
2362 let entry = ModulePackageEntry {
2363 name: "rustup".into(),
2364 min_version: None,
2365 prefer: vec!["script".into()],
2366 aliases: HashMap::new(),
2367 script: None, deny: vec![],
2369 platforms: vec![],
2370 };
2371
2372 let result = resolve_package(&entry, "test", &platform, &managers);
2373 assert!(result.is_err());
2374 assert!(
2375 result
2376 .unwrap_err()
2377 .to_string()
2378 .contains("no 'script' field")
2379 );
2380 }
2381
2382 #[test]
2385 fn resolve_package_platform_match_os() {
2386 let apt = MockManager::new("apt").with_package("ripgrep", "14.0.0");
2387 let managers = make_manager_map(&[("apt", &apt)]);
2388 let platform = linux_ubuntu_platform();
2389
2390 let entry = ModulePackageEntry {
2391 name: "ripgrep".into(),
2392 min_version: None,
2393 prefer: vec![],
2394 aliases: HashMap::new(),
2395 script: None,
2396 deny: vec![],
2397 platforms: vec!["linux".into()],
2398 };
2399
2400 let result = resolve_package(&entry, "test", &platform, &managers).unwrap();
2401 assert!(result.is_some());
2402 assert_eq!(result.unwrap().manager, "apt");
2403 }
2404
2405 #[test]
2406 fn resolve_package_platform_skip_wrong_os() {
2407 let apt = MockManager::new("apt").with_package("ripgrep", "14.0.0");
2408 let managers = make_manager_map(&[("apt", &apt)]);
2409 let platform = linux_ubuntu_platform();
2410
2411 let entry = ModulePackageEntry {
2412 name: "coreutils".into(),
2413 min_version: None,
2414 prefer: vec!["brew".into()],
2415 aliases: HashMap::new(),
2416 script: None,
2417 deny: vec![],
2418 platforms: vec!["macos".into()], };
2420
2421 let result = resolve_package(&entry, "test", &platform, &managers).unwrap();
2423 assert!(result.is_none());
2424 }
2425
2426 #[test]
2427 fn resolve_package_platform_match_distro() {
2428 let apt = MockManager::new("apt").with_package("ripgrep", "14.0.0");
2429 let managers = make_manager_map(&[("apt", &apt)]);
2430 let platform = linux_ubuntu_platform();
2431
2432 let entry = ModulePackageEntry {
2433 name: "ripgrep".into(),
2434 min_version: None,
2435 prefer: vec![],
2436 aliases: HashMap::new(),
2437 script: None,
2438 deny: vec![],
2439 platforms: vec!["ubuntu".into()],
2440 };
2441
2442 let result = resolve_package(&entry, "test", &platform, &managers).unwrap();
2443 assert!(result.is_some());
2444 }
2445
2446 #[test]
2447 fn resolve_package_platform_match_arch() {
2448 let apt = MockManager::new("apt").with_package("ripgrep", "14.0.0");
2449 let managers = make_manager_map(&[("apt", &apt)]);
2450 let platform = Platform {
2451 arch: crate::platform::Arch::Aarch64,
2452 ..linux_ubuntu_platform()
2453 };
2454
2455 let entry = ModulePackageEntry {
2456 name: "ripgrep".into(),
2457 min_version: None,
2458 prefer: vec![],
2459 aliases: HashMap::new(),
2460 script: None,
2461 deny: vec![],
2462 platforms: vec!["aarch64".into()],
2463 };
2464
2465 let result = resolve_package(&entry, "test", &platform, &managers).unwrap();
2466 assert!(result.is_some());
2467 }
2468
2469 #[test]
2470 fn resolve_package_platform_empty_matches_all() {
2471 let brew = MockManager::new("brew").with_package("ripgrep", "14.0.0");
2472 let managers = make_manager_map(&[("brew", &brew)]);
2473 let platform = macos_platform();
2474
2475 let entry = ModulePackageEntry {
2476 name: "ripgrep".into(),
2477 min_version: None,
2478 prefer: vec![],
2479 aliases: HashMap::new(),
2480 script: None,
2481 deny: vec![],
2482 platforms: vec![], };
2484
2485 let result = resolve_package(&entry, "test", &platform, &managers).unwrap();
2486 assert!(result.is_some());
2487 }
2488
2489 #[test]
2490 fn resolve_module_packages_skips_filtered() {
2491 let brew = MockManager::new("brew").with_package("ripgrep", "14.0.0");
2492 let managers = make_manager_map(&[("brew", &brew)]);
2493 let platform = macos_platform();
2494
2495 let module = LoadedModule {
2496 name: "test".into(),
2497 spec: ModuleSpec {
2498 packages: vec![
2499 ModulePackageEntry {
2500 name: "ripgrep".into(),
2501 min_version: None,
2502 prefer: vec![],
2503 aliases: HashMap::new(),
2504 script: None,
2505 deny: vec![],
2506 platforms: vec![], },
2508 ModulePackageEntry {
2509 name: "apt-only-tool".into(),
2510 min_version: None,
2511 prefer: vec!["apt".into()],
2512 aliases: HashMap::new(),
2513 script: None,
2514 deny: vec![],
2515 platforms: vec!["linux".into()], },
2517 ],
2518 ..Default::default()
2519 },
2520 dir: PathBuf::from("/fake/test"),
2521 };
2522
2523 let resolved = resolve_module_packages(&module, &platform, &managers).unwrap();
2524 assert_eq!(resolved.len(), 1);
2526 assert_eq!(resolved[0].canonical_name, "ripgrep");
2527 }
2528
2529 #[test]
2532 fn parse_module_with_script_and_platforms() {
2533 let yaml = r#"
2534apiVersion: cfgd.io/v1alpha1
2535kind: Module
2536metadata:
2537 name: rustup
2538spec:
2539 packages:
2540 - name: rustup
2541 prefer: [script]
2542 script: |
2543 curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
2544 - name: sysctl-tweaks
2545 prefer: [script]
2546 script: scripts/apply-sysctl.sh
2547 platforms: [linux]
2548"#;
2549 let doc = parse_module(yaml).unwrap();
2550 assert_eq!(doc.spec.packages.len(), 2);
2551
2552 let rustup = &doc.spec.packages[0];
2553 assert_eq!(rustup.name, "rustup");
2554 assert_eq!(rustup.prefer, vec!["script"]);
2555 assert!(rustup.script.is_some());
2556 assert!(rustup.script.as_ref().unwrap().contains("rustup.rs"));
2557 assert!(rustup.platforms.is_empty());
2558
2559 let sysctl = &doc.spec.packages[1];
2560 assert_eq!(sysctl.name, "sysctl-tweaks");
2561 assert_eq!(sysctl.script, Some("scripts/apply-sysctl.sh".into()));
2562 assert_eq!(sysctl.platforms, vec!["linux"]);
2563 }
2564
2565 #[test]
2568 fn lockfile_round_trip() {
2569 let dir = tempfile::tempdir().unwrap();
2570 let lockfile = ModuleLockfile {
2571 modules: vec![ModuleLockEntry {
2572 name: "nvim".into(),
2573 url: "https://github.com/user/nvim-module.git@v1.0".into(),
2574 pinned_ref: "v1.0".into(),
2575 commit: "abc123def456".into(),
2576 integrity: "sha256:deadbeef".into(),
2577 subdir: None,
2578 }],
2579 };
2580
2581 save_lockfile(dir.path(), &lockfile).unwrap();
2582 let loaded = load_lockfile(dir.path()).unwrap();
2583
2584 assert_eq!(loaded.modules.len(), 1);
2585 assert_eq!(loaded.modules[0].name, "nvim");
2586 assert_eq!(loaded.modules[0].pinned_ref, "v1.0");
2587 assert_eq!(loaded.modules[0].commit, "abc123def456");
2588 assert_eq!(loaded.modules[0].integrity, "sha256:deadbeef");
2589 assert!(loaded.modules[0].subdir.is_none());
2590 }
2591
2592 #[test]
2593 fn lockfile_round_trip_with_subdir() {
2594 let dir = tempfile::tempdir().unwrap();
2595 let lockfile = ModuleLockfile {
2596 modules: vec![ModuleLockEntry {
2597 name: "tmux".into(),
2598 url: "https://github.com/user/modules.git//tmux@v2.0".into(),
2599 pinned_ref: "v2.0".into(),
2600 commit: "789abc".into(),
2601 integrity: "sha256:cafe".into(),
2602 subdir: Some("tmux".into()),
2603 }],
2604 };
2605
2606 save_lockfile(dir.path(), &lockfile).unwrap();
2607 let loaded = load_lockfile(dir.path()).unwrap();
2608
2609 assert_eq!(loaded.modules[0].subdir, Some("tmux".into()));
2610 }
2611
2612 #[test]
2613 fn load_lockfile_missing_returns_empty() {
2614 let dir = tempfile::tempdir().unwrap();
2615 let lockfile = load_lockfile(dir.path()).unwrap();
2616 assert!(lockfile.modules.is_empty());
2617 }
2618
2619 #[test]
2622 fn hash_module_contents_deterministic() {
2623 let dir = tempfile::tempdir().unwrap();
2624 let mod_dir = dir.path().join("mymodule");
2625 std::fs::create_dir_all(mod_dir.join("config")).unwrap();
2626 std::fs::write(mod_dir.join("module.yaml"), "name: mymodule\n").unwrap();
2627 std::fs::write(mod_dir.join("config/init.lua"), "-- nvim config\n").unwrap();
2628
2629 let hash1 = hash_module_contents(&mod_dir).unwrap();
2630 let hash2 = hash_module_contents(&mod_dir).unwrap();
2631 assert_eq!(hash1, hash2);
2632 assert!(hash1.starts_with("sha256:"));
2633 }
2634
2635 #[test]
2636 fn hash_module_contents_changes_on_file_change() {
2637 let dir = tempfile::tempdir().unwrap();
2638 let mod_dir = dir.path().join("mymod");
2639 std::fs::create_dir_all(&mod_dir).unwrap();
2640 std::fs::write(mod_dir.join("module.yaml"), "v1\n").unwrap();
2641
2642 let hash1 = hash_module_contents(&mod_dir).unwrap();
2643 std::fs::write(mod_dir.join("module.yaml"), "v2\n").unwrap();
2644 let hash2 = hash_module_contents(&mod_dir).unwrap();
2645
2646 assert_ne!(hash1, hash2);
2647 }
2648
2649 #[test]
2650 fn hash_module_contents_skips_dot_git() {
2651 let dir = tempfile::tempdir().unwrap();
2652 let mod_dir = dir.path().join("mymod");
2653 std::fs::create_dir_all(mod_dir.join(".git")).unwrap();
2654 std::fs::write(mod_dir.join("module.yaml"), "content\n").unwrap();
2655 std::fs::write(mod_dir.join(".git/HEAD"), "ref: refs/heads/main\n").unwrap();
2656
2657 let hash_with_git = hash_module_contents(&mod_dir).unwrap();
2658
2659 std::fs::remove_dir_all(mod_dir.join(".git")).unwrap();
2661 let hash_without_git = hash_module_contents(&mod_dir).unwrap();
2662
2663 assert_eq!(hash_with_git, hash_without_git);
2664 }
2665
2666 #[test]
2669 fn verify_lockfile_integrity_success() {
2670 let dir = tempfile::tempdir().unwrap();
2671
2672 let url = "https://example.com/fake.git@v1.0";
2675 let expected_cache_dir = git_cache_dir(dir.path(), "https://example.com/fake.git");
2676 std::fs::create_dir_all(&expected_cache_dir).unwrap();
2678 std::fs::write(expected_cache_dir.join("module.yaml"), "test content\n").unwrap();
2679
2680 let actual_integrity = hash_module_contents(&expected_cache_dir).unwrap();
2681
2682 let entry = ModuleLockEntry {
2683 name: "test".into(),
2684 url: url.into(),
2685 pinned_ref: "v1.0".into(),
2686 commit: "abc".into(),
2687 integrity: actual_integrity,
2688 subdir: None,
2689 };
2690
2691 let result = verify_lockfile_integrity(&entry, dir.path());
2692 assert!(
2693 result.is_ok(),
2694 "integrity check should pass: {:?}",
2695 result.unwrap_err()
2696 );
2697
2698 std::fs::write(expected_cache_dir.join("module.yaml"), "tampered content\n").unwrap();
2700 let tampered_result = verify_lockfile_integrity(&entry, dir.path());
2701 let err = tampered_result.unwrap_err().to_string();
2702 assert!(
2703 err.contains("integrity check failed"),
2704 "tampered module should fail integrity: {err}"
2705 );
2706 }
2707
2708 #[test]
2709 fn verify_lockfile_integrity_mismatch() {
2710 let dir = tempfile::tempdir().unwrap();
2711 let url = "https://example.com/mod.git@v1.0";
2712 let cache_dir = git_cache_dir(dir.path(), "https://example.com/mod.git");
2713 std::fs::create_dir_all(&cache_dir).unwrap();
2714 std::fs::write(cache_dir.join("module.yaml"), "tampered\n").unwrap();
2715
2716 let entry = ModuleLockEntry {
2717 name: "test".into(),
2718 url: url.into(),
2719 pinned_ref: "v1.0".into(),
2720 commit: "abc".into(),
2721 integrity: "sha256:wrong".into(),
2722 subdir: None,
2723 };
2724
2725 let result = verify_lockfile_integrity(&entry, dir.path());
2726 assert!(result.is_err());
2727 let err = result.unwrap_err().to_string();
2728 assert!(err.contains("integrity"));
2729 }
2730
2731 #[test]
2734 fn diff_module_specs_no_changes() {
2735 let module = LoadedModule {
2736 name: "test".into(),
2737 spec: ModuleSpec {
2738 depends: vec!["dep1".into()],
2739 packages: vec![ModulePackageEntry {
2740 name: "pkg1".into(),
2741 min_version: Some("1.0".into()),
2742 prefer: vec![],
2743 aliases: HashMap::new(),
2744 script: None,
2745 deny: vec![],
2746 platforms: vec![],
2747 }],
2748 files: vec![],
2749 env: vec![],
2750 aliases: vec![],
2751 scripts: None,
2752 system: HashMap::new(),
2753 },
2754 dir: PathBuf::from("/fake"),
2755 };
2756
2757 let changes = diff_module_specs(&module, &module);
2758 assert_eq!(changes, vec!["(no spec changes)"]);
2759 }
2760
2761 #[test]
2762 fn diff_module_specs_detects_changes() {
2763 let old = LoadedModule {
2764 name: "test".into(),
2765 spec: ModuleSpec {
2766 depends: vec!["dep1".into()],
2767 packages: vec![
2768 ModulePackageEntry {
2769 name: "pkg1".into(),
2770 min_version: Some("1.0".into()),
2771 prefer: vec![],
2772 aliases: HashMap::new(),
2773 script: None,
2774 deny: vec![],
2775 platforms: vec![],
2776 },
2777 ModulePackageEntry {
2778 name: "pkg2".into(),
2779 min_version: None,
2780 prefer: vec![],
2781 aliases: HashMap::new(),
2782 script: None,
2783 deny: vec![],
2784 platforms: vec![],
2785 },
2786 ],
2787 files: vec![ModuleFileEntry {
2788 source: "config/".into(),
2789 target: "~/.config/test/".into(),
2790 strategy: None,
2791 private: false,
2792 encryption: None,
2793 }],
2794 env: vec![],
2795 aliases: vec![],
2796 scripts: None,
2797 system: HashMap::new(),
2798 },
2799 dir: PathBuf::from("/fake"),
2800 };
2801
2802 let new = LoadedModule {
2803 name: "test".into(),
2804 spec: ModuleSpec {
2805 depends: vec!["dep1".into(), "dep2".into()],
2806 packages: vec![
2807 ModulePackageEntry {
2808 name: "pkg1".into(),
2809 min_version: Some("2.0".into()),
2810 prefer: vec![],
2811 aliases: HashMap::new(),
2812 script: None,
2813 deny: vec![],
2814 platforms: vec![],
2815 },
2816 ModulePackageEntry {
2817 name: "pkg3".into(),
2818 min_version: None,
2819 prefer: vec![],
2820 aliases: HashMap::new(),
2821 script: None,
2822 deny: vec![],
2823 platforms: vec![],
2824 },
2825 ],
2826 files: vec![ModuleFileEntry {
2827 source: "config/".into(),
2828 target: "~/.config/new/".into(),
2829 strategy: None,
2830 private: false,
2831 encryption: None,
2832 }],
2833 env: vec![],
2834 aliases: vec![],
2835 scripts: None,
2836 system: HashMap::new(),
2837 },
2838 dir: PathBuf::from("/fake"),
2839 };
2840
2841 let changes = diff_module_specs(&old, &new);
2842 assert!(changes.iter().any(|c| c.contains("+ dependency: dep2")));
2844 assert!(changes.iter().any(|c| c.contains("+ package: pkg3")));
2845 assert!(changes.iter().any(|c| c.contains("- package: pkg2")));
2846 assert!(
2847 changes
2848 .iter()
2849 .any(|c| c.contains("~ package 'pkg1': minVersion"))
2850 );
2851 assert!(changes.iter().any(|c| c.contains("+ file target")));
2852 assert!(changes.iter().any(|c| c.contains("- file target")));
2853 }
2854
2855 #[test]
2858 fn extract_registry_name_https() {
2859 assert_eq!(
2860 extract_registry_name("https://github.com/cfgd-community/modules.git"),
2861 Some("cfgd-community".into())
2862 );
2863 }
2864
2865 #[test]
2866 fn extract_registry_name_ssh() {
2867 assert_eq!(
2868 extract_registry_name("git@github.com:myorg/modules.git"),
2869 Some("myorg".into())
2870 );
2871 }
2872
2873 #[test]
2874 fn extract_registry_name_non_github() {
2875 assert_eq!(
2876 extract_registry_name("https://gitlab.com/org/repo.git"),
2877 None
2878 );
2879 }
2880
2881 #[test]
2884 fn is_registry_ref_with_registry_module() {
2885 assert!(is_registry_ref("community/tmux"));
2886 assert!(is_registry_ref("myorg/nvim@v1.0"));
2887 }
2888
2889 #[test]
2890 fn is_registry_ref_bare_name() {
2891 assert!(!is_registry_ref("tmux"));
2892 }
2893
2894 #[test]
2895 fn is_registry_ref_git_url() {
2896 assert!(!is_registry_ref("https://github.com/user/repo.git"));
2897 assert!(!is_registry_ref("git@github.com:user/repo.git"));
2898 }
2899
2900 #[test]
2901 fn parse_registry_ref_with_tag() {
2902 let r = parse_registry_ref("community/tmux@v1.0").unwrap();
2903 assert_eq!(r.registry, "community");
2904 assert_eq!(r.module, "tmux");
2905 assert_eq!(r.tag, Some("v1.0".into()));
2906 }
2907
2908 #[test]
2909 fn parse_registry_ref_without_tag() {
2910 let r = parse_registry_ref("myorg/nvim").unwrap();
2911 assert_eq!(r.registry, "myorg");
2912 assert_eq!(r.module, "nvim");
2913 assert!(r.tag.is_none());
2914 }
2915
2916 #[test]
2917 fn parse_registry_ref_invalid() {
2918 assert!(parse_registry_ref("tmux").is_none());
2919 assert!(parse_registry_ref("/tmux").is_none());
2920 assert!(parse_registry_ref("community/").is_none());
2921 assert!(parse_registry_ref("community/@v1").is_none());
2922 assert!(parse_registry_ref("community/tmux@").is_none());
2923 }
2924
2925 #[test]
2926 fn resolve_profile_module_name_bare() {
2927 assert_eq!(resolve_profile_module_name("tmux"), "tmux");
2928 }
2929
2930 #[test]
2931 fn resolve_profile_module_name_registry_ref() {
2932 assert_eq!(resolve_profile_module_name("community/tmux"), "tmux");
2933 }
2934
2935 #[test]
2938 fn load_locked_modules_merges_with_local() {
2939 let dir = tempfile::tempdir().unwrap();
2940
2941 let mod_dir = dir.path().join("modules").join("local-mod");
2943 std::fs::create_dir_all(&mod_dir).unwrap();
2944 std::fs::write(
2945 mod_dir.join("module.yaml"),
2946 r#"
2947apiVersion: cfgd.io/v1alpha1
2948kind: Module
2949metadata:
2950 name: local-mod
2951spec:
2952 packages:
2953 - name: local-pkg
2954"#,
2955 )
2956 .unwrap();
2957
2958 let lockfile = ModuleLockfile {
2962 modules: vec![ModuleLockEntry {
2963 name: "remote-mod".into(),
2964 url: "https://example.com/remote.git@v1.0".into(),
2965 pinned_ref: "v1.0".into(),
2966 commit: "abc".into(),
2967 integrity: "sha256:test".into(),
2968 subdir: None,
2969 }],
2970 };
2971 save_lockfile(dir.path(), &lockfile).unwrap();
2972
2973 let local = load_modules(dir.path()).unwrap();
2975 assert_eq!(local.len(), 1);
2976 assert!(local.contains_key("local-mod"));
2977 }
2978
2979 #[test]
2982 fn fetch_remote_module_rejects_unpinned() {
2983 let dir = tempfile::tempdir().unwrap();
2984 let printer = test_printer();
2985 let result =
2987 fetch_remote_module("https://github.com/user/module.git", dir.path(), &printer);
2988 assert!(result.is_err());
2989 let err = result.unwrap_err().to_string();
2990 assert!(err.contains("pinned ref"));
2991 }
2992
2993 #[test]
2994 fn fetch_remote_module_rejects_branch_ref() {
2995 let dir = tempfile::tempdir().unwrap();
2996 let printer = test_printer();
2997 let result = fetch_remote_module(
2999 "https://github.com/user/module.git?ref=main",
3000 dir.path(),
3001 &printer,
3002 );
3003 assert!(result.is_err());
3004 let err = result.unwrap_err().to_string();
3005 assert!(err.contains("pinned ref"));
3006 }
3007
3008 #[test]
3011 fn parse_git_source_ref_with_subdir() {
3012 let src = parse_git_source("https://github.com/user/repo.git?ref=dev//subdir").unwrap();
3013 assert_eq!(src.repo_url, "https://github.com/user/repo.git");
3014 assert_eq!(src.git_ref.as_deref(), Some("dev"));
3015 assert_eq!(src.subdir.as_deref(), Some("subdir"));
3016 assert!(src.tag.is_none());
3017 }
3018
3019 #[test]
3020 fn parse_git_source_ref_with_subdir_and_tag() {
3021 let src =
3022 parse_git_source("https://github.com/user/repo.git?ref=dev//subdir@v1.0").unwrap();
3023 assert_eq!(src.repo_url, "https://github.com/user/repo.git");
3024 assert_eq!(src.git_ref.as_deref(), Some("dev"));
3025 assert_eq!(src.subdir.as_deref(), Some("subdir"));
3026 assert_eq!(src.tag.as_deref(), Some("v1.0"));
3027 }
3028
3029 #[test]
3032 fn parse_git_source_ssh_no_dot_git_with_tag() {
3033 let src = parse_git_source("git@github.com:user/repo@v2.0").unwrap();
3034 assert_eq!(src.repo_url, "git@github.com:user/repo");
3035 assert_eq!(src.tag.as_deref(), Some("v2.0"));
3036 }
3037
3038 #[test]
3039 fn parse_git_source_ssh_no_dot_git_no_tag() {
3040 let src = parse_git_source("git@github.com:user/repo").unwrap();
3041 assert_eq!(src.repo_url, "git@github.com:user/repo");
3042 assert!(src.tag.is_none());
3043 }
3044
3045 #[test]
3048 fn hash_module_contents_empty_dir() {
3049 let dir = tempfile::tempdir().unwrap();
3050 let hash = hash_module_contents(dir.path()).unwrap();
3051 assert!(hash.starts_with("sha256:"));
3052 let hash2 = hash_module_contents(dir.path()).unwrap();
3054 assert_eq!(hash, hash2);
3055 }
3056
3057 #[test]
3060 #[cfg(unix)]
3061 fn hash_module_contents_skips_symlinks() {
3062 let dir = tempfile::tempdir().unwrap();
3063 std::fs::write(dir.path().join("real.txt"), "hello").unwrap();
3064 std::os::unix::fs::symlink("/dev/null", dir.path().join("link.txt")).unwrap();
3065
3066 let hash_with_link = hash_module_contents(dir.path()).unwrap();
3067
3068 std::fs::remove_file(dir.path().join("link.txt")).unwrap();
3070 let hash_without_link = hash_module_contents(dir.path()).unwrap();
3071
3072 assert_eq!(hash_with_link, hash_without_link);
3073 }
3074
3075 #[test]
3078 fn diff_module_specs_scripts_changed() {
3079 let old = LoadedModule {
3080 name: "test".into(),
3081 spec: ModuleSpec {
3082 depends: vec![],
3083 packages: vec![],
3084 files: vec![],
3085 env: vec![],
3086 aliases: vec![],
3087 scripts: Some(crate::config::ScriptSpec {
3088 post_apply: vec![crate::config::ScriptEntry::Simple("echo old".to_string())],
3089 ..Default::default()
3090 }),
3091 system: HashMap::new(),
3092 },
3093 dir: PathBuf::from("/tmp"),
3094 };
3095 let new = LoadedModule {
3096 name: "test".into(),
3097 spec: ModuleSpec {
3098 depends: vec![],
3099 packages: vec![],
3100 files: vec![],
3101 env: vec![],
3102 aliases: vec![],
3103 scripts: Some(crate::config::ScriptSpec {
3104 post_apply: vec![crate::config::ScriptEntry::Simple("echo new".to_string())],
3105 ..Default::default()
3106 }),
3107 system: HashMap::new(),
3108 },
3109 dir: PathBuf::from("/tmp"),
3110 };
3111 let changes = diff_module_specs(&old, &new);
3112 assert!(changes.iter().any(|c| c.contains("+ postApply script")));
3113 assert!(changes.iter().any(|c| c.contains("- postApply script")));
3114 }
3115
3116 #[test]
3117 fn dependency_order_self_dependency_detected() {
3118 let modules = make_test_modules(&[("a", &["a"])]);
3119 let result = resolve_dependency_order(&["a".into()], &modules);
3120 let err = result.unwrap_err().to_string();
3121 assert!(err.contains("cycle"), "error should mention cycle: {err}");
3122 assert!(
3123 err.contains("a"),
3124 "error should mention the cyclic module: {err}"
3125 );
3126 }
3127
3128 #[test]
3129 fn resolve_package_deny_excludes_manager() {
3130 let brew = MockManager::new("brew").with_package("ripgrep", "14.1.0");
3131 let managers = make_manager_map(&[("brew", &brew)]);
3132 let platform = macos_platform();
3133
3134 let entry = ModulePackageEntry {
3135 name: "ripgrep".into(),
3136 min_version: None,
3137 prefer: vec![],
3138 aliases: HashMap::new(),
3139 script: None,
3140 deny: vec!["brew".into()],
3141 platforms: vec![],
3142 };
3143
3144 let result = resolve_package(&entry, "test", &platform, &managers);
3145 let err = result.unwrap_err().to_string();
3147 assert!(
3148 err.contains("ripgrep"),
3149 "error should mention the package: {err}"
3150 );
3151 assert!(
3152 err.contains("cannot be resolved"),
3153 "error should indicate unresolvable: {err}"
3154 );
3155 }
3156
3157 #[test]
3158 fn load_lockfile_malformed_yaml_errors() {
3159 let dir = tempfile::tempdir().unwrap();
3160 let lockfile_path = dir.path().join("modules.lock");
3161 std::fs::write(&lockfile_path, "{{{{not valid yaml").unwrap();
3162 let result = load_lockfile(dir.path());
3163 assert!(result.is_err());
3164 }
3165
3166 #[test]
3171 fn extract_registry_name_https_github() {
3172 assert_eq!(
3173 extract_registry_name("https://github.com/myorg/cfgd-registry"),
3174 Some("myorg".to_string())
3175 );
3176 }
3177
3178 #[test]
3179 fn extract_registry_name_https_github_with_git_suffix() {
3180 assert_eq!(
3181 extract_registry_name("https://github.com/acme/modules.git"),
3182 Some("acme".to_string())
3183 );
3184 }
3185
3186 #[test]
3187 fn extract_registry_name_ssh_github() {
3188 assert_eq!(
3189 extract_registry_name("git@github.com:myorg/cfgd-registry.git"),
3190 Some("myorg".to_string())
3191 );
3192 }
3193
3194 #[test]
3195 fn extract_registry_name_http_github() {
3196 assert_eq!(
3197 extract_registry_name("http://github.com/testorg/repo"),
3198 Some("testorg".to_string())
3199 );
3200 }
3201
3202 #[test]
3203 fn extract_registry_name_non_github_returns_none() {
3204 assert_eq!(extract_registry_name("https://gitlab.com/org/repo"), None);
3205 }
3206
3207 #[test]
3208 fn extract_registry_name_empty_returns_none() {
3209 assert_eq!(extract_registry_name(""), None);
3210 }
3211
3212 fn make_loaded_module(name: &str, spec: crate::config::ModuleSpec) -> LoadedModule {
3217 LoadedModule {
3218 name: name.to_string(),
3219 spec,
3220 dir: PathBuf::from("/fake"),
3221 }
3222 }
3223
3224 #[test]
3225 fn diff_module_specs_no_changes_default() {
3226 let spec = crate::config::ModuleSpec::default();
3227 let old = make_loaded_module("test", spec.clone());
3228 let new = make_loaded_module("test", spec);
3229 let changes = diff_module_specs(&old, &new);
3230 assert_eq!(changes, vec!["(no spec changes)".to_string()]);
3231 }
3232
3233 #[test]
3234 fn diff_module_specs_added_dependency() {
3235 let old = make_loaded_module("test", crate::config::ModuleSpec::default());
3236 let new_spec = crate::config::ModuleSpec {
3237 depends: vec!["core".to_string()],
3238 ..Default::default()
3239 };
3240 let new = make_loaded_module("test", new_spec);
3241 let changes = diff_module_specs(&old, &new);
3242 assert!(changes.iter().any(|c| c.contains("+ dependency: core")));
3243 }
3244
3245 #[test]
3246 fn diff_module_specs_removed_dependency() {
3247 let old_spec = crate::config::ModuleSpec {
3248 depends: vec!["core".to_string()],
3249 ..Default::default()
3250 };
3251 let old = make_loaded_module("test", old_spec);
3252 let new = make_loaded_module("test", crate::config::ModuleSpec::default());
3253 let changes = diff_module_specs(&old, &new);
3254 assert!(changes.iter().any(|c| c.contains("- dependency: core")));
3255 }
3256
3257 #[test]
3258 fn diff_module_specs_added_package() {
3259 let old = make_loaded_module("test", crate::config::ModuleSpec::default());
3260 let new_spec = crate::config::ModuleSpec {
3261 packages: vec![crate::config::ModulePackageEntry {
3262 name: "ripgrep".to_string(),
3263 ..Default::default()
3264 }],
3265 ..Default::default()
3266 };
3267 let new = make_loaded_module("test", new_spec);
3268 let changes = diff_module_specs(&old, &new);
3269 assert!(changes.iter().any(|c| c.contains("+ package: ripgrep")));
3270 }
3271
3272 #[test]
3273 fn diff_module_specs_removed_package() {
3274 let old_spec = crate::config::ModuleSpec {
3275 packages: vec![crate::config::ModulePackageEntry {
3276 name: "vim".to_string(),
3277 ..Default::default()
3278 }],
3279 ..Default::default()
3280 };
3281 let old = make_loaded_module("test", old_spec);
3282 let new = make_loaded_module("test", crate::config::ModuleSpec::default());
3283 let changes = diff_module_specs(&old, &new);
3284 assert!(changes.iter().any(|c| c.contains("- package: vim")));
3285 }
3286
3287 #[test]
3288 fn diff_module_specs_package_version_change() {
3289 let old_spec = crate::config::ModuleSpec {
3290 packages: vec![crate::config::ModulePackageEntry {
3291 name: "kubectl".to_string(),
3292 min_version: Some("1.28".to_string()),
3293 ..Default::default()
3294 }],
3295 ..Default::default()
3296 };
3297 let old = make_loaded_module("test", old_spec);
3298
3299 let new_spec = crate::config::ModuleSpec {
3300 packages: vec![crate::config::ModulePackageEntry {
3301 name: "kubectl".to_string(),
3302 min_version: Some("1.30".to_string()),
3303 ..Default::default()
3304 }],
3305 ..Default::default()
3306 };
3307 let new = make_loaded_module("test", new_spec);
3308 let changes = diff_module_specs(&old, &new);
3309 assert!(
3310 changes
3311 .iter()
3312 .any(|c| c.contains("kubectl") && c.contains("1.28") && c.contains("1.30"))
3313 );
3314 }
3315
3316 #[test]
3317 fn diff_module_specs_added_file() {
3318 let old = make_loaded_module("test", crate::config::ModuleSpec::default());
3319 let new_spec = crate::config::ModuleSpec {
3320 files: vec![crate::config::ModuleFileEntry {
3321 source: "zshrc".to_string(),
3322 target: "~/.zshrc".to_string(),
3323 strategy: None,
3324 private: false,
3325 encryption: None,
3326 }],
3327 ..Default::default()
3328 };
3329 let new = make_loaded_module("test", new_spec);
3330 let changes = diff_module_specs(&old, &new);
3331 assert!(
3332 changes
3333 .iter()
3334 .any(|c| c.contains("+ file target: ~/.zshrc"))
3335 );
3336 }
3337
3338 #[test]
3339 fn diff_module_specs_multiple_changes() {
3340 let old_spec = crate::config::ModuleSpec {
3341 depends: vec!["base".to_string()],
3342 packages: vec![crate::config::ModulePackageEntry {
3343 name: "vim".to_string(),
3344 ..Default::default()
3345 }],
3346 ..Default::default()
3347 };
3348 let old = make_loaded_module("test", old_spec);
3349
3350 let new_spec = crate::config::ModuleSpec {
3351 depends: vec!["core".to_string()],
3352 packages: vec![crate::config::ModulePackageEntry {
3353 name: "neovim".to_string(),
3354 ..Default::default()
3355 }],
3356 ..Default::default()
3357 };
3358 let new = make_loaded_module("test", new_spec);
3359 let changes = diff_module_specs(&old, &new);
3360 assert!(
3362 changes.len() >= 4,
3363 "expected at least 4 changes, got {changes:?}"
3364 );
3365 }
3366
3367 #[test]
3372 fn load_lockfile_nonexistent_returns_empty() {
3373 let dir = tempfile::tempdir().unwrap();
3374 let lockfile = load_lockfile(dir.path()).unwrap();
3375 assert!(lockfile.modules.is_empty());
3376 }
3377
3378 #[test]
3379 fn save_and_load_lockfile_roundtrip() {
3380 let dir = tempfile::tempdir().unwrap();
3381 let lockfile = crate::config::ModuleLockfile {
3382 modules: vec![crate::config::ModuleLockEntry {
3383 name: "nvim".to_string(),
3384 url: "https://github.com/user/nvim-config.git@v1.0".to_string(),
3385 pinned_ref: "v1.0".to_string(),
3386 commit: "abc123".to_string(),
3387 integrity: "sha256:deadbeef".to_string(),
3388 subdir: None,
3389 }],
3390 };
3391 save_lockfile(dir.path(), &lockfile).unwrap();
3392
3393 let loaded = load_lockfile(dir.path()).unwrap();
3394 assert_eq!(loaded.modules.len(), 1);
3395 assert_eq!(loaded.modules[0].name, "nvim");
3396 assert_eq!(loaded.modules[0].commit, "abc123");
3397 assert_eq!(loaded.modules[0].integrity, "sha256:deadbeef");
3398 }
3399
3400 #[test]
3401 fn save_lockfile_overwrites_existing() {
3402 let dir = tempfile::tempdir().unwrap();
3403 let lock1 = crate::config::ModuleLockfile {
3404 modules: vec![crate::config::ModuleLockEntry {
3405 name: "old".to_string(),
3406 url: "https://example.com/old.git@v1".to_string(),
3407 pinned_ref: "v1".to_string(),
3408 commit: "111".to_string(),
3409 integrity: "sha256:aaa".to_string(),
3410 subdir: None,
3411 }],
3412 };
3413 save_lockfile(dir.path(), &lock1).unwrap();
3414
3415 let lock2 = crate::config::ModuleLockfile {
3416 modules: vec![crate::config::ModuleLockEntry {
3417 name: "new".to_string(),
3418 url: "https://example.com/new.git@v2".to_string(),
3419 pinned_ref: "v2".to_string(),
3420 commit: "222".to_string(),
3421 integrity: "sha256:bbb".to_string(),
3422 subdir: Some("subdir".to_string()),
3423 }],
3424 };
3425 save_lockfile(dir.path(), &lock2).unwrap();
3426
3427 let loaded = load_lockfile(dir.path()).unwrap();
3428 assert_eq!(loaded.modules.len(), 1);
3429 assert_eq!(loaded.modules[0].name, "new");
3430 assert_eq!(loaded.modules[0].subdir, Some("subdir".to_string()));
3431 }
3432
3433 #[test]
3438 fn hash_module_contents_deterministic_v2() {
3439 let dir = tempfile::tempdir().unwrap();
3440 std::fs::write(dir.path().join("module.yaml"), "spec: {}").unwrap();
3441 std::fs::write(dir.path().join("init.lua"), "-- lua config").unwrap();
3442
3443 let h1 = hash_module_contents(dir.path()).unwrap();
3444 let h2 = hash_module_contents(dir.path()).unwrap();
3445 assert_eq!(h1, h2);
3446 assert!(h1.starts_with("sha256:"));
3447 }
3448
3449 #[test]
3450 fn hash_module_contents_differs_on_content_change() {
3451 let dir = tempfile::tempdir().unwrap();
3452 std::fs::write(dir.path().join("file.txt"), "version 1").unwrap();
3453 let h1 = hash_module_contents(dir.path()).unwrap();
3454
3455 std::fs::write(dir.path().join("file.txt"), "version 2").unwrap();
3456 let h2 = hash_module_contents(dir.path()).unwrap();
3457 assert_ne!(h1, h2);
3458 }
3459
3460 #[test]
3461 fn hash_module_contents_skips_git_dir() {
3462 let dir = tempfile::tempdir().unwrap();
3463 std::fs::write(dir.path().join("file.txt"), "content").unwrap();
3464 std::fs::create_dir(dir.path().join(".git")).unwrap();
3465 std::fs::write(dir.path().join(".git/HEAD"), "ref: refs/heads/main").unwrap();
3466
3467 let h1 = hash_module_contents(dir.path()).unwrap();
3468
3469 std::fs::write(dir.path().join(".git/HEAD"), "ref: refs/heads/dev").unwrap();
3471 let h2 = hash_module_contents(dir.path()).unwrap();
3472 assert_eq!(h1, h2);
3473 }
3474
3475 #[test]
3476 fn hash_module_contents_empty_dir_v2() {
3477 let dir = tempfile::tempdir().unwrap();
3478 let h = hash_module_contents(dir.path()).unwrap();
3479 assert!(h.starts_with("sha256:"));
3480 }
3481
3482 #[test]
3487 fn verify_lockfile_integrity_missing_cache_dir() {
3488 let cache_base = tempfile::tempdir().unwrap();
3489 let entry = crate::config::ModuleLockEntry {
3490 name: "test".to_string(),
3491 url: "https://github.com/user/repo.git@v1.0".to_string(),
3492 pinned_ref: "v1.0".to_string(),
3493 commit: "abc".to_string(),
3494 integrity: "sha256:xxx".to_string(),
3495 subdir: None,
3496 };
3497 let result = verify_lockfile_integrity(&entry, cache_base.path());
3498 assert!(result.is_err());
3499 let err = result.unwrap_err().to_string();
3500 assert!(
3501 err.contains("does not exist") || err.contains("update"),
3502 "expected cache-not-found error, got: {err}"
3503 );
3504 }
3505
3506 #[test]
3511 fn is_registry_ref_with_slash() {
3512 assert!(is_registry_ref("community/tmux"));
3513 assert!(is_registry_ref("myorg/nvim@v1.0"));
3514 }
3515
3516 #[test]
3517 fn is_registry_ref_local_name() {
3518 assert!(!is_registry_ref("tmux"));
3519 assert!(!is_registry_ref("nvim"));
3520 }
3521
3522 #[test]
3523 fn is_registry_ref_git_url_not_registry() {
3524 assert!(!is_registry_ref("https://github.com/user/repo.git"));
3525 assert!(!is_registry_ref("git@github.com:user/repo.git"));
3526 }
3527
3528 #[test]
3529 fn parse_registry_ref_basic() {
3530 let r = parse_registry_ref("community/tmux").unwrap();
3531 assert_eq!(r.registry, "community");
3532 assert_eq!(r.module, "tmux");
3533 assert_eq!(r.tag, None);
3534 }
3535
3536 #[test]
3537 fn parse_registry_ref_with_tag_v2() {
3538 let r = parse_registry_ref("myorg/nvim@v2.0").unwrap();
3539 assert_eq!(r.registry, "myorg");
3540 assert_eq!(r.module, "nvim");
3541 assert_eq!(r.tag, Some("v2.0".to_string()));
3542 }
3543
3544 #[test]
3545 fn parse_registry_ref_empty_registry() {
3546 assert!(parse_registry_ref("/tmux").is_none());
3547 }
3548
3549 #[test]
3550 fn parse_registry_ref_empty_module() {
3551 assert!(parse_registry_ref("community/").is_none());
3552 }
3553
3554 #[test]
3555 fn parse_registry_ref_empty_tag() {
3556 assert!(parse_registry_ref("community/tmux@").is_none());
3557 }
3558
3559 #[test]
3560 fn parse_registry_ref_no_slash() {
3561 assert!(parse_registry_ref("tmux").is_none());
3562 }
3563
3564 #[test]
3565 fn resolve_profile_module_name_local() {
3566 assert_eq!(resolve_profile_module_name("tmux"), "tmux");
3567 assert_eq!(resolve_profile_module_name("nvim"), "nvim");
3568 }
3569
3570 #[test]
3571 fn resolve_profile_module_name_registry_ref_v2() {
3572 assert_eq!(resolve_profile_module_name("community/tmux"), "tmux");
3573 assert_eq!(resolve_profile_module_name("myorg/nvim"), "nvim");
3574 }
3575
3576 #[test]
3581 fn resolve_subdir_none_returns_base() {
3582 let base = PathBuf::from("/cache/abc123");
3583 let result = super::resolve_subdir(base.clone(), &None, "test", "url").unwrap();
3584 assert_eq!(result, base);
3585 }
3586
3587 #[test]
3588 fn resolve_subdir_valid_path() {
3589 let base = PathBuf::from("/cache/abc123");
3590 let result = super::resolve_subdir(base, &Some("nvim".to_string()), "test", "url").unwrap();
3591 assert_eq!(result, PathBuf::from("/cache/abc123/nvim"));
3592 }
3593
3594 #[test]
3595 fn resolve_subdir_traversal_rejected() {
3596 let base = PathBuf::from("/cache/abc123");
3597 let result = super::resolve_subdir(base, &Some("../escape".to_string()), "test", "url");
3598 assert!(result.is_err());
3599 assert!(result.unwrap_err().to_string().contains("traversal"));
3600 }
3601
3602 #[test]
3607 fn load_module_missing_yaml_errors() {
3608 let dir = tempfile::tempdir().unwrap();
3609 let mod_dir = dir.path().join("mymod");
3610 std::fs::create_dir(&mod_dir).unwrap();
3611 let result = load_module(&mod_dir);
3613 assert!(result.is_err());
3614 let err = result.unwrap_err().to_string();
3615 assert!(
3616 err.contains("not found") || err.contains("mymod"),
3617 "expected not-found error, got: {err}"
3618 );
3619 }
3620
3621 #[test]
3622 fn load_module_valid() {
3623 let dir = tempfile::tempdir().unwrap();
3624 let mod_dir = dir.path().join("mymod");
3625 std::fs::create_dir(&mod_dir).unwrap();
3626 std::fs::write(
3627 mod_dir.join("module.yaml"),
3628 r#"
3629apiVersion: cfgd.io/v1alpha1
3630kind: Module
3631metadata:
3632 name: mymod
3633spec:
3634 packages:
3635 - name: ripgrep
3636"#,
3637 )
3638 .unwrap();
3639
3640 let module = load_module(&mod_dir).unwrap();
3641 assert_eq!(module.name, "mymod");
3642 assert_eq!(module.spec.packages.len(), 1);
3643 assert_eq!(module.spec.packages[0].name, "ripgrep");
3644 }
3645
3646 #[test]
3651 fn resolve_package_deny_skips_manager() {
3652 let brew = MockManager::new("brew").with_package("ripgrep", "14.1.0");
3653 let apt = MockManager::new("apt").with_package("ripgrep", "13.0.0");
3654 let managers = make_manager_map(&[("brew", &brew), ("apt", &apt)]);
3655 let platform = linux_ubuntu_platform();
3656
3657 let entry = crate::config::ModulePackageEntry {
3658 name: "ripgrep".into(),
3659 min_version: None,
3660 prefer: vec!["brew".into(), "apt".into()],
3661 aliases: HashMap::new(),
3662 script: None,
3663 deny: vec!["brew".into()],
3664 platforms: vec![],
3665 };
3666
3667 let result = resolve_package(&entry, "test", &platform, &managers)
3668 .unwrap()
3669 .unwrap();
3670 assert_eq!(result.manager, "apt");
3672 }
3673
3674 #[test]
3675 fn resolve_package_platform_filter_skips() {
3676 let brew = MockManager::new("brew").with_package("ripgrep", "14.1.0");
3677 let managers = make_manager_map(&[("brew", &brew)]);
3678 let platform = linux_ubuntu_platform();
3679
3680 let entry = crate::config::ModulePackageEntry {
3681 name: "ripgrep".into(),
3682 min_version: None,
3683 prefer: vec!["brew".into()],
3684 aliases: HashMap::new(),
3685 script: None,
3686 deny: vec![],
3687 platforms: vec!["macos".to_string()], };
3689
3690 let result = resolve_package(&entry, "test", &platform, &managers).unwrap();
3692 assert!(result.is_none());
3693 }
3694
3695 #[test]
3696 fn resolve_package_script_manager_with_deny() {
3697 let managers: HashMap<String, &dyn PackageManager> = HashMap::new();
3698 let platform = linux_ubuntu_platform();
3699
3700 let entry = crate::config::ModulePackageEntry {
3701 name: "rustup".into(),
3702 min_version: None,
3703 prefer: vec!["script".into()],
3704 aliases: HashMap::new(),
3705 script: Some("curl -sSf https://sh.rustup.rs | sh".into()),
3706 deny: vec![],
3707 platforms: vec![],
3708 };
3709
3710 let result = resolve_package(&entry, "test", &platform, &managers)
3711 .unwrap()
3712 .unwrap();
3713 assert_eq!(result.manager, "script");
3714 assert!(result.script.is_some());
3715 assert_eq!(result.canonical_name, "rustup");
3716 }
3717
3718 #[test]
3719 fn resolve_package_script_no_script_field_errors() {
3720 let managers: HashMap<String, &dyn PackageManager> = HashMap::new();
3721 let platform = linux_ubuntu_platform();
3722
3723 let entry = crate::config::ModulePackageEntry {
3724 name: "tool".into(),
3725 min_version: None,
3726 prefer: vec!["script".into()],
3727 aliases: HashMap::new(),
3728 script: None, deny: vec![],
3730 platforms: vec![],
3731 };
3732
3733 let result = resolve_package(&entry, "test", &platform, &managers);
3734 assert!(result.is_err());
3735 assert!(result.unwrap_err().to_string().contains("script"));
3736 }
3737
3738 #[test]
3743 fn git_cache_dir_uses_hash_prefix() {
3744 let base = Path::new("/tmp/cache");
3745 let dir = git_cache_dir(base, "https://github.com/user/repo.git");
3746 assert!(dir.starts_with("/tmp/cache"));
3747 let dirname = dir.file_name().unwrap().to_str().unwrap();
3749 assert_eq!(dirname.len(), 32);
3750 }
3751
3752 #[test]
3757 fn parse_git_source_ref_and_subdir_combined() {
3758 let src = parse_git_source("https://github.com/user/repo.git?ref=dev//nvim").unwrap();
3759 assert_eq!(src.repo_url, "https://github.com/user/repo.git");
3760 assert_eq!(src.git_ref, Some("dev".into()));
3761 assert_eq!(src.subdir, Some("nvim".into()));
3762 assert_eq!(src.tag, None);
3763 }
3764
3765 #[test]
3766 fn parse_git_source_no_git_extension_with_tag() {
3767 let src = parse_git_source("https://github.com/user/repo@v1.0").unwrap();
3768 assert_eq!(src.repo_url, "https://github.com/user/repo");
3769 assert_eq!(src.tag, Some("v1.0".into()));
3770 }
3771
3772 #[test]
3775 fn resolve_module_files_local_relative() {
3776 let dir = tempfile::tempdir().unwrap();
3777 let mod_dir = dir.path().join("modules").join("mymod");
3778 std::fs::create_dir_all(&mod_dir).unwrap();
3779 std::fs::write(mod_dir.join("vimrc"), "set nocompat").unwrap();
3780
3781 let module = LoadedModule {
3782 name: "mymod".into(),
3783 spec: ModuleSpec {
3784 files: vec![ModuleFileEntry {
3785 source: "vimrc".into(),
3786 target: "/tmp/test-target/.vimrc".into(),
3787 strategy: None,
3788 private: false,
3789 encryption: None,
3790 }],
3791 ..Default::default()
3792 },
3793 dir: mod_dir.clone(),
3794 };
3795
3796 let printer = test_printer();
3797 let cache_base = dir.path().join("cache");
3798 let resolved = resolve_module_files(&module, &cache_base, &printer).unwrap();
3799
3800 assert_eq!(resolved.len(), 1);
3801 assert_eq!(resolved[0].source, mod_dir.join("vimrc"));
3802 assert_eq!(resolved[0].target, PathBuf::from("/tmp/test-target/.vimrc"));
3803 assert!(!resolved[0].is_git_source);
3804 assert!(resolved[0].strategy.is_none());
3805 assert!(resolved[0].encryption.is_none());
3806 }
3807
3808 #[test]
3809 fn resolve_module_files_path_traversal_rejected() {
3810 let dir = tempfile::tempdir().unwrap();
3811 let mod_dir = dir.path().join("modules").join("evil");
3812 std::fs::create_dir_all(&mod_dir).unwrap();
3813
3814 let module = LoadedModule {
3815 name: "evil".into(),
3816 spec: ModuleSpec {
3817 files: vec![ModuleFileEntry {
3818 source: "../../../etc/passwd".into(),
3819 target: "/tmp/stolen".into(),
3820 strategy: None,
3821 private: false,
3822 encryption: None,
3823 }],
3824 ..Default::default()
3825 },
3826 dir: mod_dir,
3827 };
3828
3829 let printer = test_printer();
3830 let cache_base = dir.path().join("cache");
3831 let result = resolve_module_files(&module, &cache_base, &printer);
3832
3833 assert!(result.is_err(), "path traversal should be rejected");
3834 let err = result.unwrap_err().to_string();
3835 assert!(
3836 err.contains("traversal"),
3837 "error should mention traversal: {err}"
3838 );
3839 }
3840
3841 #[test]
3842 fn resolve_module_files_multiple_files() {
3843 let dir = tempfile::tempdir().unwrap();
3844 let mod_dir = dir.path().join("modules").join("multi");
3845 std::fs::create_dir_all(&mod_dir).unwrap();
3846 std::fs::write(mod_dir.join("bashrc"), "# bashrc").unwrap();
3847 std::fs::write(mod_dir.join("zshrc"), "# zshrc").unwrap();
3848
3849 let module = LoadedModule {
3850 name: "multi".into(),
3851 spec: ModuleSpec {
3852 files: vec![
3853 ModuleFileEntry {
3854 source: "bashrc".into(),
3855 target: "/tmp/test-resolve/.bashrc".into(),
3856 strategy: Some(crate::config::FileStrategy::Copy),
3857 private: false,
3858 encryption: None,
3859 },
3860 ModuleFileEntry {
3861 source: "zshrc".into(),
3862 target: "/tmp/test-resolve/.zshrc".into(),
3863 strategy: Some(crate::config::FileStrategy::Symlink),
3864 private: false,
3865 encryption: None,
3866 },
3867 ],
3868 ..Default::default()
3869 },
3870 dir: mod_dir.clone(),
3871 };
3872
3873 let printer = test_printer();
3874 let cache_base = dir.path().join("cache");
3875 let resolved = resolve_module_files(&module, &cache_base, &printer).unwrap();
3876
3877 assert_eq!(resolved.len(), 2);
3878 assert_eq!(resolved[0].source, mod_dir.join("bashrc"));
3879 assert_eq!(
3880 resolved[0].strategy,
3881 Some(crate::config::FileStrategy::Copy)
3882 );
3883 assert_eq!(resolved[1].source, mod_dir.join("zshrc"));
3884 assert_eq!(
3885 resolved[1].strategy,
3886 Some(crate::config::FileStrategy::Symlink)
3887 );
3888 }
3889
3890 #[test]
3891 fn resolve_module_files_empty_spec() {
3892 let dir = tempfile::tempdir().unwrap();
3893 let mod_dir = dir.path().join("modules").join("empty");
3894 std::fs::create_dir_all(&mod_dir).unwrap();
3895
3896 let module = LoadedModule {
3897 name: "empty".into(),
3898 spec: ModuleSpec::default(),
3899 dir: mod_dir,
3900 };
3901
3902 let printer = test_printer();
3903 let cache_base = dir.path().join("cache");
3904 let resolved = resolve_module_files(&module, &cache_base, &printer).unwrap();
3905 assert!(
3906 resolved.is_empty(),
3907 "module with no files should resolve to empty list"
3908 );
3909 }
3910
3911 #[test]
3912 fn resolve_module_files_symlink_escape_rejected() {
3913 let dir = tempfile::tempdir().unwrap();
3914 let mod_dir = dir.path().join("modules").join("tricky");
3915 std::fs::create_dir_all(&mod_dir).unwrap();
3916
3917 let outside_file = dir.path().join("outside.txt");
3919 std::fs::write(&outside_file, "escaped!").unwrap();
3920 #[cfg(unix)]
3921 std::os::unix::fs::symlink(&outside_file, mod_dir.join("escape.txt")).unwrap();
3922 #[cfg(windows)]
3923 std::os::windows::fs::symlink_file(&outside_file, mod_dir.join("escape.txt")).unwrap();
3924
3925 let module = LoadedModule {
3926 name: "tricky".into(),
3927 spec: ModuleSpec {
3928 files: vec![ModuleFileEntry {
3929 source: "escape.txt".into(),
3930 target: "/tmp/test-tricky/out".into(),
3931 strategy: None,
3932 private: false,
3933 encryption: None,
3934 }],
3935 ..Default::default()
3936 },
3937 dir: mod_dir,
3938 };
3939
3940 let printer = test_printer();
3941 let cache_base = dir.path().join("cache");
3942 let result = resolve_module_files(&module, &cache_base, &printer);
3943 assert!(
3944 result.is_err(),
3945 "symlink escaping module directory should be rejected"
3946 );
3947 let err = result.unwrap_err().to_string();
3948 assert!(
3949 err.contains("outside"),
3950 "error should mention resolving outside: {err}"
3951 );
3952 }
3953
3954 #[test]
3957 fn dependency_order_empty_request() {
3958 let modules = make_test_modules(&[("a", &[])]);
3959 let order = resolve_dependency_order(&[], &modules).unwrap();
3960 assert!(order.is_empty(), "empty request should yield empty order");
3961 }
3962
3963 #[test]
3964 fn dependency_order_deduplicated_request() {
3965 let modules = make_test_modules(&[("a", &[])]);
3966 let order = resolve_dependency_order(&["a".into(), "a".into()], &modules).unwrap();
3967 assert_eq!(
3968 order,
3969 vec!["a"],
3970 "duplicate requests should be deduplicated"
3971 );
3972 }
3973
3974 #[test]
3975 fn dependency_order_request_includes_transitive_dep() {
3976 let modules = make_test_modules(&[("base", &[]), ("top", &["base"])]);
3978 let order = resolve_dependency_order(&["top".into(), "base".into()], &modules).unwrap();
3979 assert_eq!(order, vec!["base", "top"]);
3980 }
3981
3982 #[test]
3983 fn dependency_order_independent_subgraphs() {
3984 let modules =
3985 make_test_modules(&[("a1", &[]), ("a2", &["a1"]), ("b1", &[]), ("b2", &["b1"])]);
3986 let order = resolve_dependency_order(&["a2".into(), "b2".into()], &modules).unwrap();
3987 assert_eq!(order.len(), 4);
3988 let pos_a1 = order.iter().position(|n| n == "a1").unwrap();
3990 let pos_a2 = order.iter().position(|n| n == "a2").unwrap();
3991 let pos_b1 = order.iter().position(|n| n == "b1").unwrap();
3992 let pos_b2 = order.iter().position(|n| n == "b2").unwrap();
3993 assert!(pos_a1 < pos_a2, "a1 must come before a2");
3994 assert!(pos_b1 < pos_b2, "b1 must come before b2");
3995 }
3996
3997 #[test]
3998 fn dependency_order_deep_chain_within_limit() {
3999 let names: Vec<String> = (0..50).map(|i| format!("mod{i:03}")).collect();
4001 let mut modules = HashMap::new();
4002 for (i, name) in names.iter().enumerate() {
4003 let deps = if i > 0 {
4004 vec![names[i - 1].clone()]
4005 } else {
4006 vec![]
4007 };
4008 modules.insert(
4009 name.clone(),
4010 LoadedModule {
4011 name: name.clone(),
4012 spec: ModuleSpec {
4013 depends: deps,
4014 ..Default::default()
4015 },
4016 dir: PathBuf::from(format!("/fake/{name}")),
4017 },
4018 );
4019 }
4020 let order = resolve_dependency_order(&[names.last().unwrap().clone()], &modules).unwrap();
4021 assert_eq!(order.len(), 50);
4022 assert_eq!(order[0], "mod000");
4023 assert_eq!(*order.last().unwrap(), "mod049");
4024 }
4025
4026 #[test]
4027 fn dependency_order_exceeds_depth_limit() {
4028 let names: Vec<String> = (0..52).map(|i| format!("deep{i:03}")).collect();
4030 let mut modules = HashMap::new();
4031 for (i, name) in names.iter().enumerate() {
4032 let deps = if i > 0 {
4033 vec![names[i - 1].clone()]
4034 } else {
4035 vec![]
4036 };
4037 modules.insert(
4038 name.clone(),
4039 LoadedModule {
4040 name: name.clone(),
4041 spec: ModuleSpec {
4042 depends: deps,
4043 ..Default::default()
4044 },
4045 dir: PathBuf::from(format!("/fake/{name}")),
4046 },
4047 );
4048 }
4049 let result = resolve_dependency_order(&[names.last().unwrap().clone()], &modules);
4050 assert!(result.is_err(), "chain exceeding depth limit should fail");
4051 let err = result.unwrap_err().to_string();
4052 assert!(
4053 err.contains("depth") || err.contains("cycle"),
4054 "error should mention depth: {err}"
4055 );
4056 }
4057
4058 #[test]
4061 fn load_all_modules_local_only() {
4062 let dir = tempfile::tempdir().unwrap();
4063 let mod_dir = dir.path().join("modules").join("shell");
4064 std::fs::create_dir_all(&mod_dir).unwrap();
4065 std::fs::write(
4066 mod_dir.join("module.yaml"),
4067 r#"
4068apiVersion: cfgd.io/v1alpha1
4069kind: Module
4070metadata:
4071 name: shell
4072spec:
4073 packages:
4074 - name: zsh
4075"#,
4076 )
4077 .unwrap();
4078
4079 let cache_base = dir.path().join("cache");
4080 std::fs::create_dir_all(&cache_base).unwrap();
4081 let printer = test_printer();
4082
4083 let modules = load_all_modules(dir.path(), &cache_base, &printer).unwrap();
4084 assert_eq!(modules.len(), 1);
4085 assert!(modules.contains_key("shell"));
4086 assert_eq!(modules["shell"].spec.packages.len(), 1);
4087 assert_eq!(modules["shell"].spec.packages[0].name, "zsh");
4088 }
4089
4090 #[test]
4091 fn load_all_modules_with_lockfile_no_cache() {
4092 let dir = tempfile::tempdir().unwrap();
4093 let cache_base = dir.path().join("cache");
4094 std::fs::create_dir_all(&cache_base).unwrap();
4095
4096 std::fs::write(
4098 dir.path().join("modules.lock"),
4099 r#"
4100apiVersion: cfgd.io/v1alpha1
4101kind: ModuleLockfile
4102entries:
4103 - name: remote-mod
4104 source: "https://github.com/example/modules.git//remote-mod"
4105 commit: "abc123"
4106 contentHash: "sha256:deadbeef"
4107"#,
4108 )
4109 .unwrap();
4110
4111 let printer = test_printer();
4112 let modules = load_all_modules(dir.path(), &cache_base, &printer).unwrap();
4115 assert!(
4117 modules.is_empty(),
4118 "remote module with no cache should not appear in loaded modules"
4119 );
4120 }
4121
4122 #[test]
4125 fn diff_module_specs_file_changes() {
4126 let old = LoadedModule {
4127 name: "mymod".into(),
4128 spec: ModuleSpec {
4129 files: vec![
4130 ModuleFileEntry {
4131 source: "old.conf".into(),
4132 target: "~/.config/app/old.conf".into(),
4133 strategy: None,
4134 private: false,
4135 encryption: None,
4136 },
4137 ModuleFileEntry {
4138 source: "shared.conf".into(),
4139 target: "~/.config/app/shared.conf".into(),
4140 strategy: None,
4141 private: false,
4142 encryption: None,
4143 },
4144 ],
4145 ..Default::default()
4146 },
4147 dir: PathBuf::from("/fake/mymod"),
4148 };
4149 let new = LoadedModule {
4150 name: "mymod".into(),
4151 spec: ModuleSpec {
4152 files: vec![
4153 ModuleFileEntry {
4154 source: "new.conf".into(),
4155 target: "~/.config/app/new.conf".into(),
4156 strategy: None,
4157 private: false,
4158 encryption: None,
4159 },
4160 ModuleFileEntry {
4161 source: "shared.conf".into(),
4162 target: "~/.config/app/shared.conf".into(),
4163 strategy: None,
4164 private: false,
4165 encryption: None,
4166 },
4167 ],
4168 ..Default::default()
4169 },
4170 dir: PathBuf::from("/fake/mymod"),
4171 };
4172
4173 let changes = diff_module_specs(&old, &new);
4174 let joined = changes.join("\n");
4175 assert!(
4176 joined.contains("+ file target: ~/.config/app/new.conf"),
4177 "should show added file: {joined}"
4178 );
4179 assert!(
4180 joined.contains("- file target: ~/.config/app/old.conf"),
4181 "should show removed file: {joined}"
4182 );
4183 assert!(
4185 !joined.contains("shared.conf"),
4186 "unchanged file should not appear: {joined}"
4187 );
4188 }
4189
4190 #[test]
4191 fn diff_module_specs_env_changes_not_tracked() {
4192 let old = LoadedModule {
4195 name: "mymod".into(),
4196 spec: ModuleSpec {
4197 env: vec![crate::config::EnvVar {
4198 name: "OLD".into(),
4199 value: "1".into(),
4200 }],
4201 ..Default::default()
4202 },
4203 dir: PathBuf::from("/fake/mymod"),
4204 };
4205 let new = LoadedModule {
4206 name: "mymod".into(),
4207 spec: ModuleSpec {
4208 env: vec![crate::config::EnvVar {
4209 name: "NEW".into(),
4210 value: "2".into(),
4211 }],
4212 ..Default::default()
4213 },
4214 dir: PathBuf::from("/fake/mymod"),
4215 };
4216
4217 let changes = diff_module_specs(&old, &new);
4218 assert_eq!(
4219 changes,
4220 vec!["(no spec changes)"],
4221 "env-only change should show as no spec changes (env not diffed)"
4222 );
4223 }
4224
4225 #[test]
4230 fn dependency_order_diamond_deterministic_ordering() {
4231 let modules = make_test_modules(&[
4234 ("base", &[]),
4235 ("left", &["base"]),
4236 ("right", &["base"]),
4237 ("top", &["left", "right"]),
4238 ]);
4239 let order = resolve_dependency_order(&["top".into()], &modules).unwrap();
4240 assert_eq!(
4241 order,
4242 vec!["base", "left", "right", "top"],
4243 "diamond should produce deterministic alphabetical ordering of peers"
4244 );
4245 }
4246
4247 #[test]
4248 fn dependency_order_wide_fan_out() {
4249 let modules = make_test_modules(&[
4251 ("leaf_a", &[]),
4252 ("leaf_b", &[]),
4253 ("leaf_c", &[]),
4254 ("leaf_d", &[]),
4255 ("root", &["leaf_a", "leaf_b", "leaf_c", "leaf_d"]),
4256 ]);
4257 let order = resolve_dependency_order(&["root".into()], &modules).unwrap();
4258 assert_eq!(order.len(), 5);
4259 let root_pos = order.iter().position(|n| n == "root").unwrap();
4261 assert_eq!(root_pos, 4, "root should be last");
4262 assert_eq!(
4264 &order[..4],
4265 &["leaf_a", "leaf_b", "leaf_c", "leaf_d"],
4266 "leaves should be sorted alphabetically"
4267 );
4268 }
4269
4270 #[test]
4271 fn dependency_order_multiple_requested_shared_deps_no_duplicates() {
4272 let modules = make_test_modules(&[
4275 ("shared", &[]),
4276 ("a", &["shared"]),
4277 ("b", &["shared"]),
4278 ("c", &["shared"]),
4279 ]);
4280 let order =
4281 resolve_dependency_order(&["a".into(), "b".into(), "c".into()], &modules).unwrap();
4282 assert_eq!(order.len(), 4, "should have 4 modules, no duplicates");
4283 let shared_count = order.iter().filter(|n| n.as_str() == "shared").count();
4284 assert_eq!(shared_count, 1, "shared should appear exactly once");
4285 assert_eq!(order[0], "shared");
4287 }
4288
4289 #[test]
4290 fn dependency_order_missing_dep_error_mentions_both_module_and_dep() {
4291 let modules = make_test_modules(&[("app", &["nonexistent"])]);
4292 let result = resolve_dependency_order(&["app".into()], &modules);
4293 let err = result.unwrap_err().to_string();
4294 assert!(
4295 err.contains("app"),
4296 "error should mention the module: {err}"
4297 );
4298 assert!(
4299 err.contains("nonexistent"),
4300 "error should mention the missing dependency: {err}"
4301 );
4302 assert!(
4303 err.contains("not available"),
4304 "error should use 'not available' phrasing: {err}"
4305 );
4306 }
4307
4308 #[test]
4309 fn dependency_order_not_found_error_message() {
4310 let modules: HashMap<String, LoadedModule> = HashMap::new();
4311 let result = resolve_dependency_order(&["ghost".into()], &modules);
4312 let err = result.unwrap_err().to_string();
4313 assert!(
4314 err.contains("not found"),
4315 "error should say 'not found': {err}"
4316 );
4317 assert!(
4318 err.contains("ghost"),
4319 "error should mention the module name: {err}"
4320 );
4321 }
4322
4323 #[test]
4324 fn dependency_order_cycle_error_lists_cycle_members() {
4325 let modules = make_test_modules(&[("x", &["y"]), ("y", &["z"]), ("z", &["x"])]);
4326 let result = resolve_dependency_order(&["x".into()], &modules);
4327 let err = result.unwrap_err().to_string();
4328 assert!(err.contains("cycle"), "error should mention cycle: {err}");
4329 assert!(
4331 err.contains("x") && err.contains("y") && err.contains("z"),
4332 "error should list all cycle members: {err}"
4333 );
4334 }
4335
4336 #[test]
4337 fn dependency_order_partial_cycle_with_non_cyclic_nodes() {
4338 let modules =
4341 make_test_modules(&[("a", &[]), ("b", &["c"]), ("c", &["b"]), ("d", &["a", "b"])]);
4342 let result = resolve_dependency_order(&["d".into()], &modules);
4343 let err = result.unwrap_err().to_string();
4344 assert!(
4345 err.contains("cycle"),
4346 "should detect the b<->c cycle: {err}"
4347 );
4348 }
4349
4350 #[test]
4351 fn dependency_order_self_dep_mentions_module_name() {
4352 let modules = make_test_modules(&[("selfref", &["selfref"])]);
4353 let result = resolve_dependency_order(&["selfref".into()], &modules);
4354 let err = result.unwrap_err().to_string();
4355 assert!(
4356 err.contains("selfref"),
4357 "self-dependency error should name the module: {err}"
4358 );
4359 }
4360
4361 #[test]
4362 fn dependency_order_complex_dag_preserves_ordering_constraints() {
4363 let modules = make_test_modules(&[
4370 ("a", &[]),
4371 ("b", &["a"]),
4372 ("c", &["a", "b"]),
4373 ("d", &["b"]),
4374 ("e", &["c", "d"]),
4375 ]);
4376 let order = resolve_dependency_order(&["e".into()], &modules).unwrap();
4377 assert_eq!(order.len(), 5);
4378
4379 let pos = |n: &str| order.iter().position(|x| x == n).unwrap();
4381 assert!(pos("a") < pos("b"), "a must come before b");
4382 assert!(pos("a") < pos("c"), "a must come before c");
4383 assert!(pos("b") < pos("c"), "b must come before c");
4384 assert!(pos("b") < pos("d"), "b must come before d");
4385 assert!(pos("c") < pos("e"), "c must come before e");
4386 assert!(pos("d") < pos("e"), "d must come before e");
4387 }
4388
4389 #[test]
4394 fn resolve_module_packages_multiple_packages() {
4395 let brew = MockManager::new("brew")
4396 .with_package("ripgrep", "14.0.0")
4397 .with_package("fd", "9.0.0")
4398 .with_package("bat", "0.24.0");
4399 let managers = make_manager_map(&[("brew", &brew)]);
4400 let platform = macos_platform();
4401
4402 let module = LoadedModule {
4403 name: "tools".into(),
4404 spec: ModuleSpec {
4405 packages: vec![
4406 ModulePackageEntry {
4407 name: "ripgrep".into(),
4408 ..Default::default()
4409 },
4410 ModulePackageEntry {
4411 name: "fd".into(),
4412 ..Default::default()
4413 },
4414 ModulePackageEntry {
4415 name: "bat".into(),
4416 ..Default::default()
4417 },
4418 ],
4419 ..Default::default()
4420 },
4421 dir: PathBuf::from("/fake/tools"),
4422 };
4423
4424 let resolved = resolve_module_packages(&module, &platform, &managers).unwrap();
4425 assert_eq!(resolved.len(), 3);
4426 assert_eq!(resolved[0].canonical_name, "ripgrep");
4427 assert_eq!(resolved[1].canonical_name, "fd");
4428 assert_eq!(resolved[2].canonical_name, "bat");
4429 for pkg in &resolved {
4431 assert_eq!(pkg.manager, "brew");
4432 }
4433 }
4434
4435 #[test]
4436 fn resolve_module_packages_empty_packages() {
4437 let managers: HashMap<String, &dyn PackageManager> = HashMap::new();
4438 let platform = macos_platform();
4439
4440 let module = LoadedModule {
4441 name: "empty".into(),
4442 spec: ModuleSpec::default(),
4443 dir: PathBuf::from("/fake/empty"),
4444 };
4445
4446 let resolved = resolve_module_packages(&module, &platform, &managers).unwrap();
4447 assert!(
4448 resolved.is_empty(),
4449 "module with no packages should resolve to empty"
4450 );
4451 }
4452
4453 #[test]
4454 fn resolve_module_packages_mixed_platforms() {
4455 let apt = MockManager::new("apt")
4456 .with_package("ripgrep", "14.0.0")
4457 .with_package("linux-tool", "1.0.0");
4458 let managers = make_manager_map(&[("apt", &apt)]);
4459 let platform = linux_ubuntu_platform();
4460
4461 let module = LoadedModule {
4462 name: "mixed".into(),
4463 spec: ModuleSpec {
4464 packages: vec![
4465 ModulePackageEntry {
4466 name: "ripgrep".into(),
4467 platforms: vec![], ..Default::default()
4469 },
4470 ModulePackageEntry {
4471 name: "linux-tool".into(),
4472 platforms: vec!["linux".into()],
4473 ..Default::default()
4474 },
4475 ModulePackageEntry {
4476 name: "macos-only".into(),
4477 platforms: vec!["macos".into()],
4478 prefer: vec!["brew".into()],
4479 ..Default::default()
4480 },
4481 ],
4482 ..Default::default()
4483 },
4484 dir: PathBuf::from("/fake/mixed"),
4485 };
4486
4487 let resolved = resolve_module_packages(&module, &platform, &managers).unwrap();
4488 assert_eq!(
4489 resolved.len(),
4490 2,
4491 "macOS-only package should be filtered out on Linux"
4492 );
4493 assert_eq!(resolved[0].canonical_name, "ripgrep");
4494 assert_eq!(resolved[1].canonical_name, "linux-tool");
4495 }
4496
4497 #[test]
4502 fn load_module_oversized_yaml_rejected() {
4503 let dir = tempfile::tempdir().unwrap();
4504 let mod_dir = dir.path().join("huge");
4505 std::fs::create_dir(&mod_dir).unwrap();
4506
4507 let mod_dir = dir.path().join("normal");
4516 std::fs::create_dir(&mod_dir).unwrap();
4517 std::fs::write(
4518 mod_dir.join("module.yaml"),
4519 r#"
4520apiVersion: cfgd.io/v1alpha1
4521kind: Module
4522metadata:
4523 name: normal
4524spec: {}
4525"#,
4526 )
4527 .unwrap();
4528
4529 let module = load_module(&mod_dir).unwrap();
4531 assert_eq!(module.name, "normal");
4532 }
4533
4534 #[test]
4539 fn resolve_profile_module_name_with_tag() {
4540 assert_eq!(
4543 resolve_profile_module_name("community/tmux@v1.0"),
4544 "tmux@v1.0"
4545 );
4546 }
4547
4548 #[test]
4549 fn resolve_profile_module_name_git_url_unchanged() {
4550 let url = "https://github.com/user/repo.git";
4552 assert_eq!(resolve_profile_module_name(url), url);
4553 }
4554
4555 #[test]
4560 fn diff_module_specs_scripts_none_to_some() {
4561 let old = make_loaded_module("test", ModuleSpec::default());
4562 let new_spec = ModuleSpec {
4563 scripts: Some(crate::config::ScriptSpec {
4564 post_apply: vec![crate::config::ScriptEntry::Simple("echo hello".to_string())],
4565 ..Default::default()
4566 }),
4567 ..Default::default()
4568 };
4569 let new = make_loaded_module("test", new_spec);
4570 let changes = diff_module_specs(&old, &new);
4571 assert!(
4572 changes
4573 .iter()
4574 .any(|c| c.contains("+ postApply script: echo hello")),
4575 "should detect added script: {changes:?}"
4576 );
4577 }
4578
4579 #[test]
4580 fn diff_module_specs_scripts_some_to_none() {
4581 let old_spec = ModuleSpec {
4582 scripts: Some(crate::config::ScriptSpec {
4583 post_apply: vec![crate::config::ScriptEntry::Simple(
4584 "echo goodbye".to_string(),
4585 )],
4586 ..Default::default()
4587 }),
4588 ..Default::default()
4589 };
4590 let old = make_loaded_module("test", old_spec);
4591 let new = make_loaded_module("test", ModuleSpec::default());
4592 let changes = diff_module_specs(&old, &new);
4593 assert!(
4594 changes
4595 .iter()
4596 .any(|c| c.contains("- postApply script: echo goodbye")),
4597 "should detect removed script: {changes:?}"
4598 );
4599 }
4600
4601 #[test]
4602 fn diff_module_specs_system_changes_not_tracked() {
4603 let old = make_loaded_module(
4605 "test",
4606 ModuleSpec {
4607 system: [(
4608 "sysctl".to_string(),
4609 serde_yaml::Value::String("old".into()),
4610 )]
4611 .into_iter()
4612 .collect(),
4613 ..Default::default()
4614 },
4615 );
4616 let new = make_loaded_module(
4617 "test",
4618 ModuleSpec {
4619 system: [(
4620 "sysctl".to_string(),
4621 serde_yaml::Value::String("new".into()),
4622 )]
4623 .into_iter()
4624 .collect(),
4625 ..Default::default()
4626 },
4627 );
4628 let changes = diff_module_specs(&old, &new);
4629 assert_eq!(
4630 changes,
4631 vec!["(no spec changes)"],
4632 "system changes are not tracked by diff"
4633 );
4634 }
4635
4636 #[test]
4641 fn extract_registry_name_ssh_scheme_url() {
4642 assert_eq!(
4644 extract_registry_name("ssh://git@github.com/myorg/repo.git"),
4645 None,
4646 "ssh:// URLs should not match the github extraction"
4647 );
4648 }
4649
4650 #[test]
4651 fn extract_registry_name_trailing_slash() {
4652 assert_eq!(
4653 extract_registry_name("https://github.com/myorg/"),
4654 Some("myorg".to_string())
4655 );
4656 }
4657
4658 #[test]
4663 fn resolve_package_bootstrappable_manager() {
4664 let mgr = MockManager::new("cargo").unavailable().bootstrappable();
4665 let managers = make_manager_map(&[("cargo", &mgr)]);
4666 let platform = linux_ubuntu_platform();
4667
4668 let entry = ModulePackageEntry {
4669 name: "ripgrep".into(),
4670 prefer: vec!["cargo".into()],
4671 ..Default::default()
4672 };
4673
4674 let result = resolve_package(&entry, "test", &platform, &managers)
4675 .unwrap()
4676 .unwrap();
4677 assert_eq!(result.manager, "cargo");
4678 assert_eq!(result.canonical_name, "ripgrep");
4679 assert!(
4681 result.version.is_none(),
4682 "bootstrappable manager should not have version"
4683 );
4684 }
4685
4686 #[test]
4691 fn resolve_package_deny_script_still_works() {
4692 let brew = MockManager::new("brew").with_package("tool", "1.0.0");
4694 let managers = make_manager_map(&[("brew", &brew)]);
4695 let platform = macos_platform();
4696
4697 let entry = ModulePackageEntry {
4698 name: "tool".into(),
4699 prefer: vec!["brew".into(), "script".into()],
4700 deny: vec!["brew".into()],
4701 script: Some("install.sh".into()),
4702 ..Default::default()
4703 };
4704
4705 let result = resolve_package(&entry, "test", &platform, &managers)
4706 .unwrap()
4707 .unwrap();
4708 assert_eq!(result.manager, "script", "should fall through to script");
4709 }
4710
4711 #[test]
4712 fn resolve_package_deny_script_also_denied() {
4713 let managers: HashMap<String, &dyn PackageManager> = HashMap::new();
4715 let platform = linux_ubuntu_platform();
4716
4717 let entry = ModulePackageEntry {
4718 name: "tool".into(),
4719 prefer: vec!["script".into()],
4720 deny: vec!["script".into()],
4721 script: Some("install.sh".into()),
4722 ..Default::default()
4723 };
4724
4725 let result = resolve_package(&entry, "test", &platform, &managers);
4726 assert!(
4727 result.is_err(),
4728 "denying script should make package unresolvable"
4729 );
4730 }
4731
4732 #[test]
4737 fn lockfile_multiple_entries_roundtrip() {
4738 let dir = tempfile::tempdir().unwrap();
4739 let lockfile = ModuleLockfile {
4740 modules: vec![
4741 ModuleLockEntry {
4742 name: "nvim".into(),
4743 url: "https://github.com/user/nvim.git@v1.0".into(),
4744 pinned_ref: "v1.0".into(),
4745 commit: "aaa111".into(),
4746 integrity: "sha256:aaaa".into(),
4747 subdir: None,
4748 },
4749 ModuleLockEntry {
4750 name: "tmux".into(),
4751 url: "https://github.com/user/tmux.git@v2.0".into(),
4752 pinned_ref: "v2.0".into(),
4753 commit: "bbb222".into(),
4754 integrity: "sha256:bbbb".into(),
4755 subdir: Some("tmux-config".into()),
4756 },
4757 ModuleLockEntry {
4758 name: "zsh".into(),
4759 url: "https://github.com/user/zsh.git@v3.0".into(),
4760 pinned_ref: "v3.0".into(),
4761 commit: "ccc333".into(),
4762 integrity: "sha256:cccc".into(),
4763 subdir: None,
4764 },
4765 ],
4766 };
4767
4768 save_lockfile(dir.path(), &lockfile).unwrap();
4769 let loaded = load_lockfile(dir.path()).unwrap();
4770
4771 assert_eq!(loaded.modules.len(), 3);
4772 assert_eq!(loaded.modules[0].name, "nvim");
4773 assert_eq!(loaded.modules[1].name, "tmux");
4774 assert_eq!(loaded.modules[1].subdir, Some("tmux-config".into()));
4775 assert_eq!(loaded.modules[2].name, "zsh");
4776 assert_eq!(loaded.modules[2].commit, "ccc333");
4777 }
4778
4779 #[test]
4784 fn hash_module_contents_nested_dirs() {
4785 let dir = tempfile::tempdir().unwrap();
4786 let nested = dir.path().join("config").join("lua").join("plugins");
4787 std::fs::create_dir_all(&nested).unwrap();
4788 std::fs::write(dir.path().join("module.yaml"), "name: test\n").unwrap();
4789 std::fs::write(nested.join("init.lua"), "-- plugins\n").unwrap();
4790 std::fs::write(dir.path().join("config").join("options.lua"), "-- opts\n").unwrap();
4791
4792 let hash = hash_module_contents(dir.path()).unwrap();
4793 assert!(hash.starts_with("sha256:"));
4794
4795 let hash2 = hash_module_contents(dir.path()).unwrap();
4797 assert_eq!(hash, hash2);
4798
4799 std::fs::write(nested.join("extra.lua"), "-- extra\n").unwrap();
4801 let hash3 = hash_module_contents(dir.path()).unwrap();
4802 assert_ne!(hash, hash3, "adding a file should change the hash");
4803 }
4804
4805 #[test]
4810 fn verify_lockfile_integrity_with_subdir() {
4811 let cache_base = tempfile::tempdir().unwrap();
4812 let url = "https://example.com/multi.git@v1.0";
4813 let cache_dir = git_cache_dir(cache_base.path(), "https://example.com/multi.git");
4814 let subdir_path = cache_dir.join("nvim");
4815 std::fs::create_dir_all(&subdir_path).unwrap();
4816 std::fs::write(subdir_path.join("module.yaml"), "test content\n").unwrap();
4817
4818 let actual_integrity = hash_module_contents(&subdir_path).unwrap();
4819
4820 let entry = ModuleLockEntry {
4821 name: "nvim".into(),
4822 url: url.into(),
4823 pinned_ref: "v1.0".into(),
4824 commit: "abc".into(),
4825 integrity: actual_integrity,
4826 subdir: Some("nvim".into()),
4827 };
4828
4829 let result = verify_lockfile_integrity(&entry, cache_base.path());
4830 assert!(
4831 result.is_ok(),
4832 "integrity check with subdir should pass: {:?}",
4833 result.unwrap_err()
4834 );
4835 }
4836
4837 #[test]
4842 fn load_modules_skips_files_in_modules_dir() {
4843 let dir = tempfile::tempdir().unwrap();
4844 let modules_dir = dir.path().join("modules");
4845 std::fs::create_dir_all(&modules_dir).unwrap();
4846 std::fs::write(modules_dir.join("README.md"), "# modules").unwrap();
4848 std::fs::create_dir(modules_dir.join("empty-dir")).unwrap();
4850 let valid = modules_dir.join("valid");
4852 std::fs::create_dir(&valid).unwrap();
4853 std::fs::write(
4854 valid.join("module.yaml"),
4855 r#"
4856apiVersion: cfgd.io/v1alpha1
4857kind: Module
4858metadata:
4859 name: valid
4860spec: {}
4861"#,
4862 )
4863 .unwrap();
4864
4865 let modules = load_modules(dir.path()).unwrap();
4866 assert_eq!(modules.len(), 1, "should only load the valid module");
4867 assert!(modules.contains_key("valid"));
4868 }
4869
4870 #[test]
4875 fn is_git_source_edge_cases() {
4876 assert!(is_git_source("http://github.com/user/repo.git"));
4877 assert!(!is_git_source(""));
4878 assert!(!is_git_source("/absolute/path"));
4879 assert!(!is_git_source("relative/path"));
4880 assert!(is_git_source("ssh://user@host/repo"));
4881 }
4882
4883 #[test]
4888 fn parse_git_source_ssh_scheme_with_tag() {
4889 let src = parse_git_source("ssh://git@github.com/user/repo.git@v1.0").unwrap();
4890 assert_eq!(src.repo_url, "ssh://git@github.com/user/repo.git");
4891 assert_eq!(src.tag, Some("v1.0".into()));
4892 }
4893
4894 #[test]
4895 fn parse_git_source_ssh_scheme_with_subdir() {
4896 let src = parse_git_source("ssh://git@github.com/user/repo.git//config@v2.0").unwrap();
4897 assert_eq!(src.repo_url, "ssh://git@github.com/user/repo.git");
4898 assert_eq!(src.subdir, Some("config".into()));
4899 assert_eq!(src.tag, Some("v2.0".into()));
4900 }
4901
4902 #[test]
4907 fn resolve_subdir_nested_path() {
4908 let base = PathBuf::from("/cache/abc123");
4909 let result =
4910 super::resolve_subdir(base, &Some("configs/nvim".to_string()), "test", "url").unwrap();
4911 assert_eq!(result, PathBuf::from("/cache/abc123/configs/nvim"));
4912 }
4913
4914 #[test]
4919 fn diff_module_specs_prefer_list_change_not_tracked() {
4920 let old = make_loaded_module(
4923 "test",
4924 ModuleSpec {
4925 packages: vec![ModulePackageEntry {
4926 name: "neovim".into(),
4927 prefer: vec!["brew".into()],
4928 ..Default::default()
4929 }],
4930 ..Default::default()
4931 },
4932 );
4933 let new = make_loaded_module(
4934 "test",
4935 ModuleSpec {
4936 packages: vec![ModulePackageEntry {
4937 name: "neovim".into(),
4938 prefer: vec!["apt".into(), "snap".into()],
4939 ..Default::default()
4940 }],
4941 ..Default::default()
4942 },
4943 );
4944 let changes = diff_module_specs(&old, &new);
4945 assert_eq!(
4946 changes,
4947 vec!["(no spec changes)"],
4948 "prefer list changes are not tracked"
4949 );
4950 }
4951
4952 #[test]
4957 fn dependency_order_exceeds_module_count_limit() {
4958 let mut modules = HashMap::new();
4960 let mut requested = Vec::new();
4961 for i in 0..501 {
4962 let name = format!("mod{i:04}");
4963 modules.insert(
4964 name.clone(),
4965 LoadedModule {
4966 name: name.clone(),
4967 spec: ModuleSpec::default(),
4968 dir: PathBuf::from(format!("/fake/{name}")),
4969 },
4970 );
4971 requested.push(name);
4972 }
4973 let result = resolve_dependency_order(&requested, &modules);
4974 assert!(
4975 result.is_err(),
4976 "should fail when exceeding module count limit"
4977 );
4978 let err = result.unwrap_err().to_string();
4979 assert!(
4980 err.contains("500") || err.contains("exceeds"),
4981 "error should mention the limit: {err}"
4982 );
4983 }
4984}