lean_ctx/core/context_package/
deps.rs1use super::manifest::{PackageDependency, PackageManifest};
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(
41 manifest: &PackageManifest,
42 base: &str,
43 token: Option<&str>,
44) -> Result<Vec<ResolvedDep>, String> {
45 let mut resolved = Vec::new();
46 for dep in &manifest.dependencies {
47 if dep.optional {
48 continue;
49 }
50 resolved.push(resolve_one(&manifest.name, dep, base, token)?);
51 }
52 Ok(resolved)
53}
54
55pub fn resolve_one(
57 root_name: &str,
58 dep: &PackageDependency,
59 base: &str,
60 token: Option<&str>,
61) -> Result<ResolvedDep, String> {
62 let Some(remote_ref) = remote::parse_remote_ref(&dep.name) else {
63 return Err(format!(
64 "dependency `{}` is not a scoped @ns/name reference — unresolvable",
65 dep.name
66 ));
67 };
68 if dep.name.trim_start_matches('@') == root_name.trim_start_matches('@') {
69 return Err(format!(
70 "package depends on itself (`{}`) — refused",
71 dep.name
72 ));
73 }
74 let req = parse_version_req(&dep.version_req)
75 .map_err(|e| format!("dependency `{}`: {e}", dep.name))?;
76
77 let versions = remote::fetch_versions(base, &remote_ref.namespace, &remote_ref.name, token)
78 .map_err(|e| format!("dependency `{}`: {e}", dep.name))?;
79 let best = pick_highest_match(&versions, &req).ok_or_else(|| {
80 format!(
81 "dependency `{}`: no installable version matches `{}` (available: {})",
82 dep.name,
83 dep.version_req,
84 versions
85 .iter()
86 .map(|v| v.version.as_str())
87 .collect::<Vec<_>>()
88 .join(", ")
89 )
90 })?;
91
92 Ok(ResolvedDep {
93 name: dep.name.clone(),
94 namespace: remote_ref.namespace,
95 slug: remote_ref.name,
96 version: best.version.clone(),
97 artifact_sha256: best.artifact_sha256.clone(),
98 })
99}
100
101pub fn parse_version_req(req: &str) -> Result<semver::VersionReq, String> {
103 let trimmed = req.trim();
104 if trimmed.is_empty() || trimmed == "*" {
105 return Ok(semver::VersionReq::STAR);
106 }
107 semver::VersionReq::parse(trimmed).map_err(|e| format!("invalid version range `{req}`: {e}"))
108}
109
110pub fn pick_highest_match<'a>(
113 versions: &'a [VersionInfo],
114 req: &semver::VersionReq,
115) -> Option<&'a VersionInfo> {
116 versions
117 .iter()
118 .filter(|v| !v.yanked)
119 .filter_map(|v| Some((semver::Version::parse(&v.version).ok()?, v)))
120 .filter(|(parsed, _)| req.matches(parsed))
121 .max_by(|(a, _), (b, _)| a.cmp(b))
122 .map(|(_, v)| v)
123}
124
125pub fn locked_version(name: &str, project_root: &std::path::Path) -> Option<String> {
127 let lock = super::lockfile::load(project_root).ok()?;
128 lock.packages
129 .iter()
130 .find(|p| p.name == name)
131 .map(|p| p.version.clone())
132}
133
134pub fn already_satisfied(
138 project_root: &std::path::Path,
139 registry: &super::registry::LocalRegistry,
140 dep: &PackageDependency,
141) -> Option<String> {
142 let lock = super::lockfile::load(project_root).ok()?;
143 let locked = lock.packages.iter().find(|p| p.name == dep.name)?;
144 let req = parse_version_req(&dep.version_req).ok()?;
145 let version = semver::Version::parse(&locked.version).ok()?;
146 if !req.matches(&version) {
147 return None;
148 }
149 let installed = registry.get(&dep.name, Some(&locked.version)).ok()??;
150 Some(installed.version)
151}
152
153#[cfg(test)]
154mod tests {
155 use super::*;
156
157 fn v(version: &str, yanked: bool) -> VersionInfo {
158 VersionInfo {
159 version: version.into(),
160 artifact_sha256: "a".repeat(64),
161 yanked,
162 }
163 }
164
165 #[test]
166 fn picks_highest_matching_version() {
167 let versions = [v("1.0.0", false), v("1.2.0", false), v("2.0.0", false)];
168 let req = parse_version_req("^1.0").unwrap();
169 assert_eq!(
170 pick_highest_match(&versions, &req).unwrap().version,
171 "1.2.0"
172 );
173 }
174
175 #[test]
176 fn yanked_versions_never_match() {
177 let versions = [v("1.0.0", false), v("1.3.0", true)];
178 let req = parse_version_req("^1.0").unwrap();
179 assert_eq!(
180 pick_highest_match(&versions, &req).unwrap().version,
181 "1.0.0"
182 );
183 }
184
185 #[test]
186 fn no_match_yields_none() {
187 let versions = [v("1.0.0", false)];
188 let req = parse_version_req("^2.0").unwrap();
189 assert!(pick_highest_match(&versions, &req).is_none());
190 }
191
192 #[test]
193 fn star_and_empty_match_anything() {
194 let versions = [v("0.3.7", false)];
195 for raw in ["", "*", " "] {
196 let req = parse_version_req(raw).unwrap();
197 assert_eq!(
198 pick_highest_match(&versions, &req).unwrap().version,
199 "0.3.7",
200 "req `{raw}`"
201 );
202 }
203 }
204
205 #[test]
206 fn non_semver_index_entries_are_skipped() {
207 let versions = [v("not-a-version", false), v("1.1.0", false)];
208 let req = parse_version_req("^1").unwrap();
209 assert_eq!(
210 pick_highest_match(&versions, &req).unwrap().version,
211 "1.1.0"
212 );
213 }
214
215 #[test]
216 fn invalid_range_is_an_error() {
217 assert!(parse_version_req(">>nope<<").is_err());
218 }
219
220 #[test]
221 fn self_dependency_is_refused() {
222 let mut manifest = crate::core::context_package::manifest::PackageManifest {
223 dependencies: vec![PackageDependency {
224 name: "@acme/root".into(),
225 version_req: "^1".into(),
226 optional: false,
227 }],
228 ..minimal("@acme/root")
229 };
230 let err = resolve_dependencies(&manifest, "http://127.0.0.1:1", None).unwrap_err();
233 assert!(err.contains("depends on itself"), "got: {err}");
234
235 manifest.dependencies[0].optional = true;
237 assert_eq!(
238 resolve_dependencies(&manifest, "http://127.0.0.1:1", None).unwrap(),
239 Vec::new()
240 );
241 }
242
243 #[test]
244 fn unscoped_dependency_is_refused() {
245 let manifest = crate::core::context_package::manifest::PackageManifest {
246 dependencies: vec![PackageDependency {
247 name: "plain-name".into(),
248 version_req: "^1".into(),
249 optional: false,
250 }],
251 ..minimal("@acme/root")
252 };
253 let err = resolve_dependencies(&manifest, "http://127.0.0.1:1", None).unwrap_err();
254 assert!(err.contains("not a scoped"), "got: {err}");
255 }
256
257 fn minimal(name: &str) -> crate::core::context_package::manifest::PackageManifest {
258 use crate::core::context_package::manifest::*;
259 PackageManifest {
260 schema_version: crate::core::contracts::CONTEXT_PACKAGE_V2_SCHEMA_VERSION,
261 conformance_level: None,
262 kind: PackageKind::default(),
263 name: name.into(),
264 version: "1.0.0".into(),
265 description: "d".into(),
266 author: None,
267 scope: None,
268 created_at: chrono::Utc::now(),
269 updated_at: None,
270 layers: vec![],
271 dependencies: vec![],
272 tags: vec![],
273 visibility: None,
274 integrity: PackageIntegrity {
275 sha256: "a".repeat(64),
276 content_hash: "b".repeat(64),
277 byte_size: 1,
278 },
279 provenance: PackageProvenance {
280 tool: "lean-ctx".into(),
281 tool_version: "0".into(),
282 project_hash: None,
283 source_session_id: None,
284 },
285 compatibility: CompatibilitySpec::default(),
286 stats: PackageStats::default(),
287 signature: None,
288 graph_summary: None,
289 marketplace: None,
290 }
291 }
292}