Skip to main content

facto_core/lockfiles/
mod.rs

1//! Lockfile parsing for supported ecosystems.
2//!
3//! The public API is three functions: [`detect_format`] to identify a
4//! lockfile by its filename, [`parse`] to extract dependencies from its
5//! content given a known format, and [`detect_and_parse`] as a convenience
6//! that combines the two.
7//!
8//! facto-core never performs filesystem I/O. Callers are responsible for
9//! reading the file and passing its content as a string.
10
11pub mod models;
12mod npm;
13mod pipfile;
14mod pnpm;
15mod toml_common;
16
17pub use models::{
18    CheckedDep, DepGroup, LockfileFormat, ParseError, ParseResult, ParseWarning, ResolvedDep,
19};
20
21/// Detect the lockfile format from a filename or file path.
22///
23/// Returns `None` when the filename does not match any known lockfile.
24/// The input may be a bare filename or a path; only the basename is used.
25/// Matching is case-sensitive: `Cargo.lock` matches but `cargo.lock` does not.
26pub fn detect_format(filename: &str) -> Option<LockfileFormat> {
27    let base = match filename.rfind(['/', '\\']) {
28        Some(i) => &filename[i + 1..],
29        None => filename,
30    };
31    match base {
32        "uv.lock" => Some(LockfileFormat::UvLock),
33        "poetry.lock" => Some(LockfileFormat::PoetryLock),
34        "pdm.lock" => Some(LockfileFormat::PdmLock),
35        "Pipfile.lock" => Some(LockfileFormat::PipfileLock),
36        "package-lock.json" => Some(LockfileFormat::NpmLock),
37        "pnpm-lock.yaml" => Some(LockfileFormat::PnpmLock),
38        "Cargo.lock" => Some(LockfileFormat::CargoLock),
39        _ => None,
40    }
41}
42
43/// Parse a lockfile's content given an already-known format.
44///
45/// Individual malformed entries become `ParseWarning`s; the returned
46/// `ParseResult` still contains the successfully extracted deps. A fatal
47/// error (e.g. invalid TOML/JSON) causes a panic-free empty result with a
48/// single warning. Use [`detect_and_parse`] if you want a `Result` on
49/// structural failures instead.
50pub fn parse(content: &str, format: LockfileFormat) -> ParseResult {
51    match format {
52        LockfileFormat::UvLock => toml_common::parse_uv(content),
53        LockfileFormat::PoetryLock => toml_common::parse_poetry(content),
54        LockfileFormat::PdmLock => toml_common::parse_pdm(content),
55        LockfileFormat::CargoLock => toml_common::parse_cargo(content),
56        LockfileFormat::PipfileLock => pipfile::parse(content),
57        LockfileFormat::NpmLock => npm::parse(content),
58        LockfileFormat::PnpmLock => pnpm::parse(content),
59    }
60}
61
62/// Detect the format from `filename` and parse `content`.
63///
64/// Returns `Err(ParseError::UnknownFormat(..))` if the filename is not
65/// recognised. Structural parse errors surface via the inner parser and
66/// are reported as warnings on the returned `ParseResult`.
67pub fn detect_and_parse(filename: &str, content: &str) -> Result<ParseResult, ParseError> {
68    let format =
69        detect_format(filename).ok_or_else(|| ParseError::UnknownFormat(filename.to_string()))?;
70    Ok(parse(content, format))
71}
72
73#[cfg(test)]
74mod tests {
75    use super::*;
76
77    #[test]
78    fn test_detect_format_python() {
79        assert_eq!(detect_format("uv.lock"), Some(LockfileFormat::UvLock));
80        assert_eq!(
81            detect_format("poetry.lock"),
82            Some(LockfileFormat::PoetryLock)
83        );
84        assert_eq!(detect_format("pdm.lock"), Some(LockfileFormat::PdmLock));
85        assert_eq!(
86            detect_format("Pipfile.lock"),
87            Some(LockfileFormat::PipfileLock)
88        );
89    }
90
91    #[test]
92    fn test_detect_format_node() {
93        assert_eq!(
94            detect_format("package-lock.json"),
95            Some(LockfileFormat::NpmLock)
96        );
97        assert_eq!(
98            detect_format("pnpm-lock.yaml"),
99            Some(LockfileFormat::PnpmLock)
100        );
101    }
102
103    #[test]
104    fn test_detect_format_rust() {
105        assert_eq!(detect_format("Cargo.lock"), Some(LockfileFormat::CargoLock));
106    }
107
108    #[test]
109    fn test_detect_format_handles_paths() {
110        assert_eq!(
111            detect_format("/tmp/project/uv.lock"),
112            Some(LockfileFormat::UvLock)
113        );
114        assert_eq!(
115            detect_format("subdir/Cargo.lock"),
116            Some(LockfileFormat::CargoLock)
117        );
118    }
119
120    #[test]
121    fn test_detect_format_unknown() {
122        assert_eq!(detect_format("requirements.txt"), None);
123        assert_eq!(detect_format("pyproject.toml"), None);
124        assert_eq!(detect_format(""), None);
125        // Matching is case-sensitive: lowercase Cargo.lock must NOT match.
126        assert_eq!(detect_format("cargo.lock"), None);
127    }
128
129    #[test]
130    fn test_parse_returns_format_in_result() {
131        let result = parse("", LockfileFormat::UvLock);
132        assert_eq!(result.format, LockfileFormat::UvLock);
133    }
134
135    #[test]
136    fn test_detect_and_parse_unknown_returns_error() {
137        let err = detect_and_parse("unknown.file", "").unwrap_err();
138        assert!(matches!(err, ParseError::UnknownFormat(_)));
139    }
140
141    #[test]
142    fn test_detect_and_parse_known_format_ok() {
143        let result = detect_and_parse("uv.lock", "").unwrap();
144        assert_eq!(result.format, LockfileFormat::UvLock);
145    }
146
147    const UV_LOCK_SAMPLE: &str = r#"
148version = 1
149requires-python = ">=3.11"
150
151[[package]]
152name = "fastapi"
153version = "0.115.8"
154source = { registry = "https://pypi.org/simple" }
155
156[[package]]
157name = "pydantic"
158version = "2.10.6"
159source = { registry = "https://pypi.org/simple" }
160
161[[package]]
162name = "pytest"
163version = "8.3.5"
164source = { registry = "https://pypi.org/simple" }
165"#;
166
167    #[test]
168    fn test_parse_uv_lock_extracts_all_packages() {
169        let result = parse(UV_LOCK_SAMPLE, LockfileFormat::UvLock);
170        assert_eq!(result.format, LockfileFormat::UvLock);
171        assert_eq!(result.deps.len(), 3);
172        assert_eq!(result.warnings, vec![]);
173
174        let fastapi = &result.deps[0];
175        assert_eq!(fastapi.name, "fastapi");
176        assert_eq!(fastapi.version, "0.115.8");
177        assert_eq!(fastapi.registry, "pypi");
178        assert_eq!(fastapi.group, DepGroup::Main);
179    }
180
181    #[test]
182    fn test_parse_uv_lock_invalid_toml_yields_warning() {
183        let result = parse("not valid = = toml", LockfileFormat::UvLock);
184        assert!(result.deps.is_empty());
185        assert_eq!(result.warnings.len(), 1);
186        assert!(result.warnings[0].message.contains("invalid TOML"));
187    }
188
189    #[test]
190    fn test_parse_uv_lock_package_missing_version_skipped() {
191        let sample = r#"
192[[package]]
193name = "brokenpkg"
194"#;
195        let result = parse(sample, LockfileFormat::UvLock);
196        assert!(result.deps.is_empty());
197        assert_eq!(result.warnings.len(), 1);
198        assert!(result.warnings[0].message.contains("no version"));
199    }
200
201    const POETRY_LOCK_SAMPLE: &str = r#"
202[[package]]
203name = "fastapi"
204version = "0.115.8"
205description = "FastAPI framework"
206category = "main"
207optional = false
208python-versions = ">=3.8"
209
210[[package]]
211name = "pytest"
212version = "8.3.5"
213description = "pytest: simple powerful testing"
214category = "dev"
215optional = false
216python-versions = ">=3.8"
217
218[[package]]
219name = "black"
220version = "24.10.0"
221description = "Code formatter"
222category = "dev"
223optional = false
224python-versions = ">=3.9"
225"#;
226
227    #[test]
228    fn test_parse_poetry_lock_distinguishes_main_and_dev() {
229        let result = parse(POETRY_LOCK_SAMPLE, LockfileFormat::PoetryLock);
230        assert_eq!(result.format, LockfileFormat::PoetryLock);
231        assert_eq!(result.deps.len(), 3);
232
233        assert_eq!(result.deps[0].name, "fastapi");
234        assert_eq!(result.deps[0].group, DepGroup::Main);
235        assert_eq!(result.deps[0].registry, "pypi");
236
237        assert_eq!(result.deps[1].name, "pytest");
238        assert_eq!(result.deps[1].group, DepGroup::Dev);
239
240        assert_eq!(result.deps[2].name, "black");
241        assert_eq!(result.deps[2].group, DepGroup::Dev);
242    }
243
244    #[test]
245    fn test_parse_poetry_lock_v2_without_category_defaults_to_main() {
246        let sample = r#"
247[[package]]
248name = "requests"
249version = "2.32.3"
250"#;
251        let result = parse(sample, LockfileFormat::PoetryLock);
252        assert_eq!(result.deps.len(), 1);
253        assert_eq!(result.deps[0].name, "requests");
254        assert_eq!(result.deps[0].group, DepGroup::Main);
255        assert_eq!(result.deps[0].registry, "pypi");
256    }
257
258    const PDM_LOCK_SAMPLE: &str = r#"
259[[package]]
260name = "fastapi"
261version = "0.115.8"
262summary = "FastAPI framework"
263groups = ["default"]
264
265[[package]]
266name = "pytest"
267version = "8.3.5"
268summary = "pytest testing"
269groups = ["dev"]
270
271[[package]]
272name = "shared"
273version = "1.0.0"
274summary = "shared between default and dev"
275groups = ["default", "dev"]
276"#;
277
278    #[test]
279    fn test_parse_pdm_lock_uses_groups_field() {
280        let result = parse(PDM_LOCK_SAMPLE, LockfileFormat::PdmLock);
281        assert_eq!(result.format, LockfileFormat::PdmLock);
282        assert_eq!(result.deps.len(), 3);
283
284        assert_eq!(result.deps[0].name, "fastapi");
285        assert_eq!(result.deps[0].group, DepGroup::Main);
286
287        assert_eq!(result.deps[1].name, "pytest");
288        assert_eq!(result.deps[1].group, DepGroup::Dev);
289
290        // A dep in both "default" and "dev" is Main (default wins).
291        assert_eq!(result.deps[2].name, "shared");
292        assert_eq!(result.deps[2].group, DepGroup::Main);
293    }
294
295    const CARGO_LOCK_SAMPLE: &str = r#"
296version = 3
297
298[[package]]
299name = "serde"
300version = "1.0.210"
301source = "registry+https://github.com/rust-lang/crates.io-index"
302checksum = "fake_checksum"
303
304[[package]]
305name = "local-crate"
306version = "0.1.0"
307
308[[package]]
309name = "tokio"
310version = "1.44.0"
311source = "registry+https://github.com/rust-lang/crates.io-index"
312checksum = "other_checksum"
313"#;
314
315    #[test]
316    fn test_parse_cargo_lock_skips_path_dependencies() {
317        let result = parse(CARGO_LOCK_SAMPLE, LockfileFormat::CargoLock);
318        assert_eq!(result.format, LockfileFormat::CargoLock);
319        // local-crate (no source) is a workspace/path dep and gets skipped.
320        assert_eq!(result.deps.len(), 2);
321        assert_eq!(result.deps[0].name, "serde");
322        assert_eq!(result.deps[0].version, "1.0.210");
323        assert_eq!(result.deps[0].registry, "crates");
324        assert_eq!(result.deps[0].group, DepGroup::Unknown);
325
326        assert_eq!(result.deps[1].name, "tokio");
327        assert_eq!(result.deps[1].group, DepGroup::Unknown);
328
329        // One warning for the skipped local-crate entry.
330        assert_eq!(result.warnings.len(), 1);
331        assert!(result.warnings[0].message.contains("local-crate"));
332    }
333
334    const PIPFILE_LOCK_SAMPLE: &str = r#"{
335    "_meta": {
336        "hash": {"sha256": "abc"},
337        "pipfile-spec": 6,
338        "requires": {"python_version": "3.11"}
339    },
340    "default": {
341        "fastapi": {
342            "version": "==0.115.8",
343            "hashes": ["sha256:abc"]
344        },
345        "pydantic": {
346            "version": "==2.10.6",
347            "hashes": ["sha256:def"]
348        }
349    },
350    "develop": {
351        "pytest": {
352            "version": "==8.3.5",
353            "hashes": ["sha256:ghi"]
354        }
355    }
356}"#;
357
358    #[test]
359    fn test_parse_pipfile_lock_splits_default_and_develop() {
360        let result = parse(PIPFILE_LOCK_SAMPLE, LockfileFormat::PipfileLock);
361        assert_eq!(result.format, LockfileFormat::PipfileLock);
362        assert_eq!(result.deps.len(), 3);
363
364        // Order preserved: default first, then develop.
365        let fastapi = result.deps.iter().find(|d| d.name == "fastapi").unwrap();
366        assert_eq!(fastapi.version, "0.115.8");
367        assert_eq!(fastapi.registry, "pypi");
368        assert_eq!(fastapi.group, DepGroup::Main);
369
370        let pytest = result.deps.iter().find(|d| d.name == "pytest").unwrap();
371        assert_eq!(pytest.version, "8.3.5");
372        assert_eq!(pytest.group, DepGroup::Dev);
373    }
374
375    #[test]
376    fn test_parse_pipfile_lock_invalid_json_warning() {
377        let result = parse("{not json", LockfileFormat::PipfileLock);
378        assert!(result.deps.is_empty());
379        assert_eq!(result.warnings.len(), 1);
380        assert!(result.warnings[0].message.contains("invalid JSON"));
381    }
382
383    const PACKAGE_LOCK_SAMPLE: &str = r#"{
384    "name": "myproject",
385    "version": "1.0.0",
386    "lockfileVersion": 3,
387    "requires": true,
388    "packages": {
389        "": {
390            "name": "myproject",
391            "version": "1.0.0",
392            "dependencies": {
393                "express": "^4.18.0"
394            }
395        },
396        "node_modules/express": {
397            "version": "4.21.2",
398            "resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz",
399            "integrity": "sha512-fake"
400        },
401        "node_modules/typescript": {
402            "version": "5.4.5",
403            "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.5.tgz",
404            "integrity": "sha512-fake",
405            "dev": true
406        }
407    }
408}"#;
409
410    #[test]
411    fn test_parse_package_lock_extracts_non_root_entries() {
412        let result = parse(PACKAGE_LOCK_SAMPLE, LockfileFormat::NpmLock);
413        assert_eq!(result.format, LockfileFormat::NpmLock);
414        assert_eq!(result.deps.len(), 2);
415
416        let express = result.deps.iter().find(|d| d.name == "express").unwrap();
417        assert_eq!(express.version, "4.21.2");
418        assert_eq!(express.registry, "npm");
419        assert_eq!(express.group, DepGroup::Main);
420
421        let ts = result.deps.iter().find(|d| d.name == "typescript").unwrap();
422        assert_eq!(ts.group, DepGroup::Dev);
423    }
424
425    #[test]
426    fn test_parse_package_lock_scoped_package() {
427        let sample = r#"{
428    "lockfileVersion": 3,
429    "packages": {
430        "node_modules/@types/node": {
431            "version": "20.11.0",
432            "dev": true
433        }
434    }
435}"#;
436        let result = parse(sample, LockfileFormat::NpmLock);
437        assert_eq!(result.deps.len(), 1);
438        assert_eq!(result.deps[0].name, "@types/node");
439        assert_eq!(result.deps[0].group, DepGroup::Dev);
440    }
441
442    const PNPM_LOCK_SAMPLE: &str = r#"lockfileVersion: '9.0'
443
444settings:
445  autoInstallPeers: true
446
447importers:
448  .:
449    dependencies:
450      express:
451        specifier: ^4.18.0
452        version: 4.21.2
453    devDependencies:
454      typescript:
455        specifier: ^5.4.0
456        version: 5.4.5
457      '@types/node':
458        specifier: ^20.11.0
459        version: 20.11.0
460
461packages:
462
463  express@4.21.2:
464    resolution: {integrity: sha512-fake}
465    engines: {node: '>=0.10.0'}
466
467  typescript@5.4.5:
468    resolution: {integrity: sha512-fake}
469    engines: {node: '>=14.17'}
470
471  '@types/node@20.11.0':
472    resolution: {integrity: sha512-fake}
473
474snapshots:
475
476  express@4.21.2: {}
477
478  typescript@5.4.5: {}
479
480  '@types/node@20.11.0': {}
481"#;
482
483    #[test]
484    fn test_parse_pnpm_lock_extracts_packages_as_unknown_group() {
485        let result = parse(PNPM_LOCK_SAMPLE, LockfileFormat::PnpmLock);
486        assert_eq!(result.format, LockfileFormat::PnpmLock);
487        assert_eq!(result.deps.len(), 3);
488
489        let express = result.deps.iter().find(|d| d.name == "express").unwrap();
490        assert_eq!(express.version, "4.21.2");
491        assert_eq!(express.registry, "npm");
492        // pnpm v9+ no longer records dev/main on individual package
493        // entries -- that context lives under `importers`. For v1 we
494        // report every dep as Unknown rather than parsing importers.
495        assert_eq!(express.group, DepGroup::Unknown);
496
497        let ts = result.deps.iter().find(|d| d.name == "typescript").unwrap();
498        assert_eq!(ts.group, DepGroup::Unknown);
499
500        let types_node = result
501            .deps
502            .iter()
503            .find(|d| d.name == "@types/node")
504            .unwrap();
505        assert_eq!(types_node.version, "20.11.0");
506        assert_eq!(types_node.group, DepGroup::Unknown);
507    }
508
509    #[test]
510    fn test_parse_pnpm_lock_invalid_yaml_yields_warning() {
511        // Unterminated flow sequence is unambiguously invalid YAML.
512        let result = parse("[1, 2, 3", LockfileFormat::PnpmLock);
513        assert!(result.deps.is_empty());
514        assert_eq!(result.warnings.len(), 1);
515        assert!(result.warnings[0].message.contains("invalid YAML"));
516    }
517}