Skip to main content

jhol_core/
lockfile.rs

1//! Read package-lock.json and bun.lock for resolved versions (deterministic installs).
2
3use std::collections::HashMap;
4use std::path::Path;
5
6/// Lockfile kind detected in a directory.
7#[derive(Clone, Copy, Debug, Eq, PartialEq)]
8pub enum LockfileKind {
9    None,
10    Npm,
11    Bun,
12}
13
14/// Return which lockfile is present in the given directory (package-lock.json or bun.lock).
15pub fn detect_lockfile(dir: &Path) -> LockfileKind {
16    if dir.join("bun.lock").exists() {
17        LockfileKind::Bun
18    } else if dir.join("package-lock.json").exists() {
19        LockfileKind::Npm
20    } else {
21        LockfileKind::None
22    }
23}
24
25/// Read package.json and return (dependencies, devDependencies) as name -> version spec.
26pub fn read_package_json_deps(path: &Path) -> Option<HashMap<String, String>> {
27    let s = std::fs::read_to_string(path).ok()?;
28    let v: serde_json::Value = serde_json::from_str(&s).ok()?;
29    let mut deps = HashMap::new();
30    if let Some(d) = v.get("dependencies").and_then(|d| d.as_object()) {
31        for (k, v) in d {
32            if let Some(s) = v.as_str() {
33                deps.insert(k.clone(), s.to_string());
34            }
35        }
36    }
37    if let Some(d) = v.get("devDependencies").and_then(|d| d.as_object()) {
38        for (k, v) in d {
39            if let Some(s) = v.as_str() {
40                deps.insert(k.clone(), s.to_string());
41            }
42        }
43    }
44    Some(deps)
45}
46
47/// Read package-lock.json and return resolved versions: package name -> exact version.
48/// Supports lockfileVersion 2 and 3 (packages key).
49pub fn read_lockfile_resolved(path: &Path) -> Option<HashMap<String, String>> {
50    let s = std::fs::read_to_string(path).ok()?;
51    let v: serde_json::Value = serde_json::from_str(&s).ok()?;
52    let packages = v.get("packages")?.as_object()?;
53    let mut resolved = HashMap::new();
54    for (key, val) in packages {
55        let version = val.get("version")?.as_str()?;
56        // key is "" for root, "node_modules/foo" or "node_modules/@scope/foo"
57        let name = key.trim_start_matches("node_modules/");
58        if name.is_empty() {
59            continue;
60        }
61        resolved.insert(name.to_string(), version.to_string());
62    }
63    Some(resolved)
64}
65
66/// Read bun.lock (text JSON format) and return resolved versions: package name -> exact version.
67/// Bun lockfile has "packages" object; keys can be "npm:name@version" or "name@version".
68pub fn read_bun_lock_resolved(path: &Path) -> Option<HashMap<String, String>> {
69    let s = std::fs::read_to_string(path).ok()?;
70    let v: serde_json::Value = serde_json::from_str(&s).ok()?;
71    let packages = v.get("packages")?.as_object()?;
72    let mut resolved = HashMap::new();
73    for (key, _val) in packages {
74        let rest = key.strip_prefix("npm:").unwrap_or(key);
75        // Scoped: @scope/pkg@1.0.0 -> name = @scope/pkg, version = 1.0.0 (rfind from left of last @)
76        let at_pos = rest.rfind('@')?;
77        if at_pos == 0 {
78            continue; // @ at start is scope, need another @
79        }
80        let name = rest[..at_pos].to_string();
81        let version = rest[at_pos + 1..].to_string();
82        if !version.is_empty() && !name.is_empty() {
83            resolved.insert(name, version);
84        }
85    }
86    Some(resolved)
87}
88
89/// Merge package.json deps with lockfile: for each dep, use lockfile version if present.
90/// Returns list of "name@version" for install.
91pub fn resolve_deps_for_install(
92    package_json_deps: &HashMap<String, String>,
93    lockfile_resolved: Option<&HashMap<String, String>>,
94) -> Vec<String> {
95    let mut out = Vec::with_capacity(package_json_deps.len());
96    for (name, spec) in package_json_deps {
97        let version = lockfile_resolved
98            .and_then(|r| r.get(name).cloned())
99            .unwrap_or_else(|| spec.clone());
100        out.push(format!("{}@{}", name, version));
101    }
102    out
103}
104
105/// Read resolved versions from whichever lockfile exists in dir (package-lock.json or bun.lock).
106pub fn read_resolved_from_dir(dir: &Path) -> Option<HashMap<String, String>> {
107    let bun_lock = dir.join("bun.lock");
108    let npm_lock = dir.join("package-lock.json");
109    if bun_lock.exists() {
110        read_bun_lock_resolved(&bun_lock)
111    } else if npm_lock.exists() {
112        read_lockfile_resolved(&npm_lock)
113    } else {
114        None
115    }
116}
117
118/// Read package-lock.json and return map name@version -> resolved tarball URL (for zero-packument install).
119pub fn read_lockfile_resolved_urls(path: &Path) -> Option<HashMap<String, String>> {
120    read_lockfile_resolved_urls_with_integrity(path).map(|(urls, _)| urls)
121}
122
123/// Read package-lock.json and return (urls, integrity) maps. Integrity key is name@version -> SRI string.
124pub fn read_lockfile_resolved_urls_with_integrity(
125    path: &Path,
126) -> Option<(HashMap<String, String>, HashMap<String, String>)> {
127    let s = std::fs::read_to_string(path).ok()?;
128    let v: serde_json::Value = serde_json::from_str(&s).ok()?;
129    let packages = v.get("packages")?.as_object()?;
130    let mut urls = HashMap::new();
131    let mut integrity = HashMap::new();
132    for (key, val) in packages {
133        let name = key.trim_start_matches("node_modules/");
134        if name.is_empty() {
135            continue;
136        }
137        let version = val.get("version")?.as_str()?;
138        let resolved = val.get("resolved")?.as_str()?;
139        if resolved.ends_with(".tgz") {
140            let pkg_key = format!("{}@{}", name, version);
141            urls.insert(pkg_key.clone(), resolved.to_string());
142            if let Some(sri) = val.get("integrity").and_then(|i| i.as_str()) {
143                integrity.insert(pkg_key, sri.to_string());
144            }
145        }
146    }
147    Some((urls, integrity))
148}
149
150/// Build npm registry tarball URL for a package version (no packument needed).
151/// Scoped: @scope/pkg -> https://registry.npmjs.org/@scope%2Fpkg/-/pkg-1.0.0.tgz
152pub fn tarball_url_from_registry(name: &str, version: &str) -> String {
153    const REGISTRY: &str = "https://registry.npmjs.org";
154    let encoded = if name.starts_with('@') {
155        name.replace('/', "%2F")
156    } else {
157        name.to_string()
158    };
159    let tarball_name = if name.starts_with('@') {
160        name.split('/').last().unwrap_or(name).to_string()
161    } else {
162        name.to_string()
163    };
164    format!(
165        "{}/{}/-/{}-{}.tgz",
166        REGISTRY.trim_end_matches('/'),
167        encoded,
168        tarball_name,
169        version
170    )
171}
172
173/// Read resolved tarball URLs from dir: package-lock has "resolved"; bun.lock we build via tarball_url_from_registry.
174pub fn read_resolved_urls_from_dir(dir: &Path) -> Option<HashMap<String, String>> {
175    read_resolved_urls_and_integrity_from_dir(dir).map(|(urls, _)| urls)
176}
177
178/// Read resolved URLs and integrity (when available, e.g. package-lock.json). For bun.lock, integrity is empty.
179pub fn read_resolved_urls_and_integrity_from_dir(
180    dir: &Path,
181) -> Option<(HashMap<String, String>, HashMap<String, String>)> {
182    let npm_lock = dir.join("package-lock.json");
183    let bun_lock = dir.join("bun.lock");
184    if npm_lock.exists() {
185        return read_lockfile_resolved_urls_with_integrity(&npm_lock);
186    }
187    if bun_lock.exists() {
188        let resolved = read_bun_lock_resolved(&bun_lock)?;
189        let mut urls = HashMap::new();
190        for (name, version) in resolved {
191            let spec = format!("{}@{}", name, version);
192            urls.insert(spec, tarball_url_from_registry(&name, &version));
193        }
194        return Some((urls, HashMap::new()));
195    }
196    None
197}
198
199/// Check whether all resolved lockfile entries include integrity strings (package-lock.json only).
200pub fn lockfile_integrity_complete(dir: &Path) -> bool {
201    let npm_lock = dir.join("package-lock.json");
202    if !npm_lock.exists() {
203        return true;
204    }
205    let Ok(s) = std::fs::read_to_string(&npm_lock) else {
206        return false;
207    };
208    let Ok(v) = serde_json::from_str::<serde_json::Value>(&s) else {
209        return false;
210    };
211    let Some(packages) = v.get("packages").and_then(|p| p.as_object()) else {
212        return false;
213    };
214    for (key, val) in packages {
215        let name = key.trim_start_matches("node_modules/");
216        if name.is_empty() {
217            continue;
218        }
219        if val.get("integrity").and_then(|i| i.as_str()).is_none() {
220            return false;
221        }
222    }
223    true
224}
225
226/// Read all resolved specs (name@version) from lockfile in dir.
227/// For package-lock.json: uses `packages` entries with `version`.
228/// For bun.lock: uses parsed resolved name/version pairs.
229pub fn read_all_resolved_specs_from_dir(dir: &Path) -> Option<Vec<String>> {
230    let npm_lock = dir.join("package-lock.json");
231    let bun_lock = dir.join("bun.lock");
232    if npm_lock.exists() {
233        let resolved = read_lockfile_resolved(&npm_lock)?;
234        let mut specs: Vec<String> = resolved
235            .into_iter()
236            .map(|(name, version)| format!("{}@{}", name, version))
237            .collect();
238        specs.sort();
239        specs.dedup();
240        return Some(specs);
241    }
242    if bun_lock.exists() {
243        let resolved = read_bun_lock_resolved(&bun_lock)?;
244        let mut specs: Vec<String> = resolved
245            .into_iter()
246            .map(|(name, version)| format!("{}@{}", name, version))
247            .collect();
248        specs.sort();
249        specs.dedup();
250        return Some(specs);
251    }
252    None
253}