Skip to main content

lean_ctx/core/context_package/
deps.rs

1//! Depth-1 dependency resolution at install time (GH #727, Phase 3).
2//!
3//! A package may declare [`PackageDependency`] entries (SemVer ranges). On
4//! `pack install` / `addon add`, the direct dependencies of the root package
5//! are resolved against the registry index and installed alongside it — one
6//! consent surface listing everything that will land.
7//!
8//! **Depth-1 is deliberate** (issue non-goal: no transitive graphs): only the
9//! root's own dependencies resolve; a dependency's dependencies do not. That
10//! keeps resolution O(deps), makes cycles impossible beyond self-reference
11//! (which is refused), and keeps the consent prompt honest — nothing installs
12//! that was not listed.
13//!
14//! Determinism: given the same registry index, resolution always picks the
15//! **highest non-yanked version matching the range** — and repeated installs
16//! short-circuit offline via the lockfile + local store (`already_satisfied`).
17
18use super::manifest::{PackageDependency, PackageManifest};
19use super::remote::{self, VersionInfo};
20
21/// One resolved direct dependency, ready to download.
22#[derive(Debug, Clone, PartialEq, Eq)]
23pub struct ResolvedDep {
24    /// Scoped name as declared (`@ns/name`).
25    pub name: String,
26    /// Registry namespace (without `@`).
27    pub namespace: String,
28    /// Bare package name (slug).
29    pub slug: String,
30    /// The picked version (highest non-yanked match of the range).
31    pub version: String,
32    /// Artifact hash from the registry index (verified again on download).
33    pub artifact_sha256: String,
34}
35
36/// Resolve the direct, non-optional dependencies of `manifest` against the
37/// registry at `base`. Fails on: unscoped names, self-dependency, invalid
38/// ranges, and ranges with no installable match — a partially-resolved
39/// install is worse than a refused one.
40pub 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
55/// Resolve a single declared dependency against the registry index.
56pub 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
101/// Parse a SemVer range. An empty/`*` requirement means "any version".
102pub 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
110/// Highest non-yanked version matching `req`. Non-SemVer versions in the
111/// index are skipped (they can never match a range).
112pub 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
125/// Version of `name` pinned in the project lockfile, if any.
126pub 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
134/// True when `name@version-satisfying-req` is already pinned in the lockfile
135/// **and** present in the local store — the offline-reproducible fast path:
136/// a second `pack install` touches no network for satisfied dependencies.
137pub 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        // resolve_one is exercised via resolve_dependencies; the self-check
231        // fires before any network I/O, so an invalid base URL never matters.
232        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        // Optional dependencies are skipped entirely.
236        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}