1use 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
17pub fn resolve_package(
32 entry: &ModulePackageEntry,
33 module_name: &str,
34 platform: &Platform,
35 managers: &HashMap<String, &dyn PackageManager>,
36) -> Result<Option<ResolvedPackage>> {
37 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 let candidates: Vec<String> = candidates
50 .into_iter()
51 .filter(|c| !entry.deny.contains(c))
52 .collect();
53
54 for candidate in &candidates {
55 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 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 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
142pub 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
158pub 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 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 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
221pub 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 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 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}