1use super::manifest::PackageDependency;
19use super::remote::{self, VersionInfo};
20
21#[derive(Debug, Clone, PartialEq, Eq)]
23pub struct ResolvedDep {
24 pub name: String,
26 pub namespace: String,
28 pub slug: String,
30 pub version: String,
32 pub artifact_sha256: String,
34}
35
36pub fn resolve_dependencies(
51 deps: &[PackageDependency],
52 root_name: Option<&str>,
53 base: &str,
54 token: Option<&str>,
55) -> Result<Vec<ResolvedDep>, String> {
56 let mut resolved = Vec::new();
57 for dep in deps {
58 if dep.optional {
59 continue;
60 }
61 resolved.push(resolve_one(root_name, dep, base, token)?);
62 }
63 Ok(resolved)
64}
65
66pub fn resolve_one(
76 root_name: Option<&str>,
77 dep: &PackageDependency,
78 base: &str,
79 token: Option<&str>,
80) -> Result<ResolvedDep, String> {
81 let Some(remote_ref) = remote::parse_remote_ref(&dep.name) else {
82 return Err(format!(
83 "dependency `{}` is not a scoped @ns/name reference — unresolvable",
84 dep.name
85 ));
86 };
87 if let Some(root) = root_name
88 && dep.name.trim_start_matches('@') == root.trim_start_matches('@')
89 {
90 return Err(format!(
91 "package depends on itself (`{}`) — refused",
92 dep.name
93 ));
94 }
95 let req = parse_version_req(&dep.version_req)
96 .map_err(|e| format!("dependency `{}`: {e}", dep.name))?;
97
98 let versions = remote::fetch_versions(base, &remote_ref.namespace, &remote_ref.name, token)
99 .map_err(|e| format!("dependency `{}`: {e}", dep.name))?;
100 let best = pick_highest_match(&versions, &req).ok_or_else(|| {
101 format!(
102 "dependency `{}`: no installable version matches `{}` (available: {})",
103 dep.name,
104 dep.version_req,
105 versions
106 .iter()
107 .map(|v| v.version.as_str())
108 .collect::<Vec<_>>()
109 .join(", ")
110 )
111 })?;
112
113 Ok(ResolvedDep {
114 name: dep.name.clone(),
115 namespace: remote_ref.namespace,
116 slug: remote_ref.name,
117 version: best.version.clone(),
118 artifact_sha256: best.artifact_sha256.clone(),
119 })
120}
121
122pub fn parse_version_req(req: &str) -> Result<semver::VersionReq, String> {
124 let trimmed = req.trim();
125 if trimmed.is_empty() || trimmed == "*" {
126 return Ok(semver::VersionReq::STAR);
127 }
128 semver::VersionReq::parse(trimmed).map_err(|e| format!("invalid version range `{req}`: {e}"))
129}
130
131pub fn pick_highest_match<'a>(
134 versions: &'a [VersionInfo],
135 req: &semver::VersionReq,
136) -> Option<&'a VersionInfo> {
137 versions
138 .iter()
139 .filter(|v| !v.yanked)
140 .filter_map(|v| Some((semver::Version::parse(&v.version).ok()?, v)))
141 .filter(|(parsed, _)| req.matches(parsed))
142 .max_by(|(a, _), (b, _)| a.cmp(b))
143 .map(|(_, v)| v)
144}
145
146pub fn locked_version(name: &str, project_root: &std::path::Path) -> Option<String> {
148 let lock = super::lockfile::load(project_root).ok()?;
149 lock.packages
150 .iter()
151 .find(|p| p.name == name)
152 .map(|p| p.version.clone())
153}
154
155pub fn already_satisfied(
165 project_root: &std::path::Path,
166 registry: &super::registry::LocalRegistry,
167 dep: &PackageDependency,
168) -> Option<ResolvedDep> {
169 let lock = super::lockfile::load(project_root).ok()?;
170 let locked = lock.packages.iter().find(|p| p.name == dep.name)?;
171 let req = parse_version_req(&dep.version_req).ok()?;
172 let version = semver::Version::parse(&locked.version).ok()?;
173 if !req.matches(&version) {
174 return None;
175 }
176 let installed = registry.get(&dep.name, Some(&locked.version)).ok()??;
177 let remote_ref = remote::parse_remote_ref(&dep.name)?;
178 Some(ResolvedDep {
179 name: dep.name.clone(),
180 namespace: remote_ref.namespace,
181 slug: remote_ref.name,
182 version: installed.version,
183 artifact_sha256: locked.artifact_sha256.clone(),
184 })
185}
186
187#[cfg(test)]
188mod tests {
189 use super::*;
190
191 fn v(version: &str, yanked: bool) -> VersionInfo {
192 VersionInfo {
193 version: version.into(),
194 artifact_sha256: "a".repeat(64),
195 yanked,
196 }
197 }
198
199 #[test]
200 fn picks_highest_matching_version() {
201 let versions = [v("1.0.0", false), v("1.2.0", false), v("2.0.0", false)];
202 let req = parse_version_req("^1.0").unwrap();
203 assert_eq!(
204 pick_highest_match(&versions, &req).unwrap().version,
205 "1.2.0"
206 );
207 }
208
209 #[test]
210 fn yanked_versions_never_match() {
211 let versions = [v("1.0.0", false), v("1.3.0", true)];
212 let req = parse_version_req("^1.0").unwrap();
213 assert_eq!(
214 pick_highest_match(&versions, &req).unwrap().version,
215 "1.0.0"
216 );
217 }
218
219 #[test]
220 fn no_match_yields_none() {
221 let versions = [v("1.0.0", false)];
222 let req = parse_version_req("^2.0").unwrap();
223 assert!(pick_highest_match(&versions, &req).is_none());
224 }
225
226 #[test]
227 fn star_and_empty_match_anything() {
228 let versions = [v("0.3.7", false)];
229 for raw in ["", "*", " "] {
230 let req = parse_version_req(raw).unwrap();
231 assert_eq!(
232 pick_highest_match(&versions, &req).unwrap().version,
233 "0.3.7",
234 "req `{raw}`"
235 );
236 }
237 }
238
239 #[test]
240 fn non_semver_index_entries_are_skipped() {
241 let versions = [v("not-a-version", false), v("1.1.0", false)];
242 let req = parse_version_req("^1").unwrap();
243 assert_eq!(
244 pick_highest_match(&versions, &req).unwrap().version,
245 "1.1.0"
246 );
247 }
248
249 #[test]
250 fn invalid_range_is_an_error() {
251 assert!(parse_version_req(">>nope<<").is_err());
252 }
253
254 #[test]
255 fn self_dependency_is_refused() {
256 let mut manifest = crate::core::context_package::manifest::PackageManifest {
257 dependencies: vec![PackageDependency {
258 name: "@acme/root".into(),
259 version_req: "^1".into(),
260 optional: false,
261 }],
262 ..minimal("@acme/root")
263 };
264 let err = resolve_dependencies(
267 &manifest.dependencies,
268 Some(&manifest.name),
269 "http://127.0.0.1:1",
270 None,
271 )
272 .unwrap_err();
273 assert!(err.contains("depends on itself"), "got: {err}");
274
275 manifest.dependencies[0].optional = true;
277 assert_eq!(
278 resolve_dependencies(
279 &manifest.dependencies,
280 Some(&manifest.name),
281 "http://127.0.0.1:1",
282 None
283 )
284 .unwrap(),
285 Vec::new()
286 );
287 }
288
289 #[test]
301 fn addon_scoped_self_dependency_is_refused() {
302 let dep = PackageDependency {
303 name: "@dasTholo/demo".into(),
304 version_req: "^0.2".into(),
305 optional: false,
306 };
307
308 let err =
311 resolve_one(Some("@dasTholo/demo"), &dep, "http://127.0.0.1:1", None).unwrap_err();
312 assert!(err.contains("depends on itself"), "got: {err}");
313
314 let err_bare = resolve_one(Some("demo"), &dep, "http://127.0.0.1:1", None).unwrap_err();
319 assert!(
320 !err_bare.contains("depends on itself"),
321 "bare slug must not trip the self-guard; got: {err_bare}"
322 );
323
324 let err_none = resolve_one(None, &dep, "http://127.0.0.1:1", None).unwrap_err();
327 assert!(
328 !err_none.contains("depends on itself"),
329 "None root must not trip the self-guard; got: {err_none}"
330 );
331 }
332
333 #[test]
334 fn unscoped_dependency_is_refused() {
335 let manifest = crate::core::context_package::manifest::PackageManifest {
336 dependencies: vec![PackageDependency {
337 name: "plain-name".into(),
338 version_req: "^1".into(),
339 optional: false,
340 }],
341 ..minimal("@acme/root")
342 };
343 let err = resolve_dependencies(
344 &manifest.dependencies,
345 Some(&manifest.name),
346 "http://127.0.0.1:1",
347 None,
348 )
349 .unwrap_err();
350 assert!(err.contains("not a scoped"), "got: {err}");
351 }
352
353 #[test]
354 fn already_satisfied_returns_the_locked_version_as_a_resolved_dep() {
355 use crate::core::context_package::lockfile::{self, LockedPackage};
356
357 let store = tempfile::tempdir().unwrap();
358 let proj = tempfile::tempdir().unwrap();
359 let registry = super::super::registry::LocalRegistry::open_at(store.path()).unwrap();
360
361 let mut manifest = minimal("@ns/skills");
363 manifest.version = "0.2.0".into();
364 registry
365 .install(&manifest, &super::super::content::PackageContent::default())
366 .unwrap();
367
368 lockfile::upsert(
370 proj.path(),
371 LockedPackage {
372 name: "@ns/skills".into(),
373 version: "0.2.0".into(),
374 artifact_sha256: "a".repeat(64),
375 registry: "https://example.test".into(),
376 },
377 )
378 .unwrap();
379
380 let dep = PackageDependency {
381 name: "@ns/skills".into(),
382 version_req: "^0.2".into(),
383 optional: false,
384 };
385 let resolved =
386 already_satisfied(proj.path(), ®istry, &dep).expect("locked + present on disk");
387
388 assert_eq!(resolved.version, "0.2.0");
392 assert_eq!(resolved.name, "@ns/skills");
393 assert_eq!(resolved.namespace, "ns");
394 assert_eq!(resolved.slug, "skills");
395 }
396
397 fn minimal(name: &str) -> crate::core::context_package::manifest::PackageManifest {
398 use crate::core::context_package::manifest::*;
399 PackageManifest {
400 schema_version: crate::core::contracts::CONTEXT_PACKAGE_V2_SCHEMA_VERSION,
401 conformance_level: None,
402 kind: PackageKind::default(),
403 name: name.into(),
404 version: "1.0.0".into(),
405 description: "d".into(),
406 author: None,
407 scope: None,
408 created_at: chrono::Utc::now(),
409 updated_at: None,
410 layers: vec![],
411 dependencies: vec![],
412 tags: vec![],
413 visibility: None,
414 integrity: PackageIntegrity {
415 sha256: "a".repeat(64),
416 content_hash: "b".repeat(64),
417 byte_size: 1,
418 },
419 provenance: PackageProvenance {
420 tool: "lean-ctx".into(),
421 tool_version: "0".into(),
422 project_hash: None,
423 source_session_id: None,
424 },
425 compatibility: CompatibilitySpec::default(),
426 stats: PackageStats::default(),
427 signature: None,
428 graph_summary: None,
429 marketplace: None,
430 }
431 }
432}