deps_pypi/
lockfile.rs

1//! Poetry/uv lock file parsing.
2//!
3//! Parses `poetry.lock` and `uv.lock` files to extract resolved dependency
4//! versions. Both formats use TOML with `[[package]]` sections.
5//!
6//! # Lock File Formats
7//!
8//! ## Poetry
9//!
10//! ```toml
11//! # This file is automatically generated by poetry.
12//! [[package]]
13//! name = "requests"
14//! version = "2.31.0"
15//! description = "Python HTTP for Humans."
16//!
17//! [package.dependencies]
18//! certifi = ">=2017.4.17"
19//! charset-normalizer = ">=2,<4"
20//!
21//! [metadata]
22//! lock-version = "2.0"
23//! python-versions = "^3.9"
24//! ```
25//!
26//! ## uv
27//!
28//! ```toml
29//! version = 1
30//!
31//! [[package]]
32//! name = "requests"
33//! version = "2.31.0"
34//! source = { registry = "https://pypi.org/simple" }
35//! dependencies = [
36//!     { name = "certifi" },
37//!     { name = "charset-normalizer" },
38//! ]
39//! ```
40
41use async_trait::async_trait;
42use deps_core::error::{DepsError, Result};
43use deps_core::lockfile::{
44    LockFileProvider, ResolvedPackage, ResolvedPackages, ResolvedSource,
45    locate_lockfile_for_manifest,
46};
47use std::path::{Path, PathBuf};
48use toml_edit::DocumentMut;
49use tower_lsp_server::ls_types::Uri;
50
51/// PyPI lock file parser.
52///
53/// Implements lock file parsing for Python package managers (Poetry, uv).
54/// Supports both `poetry.lock` and `uv.lock` formats.
55///
56/// # Lock File Location
57///
58/// The parser searches for lock files in the following order:
59/// 1. `poetry.lock` in the same directory as `pyproject.toml`
60/// 2. `uv.lock` in the same directory as `pyproject.toml`
61///
62/// Poetry takes priority because it's more established.
63///
64/// # Examples
65///
66/// ```no_run
67/// use deps_pypi::lockfile::PypiLockParser;
68/// use deps_core::lockfile::LockFileProvider;
69/// use tower_lsp_server::ls_types::Uri;
70///
71/// # async fn example() -> deps_core::error::Result<()> {
72/// let parser = PypiLockParser;
73/// let manifest_uri = Uri::from_file_path("/path/to/pyproject.toml").unwrap();
74///
75/// if let Some(lockfile_path) = parser.locate_lockfile(&manifest_uri) {
76///     let resolved = parser.parse_lockfile(&lockfile_path).await?;
77///     println!("Found {} resolved packages", resolved.len());
78/// }
79/// # Ok(())
80/// # }
81/// ```
82pub struct PypiLockParser;
83
84impl PypiLockParser {
85    /// Lock file names for PyPI ecosystem (poetry.lock, uv.lock).
86    const LOCKFILE_NAMES: &'static [&'static str] = &["poetry.lock", "uv.lock"];
87}
88
89#[async_trait]
90impl LockFileProvider for PypiLockParser {
91    fn locate_lockfile(&self, manifest_uri: &Uri) -> Option<PathBuf> {
92        locate_lockfile_for_manifest(manifest_uri, Self::LOCKFILE_NAMES)
93    }
94
95    async fn parse_lockfile(&self, lockfile_path: &Path) -> Result<ResolvedPackages> {
96        tracing::debug!("Parsing lock file: {}", lockfile_path.display());
97
98        let content = tokio::fs::read_to_string(lockfile_path)
99            .await
100            .map_err(|e| DepsError::ParseError {
101                file_type: format!("lock file at {}", lockfile_path.display()),
102                source: Box::new(e),
103            })?;
104
105        let doc: DocumentMut = content.parse().map_err(|e| DepsError::ParseError {
106            file_type: "Python lock file".into(),
107            source: Box::new(e),
108        })?;
109
110        let mut packages = ResolvedPackages::new();
111
112        let Some(package_array) = doc
113            .get("package")
114            .and_then(|v: &toml_edit::Item| v.as_array_of_tables())
115        else {
116            tracing::warn!("Lock file missing [[package]] array of tables");
117            return Ok(packages);
118        };
119
120        for table in package_array {
121            // Extract required fields
122            let Some(name) = table.get("name").and_then(|v: &toml_edit::Item| v.as_str()) else {
123                tracing::warn!("Package missing name field");
124                continue;
125            };
126
127            let Some(version) = table
128                .get("version")
129                .and_then(|v: &toml_edit::Item| v.as_str())
130            else {
131                tracing::warn!("Package '{}' missing version field", name);
132                continue;
133            };
134
135            // Parse source (format varies between poetry and uv)
136            let source = parse_pypi_source(table);
137
138            // Parse dependencies (format varies between poetry and uv)
139            let dependencies = parse_pypi_dependencies(table);
140
141            // Normalize name for consistent lookup (PyPI names are case-insensitive, - == _)
142            let normalized_name = name.to_lowercase().replace('-', "_");
143            packages.insert(ResolvedPackage {
144                name: normalized_name,
145                version: version.to_string(),
146                source,
147                dependencies,
148            });
149        }
150
151        tracing::info!(
152            "Parsed lock file: {} packages from {}",
153            packages.len(),
154            lockfile_path.display()
155        );
156
157        Ok(packages)
158    }
159}
160
161/// Parses source information from package table.
162///
163/// Handles both Poetry and uv source formats:
164///
165/// # Poetry Format
166///
167/// - No `source` field → PyPI registry (default)
168/// - `source.type = "git"` with `source.url` and `source.resolved_reference`
169/// - `source.type = "directory"` or `source.type = "file"` with `source.url`
170///
171/// # uv Format
172///
173/// - `source.registry = "https://pypi.org/simple"` → Registry
174/// - `source.git = "https://github.com/..."` → Git
175/// - `source.path = "..."` → Path
176fn parse_pypi_source(table: &toml_edit::Table) -> ResolvedSource {
177    let Some(source_item) = table.get("source") else {
178        // No source field = PyPI registry (poetry default)
179        return ResolvedSource::Registry {
180            url: "https://pypi.org/simple".to_string(),
181            checksum: String::new(),
182        };
183    };
184
185    // Handle inline table format (uv style)
186    if let Some(source_table) = source_item.as_inline_table() {
187        // uv: source = { registry = "https://pypi.org/simple" }
188        if let Some(registry) = source_table.get("registry").and_then(|v| v.as_str()) {
189            return ResolvedSource::Registry {
190                url: registry.to_string(),
191                checksum: String::new(),
192            };
193        }
194
195        // uv: source = { git = "https://github.com/..." }
196        if let Some(git_url) = source_table.get("git").and_then(|v| v.as_str()) {
197            let rev = source_table
198                .get("rev")
199                .and_then(|v| v.as_str())
200                .unwrap_or("")
201                .to_string();
202
203            return ResolvedSource::Git {
204                url: git_url.to_string(),
205                rev,
206            };
207        }
208
209        // uv: source = { path = "..." }
210        if let Some(path) = source_table.get("path").and_then(|v| v.as_str()) {
211            return ResolvedSource::Path {
212                path: path.to_string(),
213            };
214        }
215    }
216
217    // Handle table format (poetry style)
218    if let Some(source_table) = source_item.as_table() {
219        // poetry: [package.source] type = "git"
220        if let Some(source_type) = source_table.get("type").and_then(|v| v.as_str()) {
221            match source_type {
222                "git" => {
223                    let url = source_table
224                        .get("url")
225                        .and_then(|v| v.as_str())
226                        .unwrap_or("")
227                        .to_string();
228
229                    let rev = source_table
230                        .get("resolved_reference")
231                        .or_else(|| source_table.get("reference"))
232                        .and_then(|v| v.as_str())
233                        .unwrap_or("")
234                        .to_string();
235
236                    return ResolvedSource::Git { url, rev };
237                }
238                "directory" | "file" => {
239                    let path = source_table
240                        .get("url")
241                        .and_then(|v| v.as_str())
242                        .unwrap_or("")
243                        .to_string();
244
245                    return ResolvedSource::Path { path };
246                }
247                _ => {}
248            }
249        }
250    }
251
252    // Default to PyPI registry
253    ResolvedSource::Registry {
254        url: "https://pypi.org/simple".to_string(),
255        checksum: String::new(),
256    }
257}
258
259/// Parses dependencies from package table.
260///
261/// Handles both Poetry and uv dependency formats:
262///
263/// # Poetry Format
264///
265/// ```toml
266/// [package.dependencies]
267/// certifi = ">=2017.4.17"
268/// charset-normalizer = ">=2,<4"
269/// ```
270///
271/// # uv Format
272///
273/// ```toml
274/// dependencies = [
275///     { name = "certifi" },
276///     { name = "charset-normalizer" },
277/// ]
278/// ```
279fn parse_pypi_dependencies(table: &toml_edit::Table) -> Vec<String> {
280    // Try uv format first (dependencies array)
281    if let Some(deps_value) = table.get("dependencies")
282        && let Some(deps_array) = deps_value.as_array()
283    {
284        return deps_array
285            .iter()
286            .filter_map(|item| {
287                // uv format: { name = "certifi" }
288                if let Some(dep_table) = item.as_inline_table()
289                    && let Some(name) = dep_table.get("name").and_then(|v| v.as_str())
290                {
291                    return Some(name.to_string());
292                }
293
294                // Simple string format (fallback)
295                if let Some(s) = item.as_str() {
296                    return Some(s.to_string());
297                }
298
299                None
300            })
301            .collect();
302    }
303
304    // Try poetry format (package.dependencies table)
305    if let Some(deps_item) = table.get("dependencies")
306        && let Some(deps_table) = deps_item.as_table()
307    {
308        return deps_table
309            .iter()
310            .map(|(name, _)| name.to_string())
311            .collect();
312    }
313
314    vec![]
315}
316
317#[cfg(test)]
318mod tests {
319    use super::*;
320
321    #[tokio::test]
322    async fn test_parse_simple_poetry_lock() {
323        let lockfile_content = r#"
324# This file is automatically generated by poetry.
325[[package]]
326name = "requests"
327version = "2.31.0"
328description = "Python HTTP for Humans."
329
330[package.dependencies]
331certifi = ">=2017.4.17"
332charset-normalizer = ">=2,<4"
333
334[[package]]
335name = "certifi"
336version = "2023.7.22"
337description = "Python package for providing Mozilla's CA Bundle."
338
339[metadata]
340lock-version = "2.0"
341python-versions = "^3.9"
342"#;
343
344        let temp_dir = tempfile::tempdir().unwrap();
345        let lockfile_path = temp_dir.path().join("poetry.lock");
346        std::fs::write(&lockfile_path, lockfile_content).unwrap();
347
348        let parser = PypiLockParser;
349        let resolved = parser.parse_lockfile(&lockfile_path).await.unwrap();
350
351        assert_eq!(resolved.len(), 2);
352        assert_eq!(resolved.get_version("requests"), Some("2.31.0"));
353        assert_eq!(resolved.get_version("certifi"), Some("2023.7.22"));
354
355        let requests_pkg = resolved.get("requests").unwrap();
356        assert_eq!(requests_pkg.dependencies.len(), 2);
357        assert!(requests_pkg.dependencies.contains(&"certifi".to_string()));
358        assert!(
359            requests_pkg
360                .dependencies
361                .contains(&"charset-normalizer".to_string())
362        );
363
364        // Verify it's a registry source
365        match &requests_pkg.source {
366            ResolvedSource::Registry { url, .. } => {
367                assert_eq!(url, "https://pypi.org/simple");
368            }
369            _ => panic!("Expected Registry source"),
370        }
371    }
372
373    #[tokio::test]
374    async fn test_parse_uv_lock() {
375        let lockfile_content = r#"
376version = 1
377
378[[package]]
379name = "requests"
380version = "2.31.0"
381source = { registry = "https://pypi.org/simple" }
382dependencies = [
383    { name = "certifi" },
384    { name = "charset-normalizer" },
385]
386
387[[package]]
388name = "certifi"
389version = "2023.7.22"
390source = { registry = "https://pypi.org/simple" }
391"#;
392
393        let temp_dir = tempfile::tempdir().unwrap();
394        let lockfile_path = temp_dir.path().join("uv.lock");
395        std::fs::write(&lockfile_path, lockfile_content).unwrap();
396
397        let parser = PypiLockParser;
398        let resolved = parser.parse_lockfile(&lockfile_path).await.unwrap();
399
400        assert_eq!(resolved.len(), 2);
401        assert_eq!(resolved.get_version("requests"), Some("2.31.0"));
402        assert_eq!(resolved.get_version("certifi"), Some("2023.7.22"));
403
404        let requests_pkg = resolved.get("requests").unwrap();
405        assert_eq!(requests_pkg.dependencies.len(), 2);
406        assert!(requests_pkg.dependencies.contains(&"certifi".to_string()));
407
408        match &requests_pkg.source {
409            ResolvedSource::Registry { url, .. } => {
410                assert_eq!(url, "https://pypi.org/simple");
411            }
412            _ => panic!("Expected Registry source"),
413        }
414    }
415
416    #[tokio::test]
417    async fn test_parse_poetry_lock_with_git() {
418        let lockfile_content = r#"
419[[package]]
420name = "my-git-dep"
421version = "0.1.0"
422description = "Git dependency"
423
424[package.source]
425type = "git"
426url = "https://github.com/user/repo"
427resolved_reference = "abc123def456"
428"#;
429
430        let temp_dir = tempfile::tempdir().unwrap();
431        let lockfile_path = temp_dir.path().join("poetry.lock");
432        std::fs::write(&lockfile_path, lockfile_content).unwrap();
433
434        let parser = PypiLockParser;
435        let resolved = parser.parse_lockfile(&lockfile_path).await.unwrap();
436
437        // Names are normalized: - → _
438        assert_eq!(resolved.len(), 1);
439        let pkg = resolved.get("my_git_dep").unwrap();
440        assert_eq!(pkg.version, "0.1.0");
441
442        match &pkg.source {
443            ResolvedSource::Git { url, rev } => {
444                assert_eq!(url, "https://github.com/user/repo");
445                assert_eq!(rev, "abc123def456");
446            }
447            _ => panic!("Expected Git source"),
448        }
449    }
450
451    #[tokio::test]
452    async fn test_parse_uv_lock_with_git() {
453        let lockfile_content = r#"
454version = 1
455
456[[package]]
457name = "my-git-dep"
458version = "0.1.0"
459source = { git = "https://github.com/user/repo", rev = "abc123" }
460"#;
461
462        let temp_dir = tempfile::tempdir().unwrap();
463        let lockfile_path = temp_dir.path().join("uv.lock");
464        std::fs::write(&lockfile_path, lockfile_content).unwrap();
465
466        let parser = PypiLockParser;
467        let resolved = parser.parse_lockfile(&lockfile_path).await.unwrap();
468
469        // Names are normalized: - → _
470        assert_eq!(resolved.len(), 1);
471        let pkg = resolved.get("my_git_dep").unwrap();
472
473        match &pkg.source {
474            ResolvedSource::Git { url, rev } => {
475                assert_eq!(url, "https://github.com/user/repo");
476                assert_eq!(rev, "abc123");
477            }
478            _ => panic!("Expected Git source"),
479        }
480    }
481
482    #[tokio::test]
483    async fn test_parse_poetry_lock_with_path() {
484        let lockfile_content = r#"
485[[package]]
486name = "my-local-dep"
487version = "0.1.0"
488
489[package.source]
490type = "directory"
491url = "../local-package"
492"#;
493
494        let temp_dir = tempfile::tempdir().unwrap();
495        let lockfile_path = temp_dir.path().join("poetry.lock");
496        std::fs::write(&lockfile_path, lockfile_content).unwrap();
497
498        let parser = PypiLockParser;
499        let resolved = parser.parse_lockfile(&lockfile_path).await.unwrap();
500
501        // Names are normalized: - → _
502        assert_eq!(resolved.len(), 1);
503        let pkg = resolved.get("my_local_dep").unwrap();
504
505        match &pkg.source {
506            ResolvedSource::Path { path } => {
507                assert_eq!(path, "../local-package");
508            }
509            _ => panic!("Expected Path source"),
510        }
511    }
512
513    #[tokio::test]
514    async fn test_parse_uv_lock_with_path() {
515        let lockfile_content = r#"
516version = 1
517
518[[package]]
519name = "my-local-dep"
520version = "0.1.0"
521source = { path = "../local-package" }
522"#;
523
524        let temp_dir = tempfile::tempdir().unwrap();
525        let lockfile_path = temp_dir.path().join("uv.lock");
526        std::fs::write(&lockfile_path, lockfile_content).unwrap();
527
528        let parser = PypiLockParser;
529        let resolved = parser.parse_lockfile(&lockfile_path).await.unwrap();
530
531        // Names are normalized: - → _
532        assert_eq!(resolved.len(), 1);
533        let pkg = resolved.get("my_local_dep").unwrap();
534
535        match &pkg.source {
536            ResolvedSource::Path { path } => {
537                assert_eq!(path, "../local-package");
538            }
539            _ => panic!("Expected Path source"),
540        }
541    }
542
543    #[tokio::test]
544    async fn test_parse_empty_lock_file() {
545        let lockfile_content = r"
546version = 1
547";
548
549        let temp_dir = tempfile::tempdir().unwrap();
550        let lockfile_path = temp_dir.path().join("poetry.lock");
551        std::fs::write(&lockfile_path, lockfile_content).unwrap();
552
553        let parser = PypiLockParser;
554        let resolved = parser.parse_lockfile(&lockfile_path).await.unwrap();
555
556        assert_eq!(resolved.len(), 0);
557        assert!(resolved.is_empty());
558    }
559
560    #[tokio::test]
561    async fn test_parse_malformed_toml() {
562        let lockfile_content = "not valid toml {{{";
563
564        let temp_dir = tempfile::tempdir().unwrap();
565        let lockfile_path = temp_dir.path().join("poetry.lock");
566        std::fs::write(&lockfile_path, lockfile_content).unwrap();
567
568        let parser = PypiLockParser;
569        let result = parser.parse_lockfile(&lockfile_path).await;
570
571        assert!(result.is_err());
572    }
573
574    #[test]
575    fn test_locate_lockfile_poetry_priority() {
576        let temp_dir = tempfile::tempdir().unwrap();
577        let manifest_path = temp_dir.path().join("pyproject.toml");
578        let poetry_lock = temp_dir.path().join("poetry.lock");
579        let uv_lock = temp_dir.path().join("uv.lock");
580
581        std::fs::write(&manifest_path, "[project]\nname = \"test\"").unwrap();
582        std::fs::write(&poetry_lock, "# poetry.lock").unwrap();
583        std::fs::write(&uv_lock, "# uv.lock").unwrap();
584
585        let manifest_uri = Uri::from_file_path(&manifest_path).unwrap();
586        let parser = PypiLockParser;
587
588        let located = parser.locate_lockfile(&manifest_uri);
589        assert!(located.is_some());
590        assert_eq!(
591            located.unwrap(),
592            poetry_lock,
593            "poetry.lock should take priority over uv.lock"
594        );
595    }
596
597    #[test]
598    fn test_locate_lockfile_uv_fallback() {
599        let temp_dir = tempfile::tempdir().unwrap();
600        let manifest_path = temp_dir.path().join("pyproject.toml");
601        let uv_lock = temp_dir.path().join("uv.lock");
602
603        std::fs::write(&manifest_path, "[project]\nname = \"test\"").unwrap();
604        std::fs::write(&uv_lock, "# uv.lock").unwrap();
605
606        let manifest_uri = Uri::from_file_path(&manifest_path).unwrap();
607        let parser = PypiLockParser;
608
609        let located = parser.locate_lockfile(&manifest_uri);
610        assert!(located.is_some());
611        assert_eq!(located.unwrap(), uv_lock);
612    }
613
614    #[test]
615    fn test_locate_lockfile_not_found() {
616        let temp_dir = tempfile::tempdir().unwrap();
617        let manifest_path = temp_dir.path().join("pyproject.toml");
618        std::fs::write(&manifest_path, "[project]\nname = \"test\"").unwrap();
619
620        let manifest_uri = Uri::from_file_path(&manifest_path).unwrap();
621        let parser = PypiLockParser;
622
623        let located = parser.locate_lockfile(&manifest_uri);
624        assert!(located.is_none());
625    }
626
627    #[tokio::test]
628    async fn test_parse_poetry_lock_missing_fields() {
629        let lockfile_content = r#"
630[[package]]
631name = "valid-package"
632version = "1.0.0"
633
634[[package]]
635# Missing name field
636version = "2.0.0"
637
638[[package]]
639name = "missing-version"
640# Missing version field
641"#;
642
643        let temp_dir = tempfile::tempdir().unwrap();
644        let lockfile_path = temp_dir.path().join("poetry.lock");
645        std::fs::write(&lockfile_path, lockfile_content).unwrap();
646
647        let parser = PypiLockParser;
648        let resolved = parser.parse_lockfile(&lockfile_path).await.unwrap();
649
650        // Should only parse valid package (names are normalized: - → _)
651        assert_eq!(resolved.len(), 1);
652        assert_eq!(resolved.get_version("valid_package"), Some("1.0.0"));
653        assert!(resolved.get("missing_version").is_none());
654    }
655
656    #[test]
657    fn test_is_lockfile_stale_not_modified() {
658        let temp_dir = tempfile::tempdir().unwrap();
659        let lockfile_path = temp_dir.path().join("poetry.lock");
660        std::fs::write(&lockfile_path, "version = 1").unwrap();
661
662        let mtime = std::fs::metadata(&lockfile_path)
663            .unwrap()
664            .modified()
665            .unwrap();
666        let parser = PypiLockParser;
667
668        assert!(
669            !parser.is_lockfile_stale(&lockfile_path, mtime),
670            "Lock file should not be stale when mtime matches"
671        );
672    }
673
674    #[test]
675    fn test_is_lockfile_stale_modified() {
676        let temp_dir = tempfile::tempdir().unwrap();
677        let lockfile_path = temp_dir.path().join("poetry.lock");
678        std::fs::write(&lockfile_path, "version = 1").unwrap();
679
680        let old_time = std::time::UNIX_EPOCH;
681        let parser = PypiLockParser;
682
683        assert!(
684            parser.is_lockfile_stale(&lockfile_path, old_time),
685            "Lock file should be stale when last_modified is old"
686        );
687    }
688
689    #[test]
690    fn test_is_lockfile_stale_deleted() {
691        let parser = PypiLockParser;
692        let non_existent = std::path::Path::new("/nonexistent/poetry.lock");
693
694        assert!(
695            parser.is_lockfile_stale(non_existent, std::time::SystemTime::now()),
696            "Non-existent lock file should be considered stale"
697        );
698    }
699}