1pub 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
21pub 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
43pub 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
62pub 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 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 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 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 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 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 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 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}