Skip to main content

cfgd_core/modules/
mod.rs

1// Module system — self-contained, portable configuration units
2//
3// Handles module loading, dependency resolution (topological sort),
4// cross-platform package resolution, and git file source management.
5//
6// Dependency rules: depends on config/, errors/, platform/, providers/ (trait only).
7// Must NOT import files/, packages/, secrets/, reconciler/, state/, daemon/.
8
9use 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// ---------------------------------------------------------------------------
20// Resolved types — output of module resolution
21// ---------------------------------------------------------------------------
22
23/// A package resolved to a concrete manager and name.
24#[derive(Debug, Clone, Serialize)]
25pub struct ResolvedPackage {
26    /// Canonical name from the module spec.
27    pub canonical_name: String,
28    /// Actual name for the manager (after alias resolution).
29    pub resolved_name: String,
30    /// Which manager will install it. `"script"` means use a custom install script.
31    pub manager: String,
32    /// Available version (if queried).
33    pub version: Option<String>,
34    /// Install script content (inline or file path). Only set when `manager == "script"`.
35    pub script: Option<String>,
36}
37
38/// A file resolved to a concrete local path.
39#[derive(Debug, Clone, Serialize)]
40pub struct ResolvedFile {
41    /// Local source path (after git clone if needed).
42    pub source: PathBuf,
43    /// Target path on the machine.
44    pub target: PathBuf,
45    /// Whether the source was fetched from git.
46    pub is_git_source: bool,
47    /// Per-file deployment strategy override (from module spec).
48    pub strategy: Option<crate::config::FileStrategy>,
49    /// Encryption settings carried from the module file entry.
50    pub encryption: Option<crate::config::EncryptionSpec>,
51}
52
53/// A fully resolved module — ready for the reconciler.
54#[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    /// System configurator settings declared by this module.
62    /// Deep-merged into the profile system map during reconciliation; module wins on conflict.
63    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    /// Module directory — used as working directory for module scripts.
71    pub dir: PathBuf,
72}
73
74// ---------------------------------------------------------------------------
75// Loaded module — parsed from YAML but not yet resolved
76// ---------------------------------------------------------------------------
77
78/// A module loaded from disk.
79#[derive(Debug, Clone, Serialize)]
80pub struct LoadedModule {
81    pub name: String,
82    pub spec: ModuleSpec,
83    pub dir: PathBuf,
84}
85
86// ---------------------------------------------------------------------------
87// Module loading
88// ---------------------------------------------------------------------------
89
90/// Load all modules from the `modules/` directory under the given config dir.
91/// Returns a map of module name → LoadedModule.
92pub 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
158/// Load a single module from a given directory.
159pub 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    // Reject excessively large module files to prevent memory exhaustion
174    const MAX_MODULE_SIZE: u64 = 10 * 1024 * 1024; // 10 MB
175    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
203// ---------------------------------------------------------------------------
204// Dependency resolution — topological sort with cycle detection
205// ---------------------------------------------------------------------------
206
207/// Resolve module dependencies using topological sort (Kahn's algorithm).
208/// Returns module names in dependency order (leaves first).
209pub fn resolve_dependency_order(
210    requested: &[String],
211    all_modules: &HashMap<String, LoadedModule>,
212) -> Result<Vec<String>> {
213    // Safety limits to prevent DoS from malicious module graphs
214    const MAX_MODULES: usize = 500;
215    const MAX_DEPENDENCY_DEPTH: usize = 50;
216
217    // Collect the full set of modules we need (requested + transitive deps)
218    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    // Build adjacency and in-degree for the needed subset
264    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    // Kahn's algorithm
282    let mut queue: VecDeque<String> = in_degree
283        .iter()
284        .filter(|(_, deg)| **deg == 0)
285        .map(|(name, _)| name.clone())
286        .collect();
287
288    // Sort the initial queue for deterministic output
289    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            // Sort for deterministic output
309            next.sort();
310            queue.extend(next);
311        }
312    }
313
314    if order.len() != needed.len() {
315        // Cycle detected — find the cycle members (use HashSet for O(1) lookup)
316        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
327// ---------------------------------------------------------------------------
328// Package resolution
329// ---------------------------------------------------------------------------
330
331/// Resolve a single module package entry to a concrete (manager, name, version).
332///
333/// Algorithm:
334/// 0. If `platforms` is non-empty and current platform doesn't match → return None (skipped)
335/// 1. Determine candidate managers: `prefer` list, or `[platform.native_manager()]`
336/// 2. For each candidate:
337///    a. If `"script"` — always available, uses the `script` field as installer
338///    b. Otherwise: check available + alias resolve + min-version check
339/// 3. First satisfying candidate wins
340/// 4. If none satisfies, return error with details
341pub fn resolve_package(
342    entry: &ModulePackageEntry,
343    module_name: &str,
344    platform: &Platform,
345    managers: &HashMap<String, &dyn PackageManager>,
346) -> Result<Option<ResolvedPackage>> {
347    // Platform filter: skip entirely if platforms is non-empty and doesn't match
348    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    // Filter out denied managers
359    let candidates: Vec<String> = candidates
360        .into_iter()
361        .filter(|c| !entry.deny.contains(c))
362        .collect();
363
364    for candidate in &candidates {
365        // Special "script" manager — always available, uses custom install script
366        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 the manager isn't installed yet but can be bootstrapped, resolve
403        // optimistically — we can't query versions until it's installed.
404        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            // No min-version: first available manager wins.
433            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
452/// Resolve all packages in a module spec.
453/// Packages filtered out by platform constraints are silently skipped.
454pub 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// ---------------------------------------------------------------------------
469// Git file source URL parsing
470// ---------------------------------------------------------------------------
471
472/// Parsed git file source URL.
473#[derive(Debug, Clone, PartialEq, Eq)]
474pub struct GitSource {
475    /// The repo URL (without tag/ref/subdir suffixes).
476    pub repo_url: String,
477    /// Tag to checkout (from `@tag` suffix).
478    pub tag: Option<String>,
479    /// Branch/ref to checkout (from `?ref=branch` suffix).
480    pub git_ref: Option<String>,
481    /// Subdirectory within the repo (from `//subdir` separator).
482    pub subdir: Option<String>,
483}
484
485/// Check whether a file source string is a git URL (not a local path).
486pub 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
493/// Check if a module name is a `registry/module[@tag]` reference.
494/// Returns true if it contains `/` but is not a git URL.
495pub fn is_registry_ref(name: &str) -> bool {
496    name.contains('/') && !is_git_source(name)
497}
498
499/// Parsed registry/module reference.
500pub struct RegistryRef {
501    pub registry: String,
502    pub module: String,
503    pub tag: Option<String>,
504}
505
506/// Parse `registry/module[@tag]` into components.
507/// Returns `None` if the input doesn't match the expected pattern.
508pub fn parse_registry_ref(input: &str) -> Option<RegistryRef> {
509    // Split on first `/` to get registry and remainder
510    let (registry, remainder) = input.split_once('/')?;
511    if registry.is_empty() || remainder.is_empty() {
512        return None;
513    }
514
515    // Split remainder on `@` for optional tag
516    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, // empty module or tag
519        None => (remainder.to_string(), None),
520    };
521
522    Some(RegistryRef {
523        registry: registry.to_string(),
524        module,
525        tag,
526    })
527}
528
529/// Resolve a profile module reference to its lookup name.
530///
531/// Profiles can reference modules as:
532/// - `tmux` — local module (returns `"tmux"`)
533/// - `community/tmux` — remote module from registry (returns `"tmux"`)
534///
535/// The returned name is what to look up in the loaded modules HashMap.
536pub 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
547/// Parse a git file source URL into its components.
548///
549/// Supports:
550/// - `https://github.com/user/repo.git` — plain clone
551/// - `https://github.com/user/repo.git@v2.1.0` — pin to tag
552/// - `https://github.com/user/repo.git?ref=dev` — track branch
553/// - `https://github.com/user/repo.git//subdir` — subdirectory
554/// - `https://github.com/user/repo.git//subdir@v2.1.0` — subdir at tag
555/// - `git@github.com:user/repo.git@v2.1.0` — SSH with tag
556pub 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    // Extract ?ref=... (must be done before @tag extraction since ? is unambiguous)
571    // Stop at // (subdir separator) so ?ref=dev//subdir works correctly
572    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    // Extract //subdir (and possibly @tag after the subdir)
582    // Skip the :// scheme prefix when looking for // path separator
583    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        // The subdir part may have @tag
590        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        // No subdir — check for @tag on the URL itself
598        // For SSH URLs like git@github.com:user/repo.git@v2.1.0,
599        // we need to find the @tag *after* the .git suffix
600        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            // No .git in URL — look for last @ that isn't part of the protocol.
608            // For https/http/ssh://, skip past ://
609            // For git@, skip past the first @
610            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
630/// Compute the cache directory for a git source URL.
631/// Uses SHA-256 hash of the repo URL for uniqueness.
632pub 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
637/// Default cache directory for module git sources.
638pub 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
647/// Resolve optional subdir within a cache directory with traversal validation.
648fn 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
669// ---------------------------------------------------------------------------
670// Git clone / fetch operations
671// ---------------------------------------------------------------------------
672
673/// Clone or fetch a git source to the cache, returning the local path.
674///
675/// If the repo is already cached, fetches updates. Otherwise, clones.
676/// Checks out the specified tag/ref if provided.
677pub 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
696/// Open a git2 repo with a consistent error mapping.
697fn 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
708/// Build fetch options with SSH credential callback.
709fn 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    // Try git CLI first with live progress output.
732    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    // Clean up partial clone before libgit2 retry.
744    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    // Fall back to libgit2 with spinner.
750    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    // Try git CLI first with live progress output.
774    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    // Fall back to libgit2 with spinner.
786    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        // No specific ref — stay on default branch
825        return Ok(());
826    };
827
828    // Try as a tag first, then as a branch
829    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    // Peel to commit
840    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
865// ---------------------------------------------------------------------------
866// File resolution
867// ---------------------------------------------------------------------------
868
869/// Resolve module file entries to concrete local paths.
870/// Local sources are resolved relative to the module directory.
871/// Git sources are cloned/fetched to cache and resolved to the local cache path.
872pub 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            // Local path — relative to module directory
893            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            // Verify the resolved path stays within the module directory
900            // (prevents symlink-based escape from module boundary)
901            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
928// ---------------------------------------------------------------------------
929// Full module resolution
930// ---------------------------------------------------------------------------
931
932/// Resolve a set of modules: load, sort dependencies, resolve packages and files.
933/// Includes both local modules and remote modules from the lockfile.
934pub 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    // Resolve profile references (e.g., "community/tmux" → "tmux") to actual module names
945    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        // Warn if module defines onDrift scripts — onDrift is profile-level only
968        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
997// ---------------------------------------------------------------------------
998// Module lockfile — tracking remote modules with integrity
999// ---------------------------------------------------------------------------
1000
1001use crate::config::{ModuleLockEntry, ModuleLockfile, ModuleRegistryEntry};
1002
1003/// Load the module lockfile from `<config_dir>/modules.lock`.
1004/// Returns an empty lockfile if the file does not exist.
1005pub 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
1017/// Save the module lockfile to `<config_dir>/modules.lock`.
1018/// Uses `atomic_write_str` (temp file + rename) to prevent corruption.
1019pub 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
1028/// Compute SHA-256 integrity hash of a module directory's contents.
1029/// Hashes file paths (relative to module dir) and their contents, sorted for determinism.
1030pub 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        // Skip .git directory
1060        if path.file_name().is_some_and(|n| n == ".git") {
1061            continue;
1062        }
1063        // Skip symlinks — only hash real files to avoid infinite recursion
1064        // and to avoid hashing files outside the module tree
1065        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/// Result of fetching a remote module — module + lockfile metadata.
1085#[derive(Debug, Clone)]
1086pub struct FetchedRemoteModule {
1087    pub module: LoadedModule,
1088    pub commit: String,
1089    pub integrity: String,
1090}
1091
1092/// Fetch a remote module from a git URL.
1093///
1094/// Validates that the URL has a pinned ref (tag or commit SHA).
1095/// Branches are rejected for security (upstream push = code execution).
1096pub 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    // Enforce pinned ref for remote modules — only tags (which may be semver tags or
1104    // commit SHAs) are allowed. Branch refs (?ref=main) are rejected because upstream
1105    // pushes would silently change the code that gets executed.
1106    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    // The repo root is the cache dir (before subdir), we need it for commit hash
1122    let repo_dir = git_cache_dir(cache_base, &git_src.repo_url);
1123    let commit = get_head_commit_sha(&repo_dir)?;
1124
1125    // Load the module from the fetched path
1126    let module = load_module(&local_path)?;
1127
1128    // Compute integrity hash
1129    let integrity = hash_module_contents(&local_path)?;
1130
1131    Ok(FetchedRemoteModule {
1132        module,
1133        commit,
1134        integrity,
1135    })
1136}
1137
1138/// Get the HEAD commit SHA from a git repo.
1139pub 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
1157/// Verify the integrity of a locked remote module against its lockfile entry.
1158pub 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
1189/// Load remote modules from the lockfile, fetching if needed, and merge
1190/// them into the given modules map.
1191pub 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        // Skip if a local module with the same name already exists (local wins)
1201        if modules.contains_key(&entry.name) {
1202            continue;
1203        }
1204
1205        let git_src = parse_git_source(&entry.url)?;
1206
1207        // Build a GitSource with the pinned ref
1208        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        // Fetch to cache (no-op if already present at correct ref)
1216        let local_path = fetch_git_source(&pinned_src, cache_base, &entry.name, printer)?;
1217
1218        // Verify integrity
1219        verify_lockfile_integrity(entry, cache_base)?;
1220
1221        // Load the module
1222        let module = load_module(&local_path)?;
1223        modules.insert(entry.name.clone(), module);
1224    }
1225
1226    Ok(())
1227}
1228
1229/// Load all modules: local modules from disk + remote locked modules.
1230pub 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/// Signature status for a git tag.
1241#[derive(Debug, Clone, PartialEq, Eq)]
1242pub enum TagSignatureStatus {
1243    /// Lightweight tag — cannot carry a signature.
1244    LightweightTag,
1245    /// Annotated tag with no signature.
1246    Unsigned,
1247    /// Annotated tag with a GPG/SSH signature present.
1248    SignaturePresent,
1249    /// Tag not found.
1250    TagNotFound,
1251}
1252
1253/// Check whether a git tag has a GPG/SSH signature.
1254///
1255/// Detects signature presence via git2 (no shell-out required).
1256/// Full GPG verification (cryptographic check) requires `git tag -v` which
1257/// calls `gpg`; the CLI layer can do that if desired.
1258pub 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// ---------------------------------------------------------------------------
1290// Module registries — git repos with prescribed directory structure
1291// ---------------------------------------------------------------------------
1292
1293/// A discovered module within a registry repo.
1294#[derive(Debug, Clone)]
1295pub struct RegistryModule {
1296    /// Module name (directory name under `modules/`).
1297    pub name: String,
1298    /// Description from the module's `module.yaml` metadata.
1299    pub description: String,
1300    /// Registry name (alias) this module belongs to.
1301    pub registry: String,
1302    /// Available per-module tags (`<module>/v1.0.0` format) in the repo.
1303    pub tags: Vec<String>,
1304}
1305
1306/// Extract the default registry name from a GitHub URL.
1307/// `https://github.com/cfgd-community/modules.git` → `cfgd-community`
1308pub fn extract_registry_name(url: &str) -> Option<String> {
1309    // Handle https://github.com/org/repo(.git)
1310    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    // Handle git@github.com:org/repo(.git)
1317    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
1323/// Fetch a module registry repo and discover available modules.
1324///
1325/// Scans the `modules/` directory for subdirectories containing `module.yaml`.
1326/// Also collects per-module tags (matching `<module>/v*` pattern).
1327pub 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, &registry.url);
1340
1341    // Clone or fetch
1342    if cache_dir.join(".git").exists() || cache_dir.join("HEAD").exists() {
1343        fetch_existing_repo(&cache_dir, &git_src, &registry.name, printer)?;
1344    } else {
1345        clone_repo(&cache_dir, &git_src, &registry.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    // Collect per-module tags from the repo
1358    let module_tags = list_module_tags(&cache_dir, &registry.name)?;
1359
1360    // Scan modules/ for module directories
1361    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        // Read description from module.yaml metadata
1379        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        // Collect tags for this module
1386        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
1400/// List per-module tags from a source repo.
1401/// Tags follow the `<module>/<version>` convention (e.g., `tmux/v1.0.0`).
1402/// Returns a map of module_name → sorted list of version tags.
1403fn 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    // Sort each module's tags (best-effort semver sort, falling back to string sort)
1425    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
1439/// Find the latest version for a module in a registry repo.
1440/// Registry repo tags follow `<module>/<version>` convention; returns only the version part.
1441pub 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, &registry.url);
1447    let tags = list_module_tags(&cache_dir, &registry.name)?;
1448    Ok(tags.get(module_name).and_then(|t| t.last()).cloned())
1449}
1450
1451/// Diff two module specs, returning a human-readable summary of changes.
1452pub fn diff_module_specs(old: &LoadedModule, new: &LoadedModule) -> Vec<String> {
1453    let mut changes = Vec::new();
1454
1455    // Dependencies
1456    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    // Packages
1466    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    // Check for version constraint changes on existing packages
1476    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    // Files
1490    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    // Scripts
1500    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// ---------------------------------------------------------------------------
1529// Tests
1530// ---------------------------------------------------------------------------
1531
1532#[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    // --- Module loading tests ---
1543
1544    #[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        // No modules/ subdirectory
1555        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    // --- Dependency resolution tests ---
1650
1651    #[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        // base must come first, then left and right (alphabetical among peers), then top
1675        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    // --- Package resolution tests ---
1725
1726    #[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        // brew is unavailable, so snap should be tried next
1772        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"); // alias applied
1777        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        // apt has 0.6.1 which is < 0.9, so snap (0.10.2) should be chosen
1800        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(); // platform doesn't matter for prefer-based resolution
1865
1866        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        // brew not in managers map → unresolvable
1957        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    // --- Git URL parsing tests ---
1970
1971    #[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    // --- Git cache dir tests ---
2055
2056    #[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    // --- File resolution tests ---
2073
2074    #[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    // --- Full resolution test with filesystem ---
2109
2110    #[test]
2111    fn full_module_resolution() {
2112        let dir = tempfile::tempdir().unwrap();
2113
2114        // Create two modules: node (leaf) and nvim (depends on node)
2115        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        // node should come before nvim
2176        assert_eq!(resolved.len(), 2);
2177        assert_eq!(resolved[0].name, "node");
2178        assert_eq!(resolved[1].name, "nvim");
2179
2180        // node packages
2181        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"); // alias
2184        assert_eq!(resolved[0].packages[0].manager, "brew");
2185
2186        // nvim packages
2187        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        // nvim scripts
2192        assert_eq!(resolved[1].post_apply_scripts.len(), 1);
2193    }
2194
2195    // --- Module YAML parsing tests ---
2196
2197    #[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    // --- Profile modules field test ---
2262
2263    #[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    // --- Script package resolution tests ---
2281
2282    #[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        // brew is unavailable, script should be chosen as fallback
2313        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        // When script is first in prefer, it wins even if a manager is available
2337        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, // script field missing!
2368            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    // --- Platform filtering tests ---
2383
2384    #[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()], // macos only
2419        };
2420
2421        // On Linux, this should be skipped (None), not an error
2422        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![], // empty = all platforms
2483        };
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![], // all platforms
2507                    },
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()], // linux only
2516                    },
2517                ],
2518                ..Default::default()
2519            },
2520            dir: PathBuf::from("/fake/test"),
2521        };
2522
2523        let resolved = resolve_module_packages(&module, &platform, &managers).unwrap();
2524        // Only ripgrep should be resolved; apt-only-tool is filtered out on macOS
2525        assert_eq!(resolved.len(), 1);
2526        assert_eq!(resolved[0].canonical_name, "ripgrep");
2527    }
2528
2529    // --- Script + platform YAML parsing tests ---
2530
2531    #[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    // --- Lockfile tests ---
2566
2567    #[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    // --- Content hashing tests ---
2620
2621    #[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        // Remove .git and rehash — should be the same
2660        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    // --- Integrity verification tests ---
2667
2668    #[test]
2669    fn verify_lockfile_integrity_success() {
2670        let dir = tempfile::tempdir().unwrap();
2671
2672        // Build a fake lock entry that points to an "http" URL whose SHA hash
2673        // maps to a predictable cache dir
2674        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        // Create content in the expected cache dir
2677        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        // Tamper with file and verify integrity now fails
2699        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    // --- Module diff tests ---
2732
2733    #[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        // Should detect: +dep2, +pkg3, -pkg2, ~pkg1 version change, +file target, -file target
2843        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    // --- Module registry tests ---
2856
2857    #[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    // --- Registry ref parsing tests ---
2882
2883    #[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    // --- Load locked modules into local map ---
2936
2937    #[test]
2938    fn load_locked_modules_merges_with_local() {
2939        let dir = tempfile::tempdir().unwrap();
2940
2941        // Create a local module
2942        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        // Write a lockfile with a remote module that has the same cache structure
2959        // For this test we verify the function doesn't crash on missing cache
2960        // (it will error on missing git repo, which is expected)
2961        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        // Load local modules only — they should work fine
2974        let local = load_modules(dir.path()).unwrap();
2975        assert_eq!(local.len(), 1);
2976        assert!(local.contains_key("local-mod"));
2977    }
2978
2979    // --- Unpinned remote module rejection ---
2980
2981    #[test]
2982    fn fetch_remote_module_rejects_unpinned() {
2983        let dir = tempfile::tempdir().unwrap();
2984        let printer = test_printer();
2985        // URL without @tag or ?ref= — should be rejected
2986        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        // URL with ?ref=main — branches are rejected for security
2998        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    // --- URL parsing: ?ref= + //subdir combined ---
3009
3010    #[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    // --- URL parsing: SSH without .git suffix ---
3030
3031    #[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    // --- hash_module_contents: empty directory ---
3046
3047    #[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        // Empty dir produces a deterministic hash
3053        let hash2 = hash_module_contents(dir.path()).unwrap();
3054        assert_eq!(hash, hash2);
3055    }
3056
3057    // --- hash_module_contents: symlinks skipped ---
3058
3059    #[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        // Remove the symlink and check hash matches (symlink was skipped)
3069        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    // --- diff_module_specs with script changes ---
3076
3077    #[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        // brew is the only/native manager on macOS but is denied, so resolution should fail
3146        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    // -----------------------------------------------------------------------
3167    // extract_registry_name
3168    // -----------------------------------------------------------------------
3169
3170    #[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    // -----------------------------------------------------------------------
3213    // diff_module_specs
3214    // -----------------------------------------------------------------------
3215
3216    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        // Should have: +dep core, -dep base, +pkg neovim, -pkg vim
3361        assert!(
3362            changes.len() >= 4,
3363            "expected at least 4 changes, got {changes:?}"
3364        );
3365    }
3366
3367    // -----------------------------------------------------------------------
3368    // Lockfile load/save
3369    // -----------------------------------------------------------------------
3370
3371    #[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    // -----------------------------------------------------------------------
3434    // hash_module_contents
3435    // -----------------------------------------------------------------------
3436
3437    #[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        // Change .git content and hash again - should be identical
3470        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    // -----------------------------------------------------------------------
3483    // verify_lockfile_integrity
3484    // -----------------------------------------------------------------------
3485
3486    #[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    // -----------------------------------------------------------------------
3507    // is_registry_ref / parse_registry_ref / resolve_profile_module_name
3508    // -----------------------------------------------------------------------
3509
3510    #[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    // -----------------------------------------------------------------------
3577    // resolve_subdir
3578    // -----------------------------------------------------------------------
3579
3580    #[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    // -----------------------------------------------------------------------
3603    // load_module
3604    // -----------------------------------------------------------------------
3605
3606    #[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        // No module.yaml
3612        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    // -----------------------------------------------------------------------
3647    // Package resolution: deny list, platform filtering, script manager
3648    // -----------------------------------------------------------------------
3649
3650    #[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        // brew is denied, so apt should be used
3671        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()], // only macOS
3688        };
3689
3690        // Linux platform should be filtered out
3691        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, // missing!
3729            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    // -----------------------------------------------------------------------
3739    // git_cache_dir
3740    // -----------------------------------------------------------------------
3741
3742    #[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        // Hash should be 32 chars (first 32 of SHA-256)
3748        let dirname = dir.file_name().unwrap().to_str().unwrap();
3749        assert_eq!(dirname.len(), 32);
3750    }
3751
3752    // -----------------------------------------------------------------------
3753    // parse_git_source edge cases
3754    // -----------------------------------------------------------------------
3755
3756    #[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    // --- resolve_module_files tests ---
3773
3774    #[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        // Create a symlink that points outside the module directory
3918        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    // --- dependency_order edge cases ---
3955
3956    #[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        // Requesting both "top" and its transitive dep "base" explicitly
3977        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        // a1 before a2, b1 before b2
3989        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        // Build a chain of 50 modules (MAX_DEPENDENCY_DEPTH = 50)
4000        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        // Build a chain of 52 modules (exceeds MAX_DEPENDENCY_DEPTH = 50)
4029        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    // --- load_all_modules tests ---
4059
4060    #[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        // Create a lockfile referencing a remote module
4097        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        // load_all_modules should succeed but remote module won't be in result
4113        // because its cache directory doesn't exist
4114        let modules = load_all_modules(dir.path(), &cache_base, &printer).unwrap();
4115        // No local modules, remote module cache doesn't exist — empty result
4116        assert!(
4117            modules.is_empty(),
4118            "remote module with no cache should not appear in loaded modules"
4119        );
4120    }
4121
4122    // --- diff_module_specs edge cases ---
4123
4124    #[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        // shared.conf should NOT appear in changes
4184        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        // diff_module_specs currently only tracks deps, packages, files, and scripts.
4193        // Env changes should result in "(no spec changes)" since env isn't diffed.
4194        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    // -----------------------------------------------------------------------
4226    // resolve_dependency_order — additional coverage
4227    // -----------------------------------------------------------------------
4228
4229    #[test]
4230    fn dependency_order_diamond_deterministic_ordering() {
4231        // Diamond: top -> left, right; left -> base; right -> base
4232        // With alphabetical sort, the deterministic order should be: base, left, right, top
4233        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        // Single module depending on many independent leaves
4250        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        // All leaves must come before root
4260        let root_pos = order.iter().position(|n| n == "root").unwrap();
4261        assert_eq!(root_pos, 4, "root should be last");
4262        // Leaves should be sorted alphabetically (deterministic)
4263        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        // a -> shared; b -> shared; c -> shared
4273        // Requesting [a, b, c] should include shared exactly once
4274        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        // shared must be first (only leaf)
4286        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        // All three modules should be mentioned in the error
4330        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        // d -> c -> b -> c (cycle), a -> d (non-cyclic)
4339        // But a is requested, so it should fail on the cycle
4340        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        // A complex DAG:
4364        //   e -> c, d
4365        //   d -> b
4366        //   c -> a, b
4367        //   b -> a
4368        //   a -> (none)
4369        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        // Verify all ordering constraints
4380        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    // -----------------------------------------------------------------------
4390    // resolve_module_packages — additional coverage
4391    // -----------------------------------------------------------------------
4392
4393    #[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        // All should use brew on macOS
4430        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![], // all platforms
4468                        ..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    // -----------------------------------------------------------------------
4498    // load_module — oversized file rejection
4499    // -----------------------------------------------------------------------
4500
4501    #[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        // Create a module.yaml that exceeds 10 MB
4508        // We create a sparse-ish file by writing a moderate amount since we can't
4509        // easily create a 10MB file in tests. Instead, verify the error path
4510        // by checking the error message format against the constant.
4511        //
4512        // The actual size check is: meta.len() > 10 * 1024 * 1024
4513        // We can't practically create a 10MB+ file in a test, but we can verify
4514        // that the check exists and that normal files pass through.
4515        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        // Normal-sized file should load fine
4530        let module = load_module(&mod_dir).unwrap();
4531        assert_eq!(module.name, "normal");
4532    }
4533
4534    // -----------------------------------------------------------------------
4535    // resolve_profile_module_name — registry ref with tag suffix
4536    // -----------------------------------------------------------------------
4537
4538    #[test]
4539    fn resolve_profile_module_name_with_tag() {
4540        // "community/tmux@v1.0" should resolve to "tmux@v1.0"
4541        // (the tag stays, registry prefix is stripped)
4542        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        // git URLs are not registry refs and should pass through unchanged
4551        let url = "https://github.com/user/repo.git";
4552        assert_eq!(resolve_profile_module_name(url), url);
4553    }
4554
4555    // -----------------------------------------------------------------------
4556    // diff_module_specs — scripts None vs Some transitions
4557    // -----------------------------------------------------------------------
4558
4559    #[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        // System map changes are not tracked by diff_module_specs
4604        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    // -----------------------------------------------------------------------
4637    // extract_registry_name — additional URL patterns
4638    // -----------------------------------------------------------------------
4639
4640    #[test]
4641    fn extract_registry_name_ssh_scheme_url() {
4642        // ssh:// URLs are not supported (only git@ and https/http)
4643        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    // -----------------------------------------------------------------------
4659    // resolve_package — bootstrappable manager
4660    // -----------------------------------------------------------------------
4661
4662    #[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        // Bootstrappable managers can't query version yet
4680        assert!(
4681            result.version.is_none(),
4682            "bootstrappable manager should not have version"
4683        );
4684    }
4685
4686    // -----------------------------------------------------------------------
4687    // resolve_package — deny + script interaction
4688    // -----------------------------------------------------------------------
4689
4690    #[test]
4691    fn resolve_package_deny_script_still_works() {
4692        // Denying a regular manager should still allow "script" if it's in prefer
4693        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        // If "script" itself is in the deny list, it should be filtered out
4714        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    // -----------------------------------------------------------------------
4733    // save_lockfile and load_lockfile — multiple entries
4734    // -----------------------------------------------------------------------
4735
4736    #[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    // -----------------------------------------------------------------------
4780    // hash_module_contents — nested directories
4781    // -----------------------------------------------------------------------
4782
4783    #[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        // Verify determinism
4796        let hash2 = hash_module_contents(dir.path()).unwrap();
4797        assert_eq!(hash, hash2);
4798
4799        // Adding a file should change the hash
4800        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    // -----------------------------------------------------------------------
4806    // verify_lockfile_integrity — subdir handling
4807    // -----------------------------------------------------------------------
4808
4809    #[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    // -----------------------------------------------------------------------
4838    // load_modules — skips non-dirs and dirs without module.yaml
4839    // -----------------------------------------------------------------------
4840
4841    #[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        // Create a regular file in modules/ (not a directory)
4847        std::fs::write(modules_dir.join("README.md"), "# modules").unwrap();
4848        // Create a directory without module.yaml
4849        std::fs::create_dir(modules_dir.join("empty-dir")).unwrap();
4850        // Create a valid module
4851        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    // -----------------------------------------------------------------------
4871    // is_git_source — edge cases
4872    // -----------------------------------------------------------------------
4873
4874    #[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    // -----------------------------------------------------------------------
4884    // parse_git_source — ssh:// scheme
4885    // -----------------------------------------------------------------------
4886
4887    #[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    // -----------------------------------------------------------------------
4903    // resolve_subdir — nested subdirectory
4904    // -----------------------------------------------------------------------
4905
4906    #[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    // -----------------------------------------------------------------------
4915    // diff_module_specs — prefer list changes
4916    // -----------------------------------------------------------------------
4917
4918    #[test]
4919    fn diff_module_specs_prefer_list_change_not_tracked() {
4920        // Changes to prefer lists on existing packages are not tracked
4921        // (only name, minVersion are compared)
4922        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    // -----------------------------------------------------------------------
4953    // dependency_order — MAX_MODULES limit
4954    // -----------------------------------------------------------------------
4955
4956    #[test]
4957    fn dependency_order_exceeds_module_count_limit() {
4958        // Build more than MAX_MODULES (500) independent modules
4959        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}