Skip to main content

cfgd_core/modules/
resolve.rs

1//! Package and file resolution — turn LoadedModules into ResolvedModules.
2
3use std::collections::HashMap;
4use std::path::Path;
5
6use crate::config::ModulePackageEntry;
7use crate::errors::{ModuleError, Result};
8use crate::platform::Platform;
9use crate::providers::PackageManager;
10
11use super::git::{fetch_git_source, is_git_source, parse_git_source};
12use super::loader::resolve_dependency_order;
13use super::lockfile::load_all_modules;
14use super::registry::resolve_profile_module_name;
15use super::{LoadedModule, ResolvedFile, ResolvedModule, ResolvedPackage};
16
17// ---------------------------------------------------------------------------
18// Package resolution
19// ---------------------------------------------------------------------------
20
21/// Resolve a single module package entry to a concrete (manager, name, version).
22///
23/// Algorithm:
24/// 0. If `platforms` is non-empty and current platform doesn't match → return None (skipped)
25/// 1. Determine candidate managers: `prefer` list, or `[platform.native_manager()]`
26/// 2. For each candidate:
27///    a. If `"script"` — always available, uses the `script` field as installer
28///    b. Otherwise: check available + alias resolve + min-version check
29/// 3. First satisfying candidate wins
30/// 4. If none satisfies, return error with details
31pub fn resolve_package(
32    entry: &ModulePackageEntry,
33    module_name: &str,
34    platform: &Platform,
35    managers: &HashMap<String, &dyn PackageManager>,
36) -> Result<Option<ResolvedPackage>> {
37    // Platform filter: skip entirely if platforms is non-empty and doesn't match
38    if !platform.matches_any(&entry.platforms) {
39        return Ok(None);
40    }
41
42    let candidates: Vec<String> = if entry.prefer.is_empty() {
43        vec![platform.native_manager().to_string()]
44    } else {
45        entry.prefer.clone()
46    };
47
48    // Filter out denied managers
49    let candidates: Vec<String> = candidates
50        .into_iter()
51        .filter(|c| !entry.deny.contains(c))
52        .collect();
53
54    for candidate in &candidates {
55        // Special "script" manager — always available, uses custom install script
56        if candidate == "script" {
57            let script = entry
58                .script
59                .as_ref()
60                .ok_or_else(|| ModuleError::InvalidSpec {
61                    name: module_name.to_string(),
62                    message: format!(
63                        "package '{}' has 'script' in prefer list but no 'script' field defined",
64                        entry.name
65                    ),
66                })?;
67            return Ok(Some(ResolvedPackage {
68                canonical_name: entry.name.clone(),
69                resolved_name: entry.name.clone(),
70                manager: "script".to_string(),
71                version: None,
72                script: Some(script.clone()),
73            }));
74        }
75
76        let mgr = match managers.get(candidate.as_str()) {
77            Some(m) => *m,
78            None => continue,
79        };
80
81        let bootstrappable = !mgr.is_available() && mgr.can_bootstrap();
82        if !mgr.is_available() && !bootstrappable {
83            continue;
84        }
85
86        let resolved_name = entry
87            .aliases
88            .get(candidate)
89            .cloned()
90            .unwrap_or_else(|| entry.name.clone());
91
92        // If the manager isn't installed yet but can be bootstrapped, resolve
93        // optimistically — we can't query versions until it's installed.
94        if bootstrappable {
95            return Ok(Some(ResolvedPackage {
96                canonical_name: entry.name.clone(),
97                resolved_name,
98                manager: candidate.clone(),
99                version: None,
100                script: None,
101            }));
102        }
103
104        if let Some(ref min_ver) = entry.min_version {
105            match mgr.available_version(&resolved_name) {
106                Ok(Some(ver)) => {
107                    if !crate::version_satisfies(&ver, &format!(">={min_ver}")) {
108                        continue;
109                    }
110                    return Ok(Some(ResolvedPackage {
111                        canonical_name: entry.name.clone(),
112                        resolved_name,
113                        manager: candidate.clone(),
114                        version: Some(ver),
115                        script: None,
116                    }));
117                }
118                Ok(None) => continue,
119                Err(_) => continue,
120            }
121        } else {
122            // No min-version: first available manager wins.
123            let version = mgr.available_version(&resolved_name).ok().flatten();
124            return Ok(Some(ResolvedPackage {
125                canonical_name: entry.name.clone(),
126                resolved_name,
127                manager: candidate.clone(),
128                version,
129                script: None,
130            }));
131        }
132    }
133
134    Err(ModuleError::UnresolvablePackage {
135        module: module_name.to_string(),
136        package: entry.name.clone(),
137        min_version: entry.min_version.clone().unwrap_or_else(|| "any".into()),
138    }
139    .into())
140}
141
142/// Resolve all packages in a module spec.
143/// Packages filtered out by platform constraints are silently skipped.
144pub fn resolve_module_packages(
145    module: &LoadedModule,
146    platform: &Platform,
147    managers: &HashMap<String, &dyn PackageManager>,
148) -> Result<Vec<ResolvedPackage>> {
149    let mut resolved = Vec::new();
150    for entry in &module.spec.packages {
151        if let Some(pkg) = resolve_package(entry, &module.name, platform, managers)? {
152            resolved.push(pkg);
153        }
154    }
155    Ok(resolved)
156}
157
158// ---------------------------------------------------------------------------
159// File resolution
160// ---------------------------------------------------------------------------
161
162/// Resolve module file entries to concrete local paths.
163/// Local sources are resolved relative to the module directory.
164/// Git sources are cloned/fetched to cache and resolved to the local cache path.
165pub fn resolve_module_files(
166    module: &LoadedModule,
167    cache_base: &Path,
168    printer: &crate::output::Printer,
169) -> Result<Vec<ResolvedFile>> {
170    let mut resolved = Vec::new();
171
172    for entry in &module.spec.files {
173        if is_git_source(&entry.source) {
174            let git_src = parse_git_source(&entry.source)?;
175            let local_path = fetch_git_source(&git_src, cache_base, &module.name, printer)?;
176
177            resolved.push(ResolvedFile {
178                source: local_path,
179                target: crate::expand_tilde(Path::new(&entry.target)),
180                is_git_source: true,
181                strategy: entry.strategy,
182                encryption: entry.encryption.clone(),
183            });
184        } else {
185            // Local path — relative to module directory
186            let rel = std::path::Path::new(&entry.source);
187            crate::validate_no_traversal(rel).map_err(|_| ModuleError::InvalidSpec {
188                name: module.name.clone(),
189                message: format!("file source contains path traversal: {}", entry.source),
190            })?;
191            let source = module.dir.join(rel);
192            // Verify the resolved path stays within the module directory
193            // (prevents symlink-based escape from module boundary)
194            if source.exists()
195                && let (Ok(canonical_src), Ok(canonical_dir)) =
196                    (source.canonicalize(), module.dir.canonicalize())
197                && !canonical_src.starts_with(&canonical_dir)
198            {
199                return Err(ModuleError::InvalidSpec {
200                    name: module.name.clone(),
201                    message: format!(
202                        "file source '{}' resolves outside module directory",
203                        entry.source
204                    ),
205                }
206                .into());
207            }
208            resolved.push(ResolvedFile {
209                source,
210                target: crate::expand_tilde(Path::new(&entry.target)),
211                is_git_source: false,
212                strategy: entry.strategy,
213                encryption: entry.encryption.clone(),
214            });
215        }
216    }
217
218    Ok(resolved)
219}
220
221// ---------------------------------------------------------------------------
222// Full module resolution
223// ---------------------------------------------------------------------------
224
225/// Resolve a set of modules: load, sort dependencies, resolve packages and files.
226/// Includes both local modules and remote modules from the lockfile.
227pub fn resolve_modules(
228    requested: &[String],
229    config_dir: &Path,
230    cache_base: &Path,
231    platform: &Platform,
232    managers: &HashMap<String, &dyn PackageManager>,
233    printer: &crate::output::Printer,
234) -> Result<Vec<ResolvedModule>> {
235    let all_modules = load_all_modules(config_dir, cache_base, printer)?;
236
237    // Resolve profile references (e.g., "community/tmux" → "tmux") to actual module names
238    let resolved_names: Vec<String> = requested
239        .iter()
240        .map(|r| resolve_profile_module_name(r).to_string())
241        .collect();
242
243    let order = resolve_dependency_order(&resolved_names, &all_modules)?;
244
245    let mut resolved = Vec::new();
246    for name in &order {
247        let module = &all_modules[name];
248        let packages = resolve_module_packages(module, platform, managers)?;
249        let files = resolve_module_files(module, cache_base, printer)?;
250
251        let scripts = module.spec.scripts.as_ref();
252        let pre_apply_scripts = scripts.map(|s| s.pre_apply.clone()).unwrap_or_default();
253        let post_apply_scripts = scripts.map(|s| s.post_apply.clone()).unwrap_or_default();
254        let pre_reconcile_scripts = scripts.map(|s| s.pre_reconcile.clone()).unwrap_or_default();
255        let post_reconcile_scripts = scripts
256            .map(|s| s.post_reconcile.clone())
257            .unwrap_or_default();
258        let on_change_scripts = scripts.map(|s| s.on_change.clone()).unwrap_or_default();
259
260        // Warn if module defines onDrift scripts — onDrift is profile-level only
261        if let Some(ref scripts) = module.spec.scripts
262            && !scripts.on_drift.is_empty()
263        {
264            tracing::warn!(
265                "module '{}' defines onDrift scripts, but onDrift is profile-level only — these will be ignored",
266                name
267            );
268        }
269
270        resolved.push(ResolvedModule {
271            name: name.clone(),
272            packages,
273            files,
274            env: module.spec.env.clone(),
275            aliases: module.spec.aliases.clone(),
276            system: module.spec.system.clone(),
277            pre_apply_scripts,
278            post_apply_scripts,
279            pre_reconcile_scripts,
280            post_reconcile_scripts,
281            on_change_scripts,
282            depends: module.spec.depends.clone(),
283            dir: module.dir.clone(),
284        });
285    }
286
287    Ok(resolved)
288}