use check_updates_core::Version;
use anyhow::{Context, Result};
use serde::Deserialize;
use std::collections::HashMap;
use std::fs;
use std::path::Path;
use std::str::FromStr;
pub struct LockfileParser;
#[derive(Debug, Deserialize)]
struct TomlPackage {
name: String,
version: String,
}
#[derive(Debug, Deserialize)]
struct TomlLockFile {
package: Vec<TomlPackage>,
}
#[derive(Debug, Deserialize)]
struct PdmLockFile {
package: Vec<PdmPackage>,
}
#[derive(Debug, Deserialize)]
struct PdmPackage {
name: String,
version: String,
}
impl LockfileParser {
pub fn new() -> Self {
Self
}
pub fn parse(&self, path: &Path) -> Result<HashMap<String, Version>> {
let filename = path
.file_name()
.and_then(|n| n.to_str())
.context("Invalid lock file path")?;
match filename {
"uv.lock" => self.parse_uv_lock(path),
"poetry.lock" => self.parse_poetry_lock(path),
"pdm.lock" => self.parse_pdm_lock(path),
_ => anyhow::bail!("Unsupported lock file: {filename}"),
}
}
pub fn find_and_parse(&self, dir: &Path) -> Result<HashMap<String, Version>> {
let lock_files = ["uv.lock", "poetry.lock", "pdm.lock"];
for filename in &lock_files {
let lock_path = dir.join(filename);
if lock_path.exists() {
return self.parse(&lock_path);
}
}
Ok(HashMap::new())
}
pub fn can_parse(&self, path: &Path) -> bool {
path.file_name()
.and_then(|n| n.to_str())
.map(|n| {
n == "uv.lock"
|| n == "poetry.lock"
|| n == "pdm.lock"
|| n == "Pipfile.lock"
|| n == "conda-lock.yml"
})
.unwrap_or(false)
}
fn parse_uv_lock(&self, path: &Path) -> Result<HashMap<String, Version>> {
let content = fs::read_to_string(path)
.with_context(|| format!("Failed to read uv.lock at {path:?}"))?;
let lock_file: TomlLockFile = toml::from_str(&content)
.with_context(|| format!("Failed to parse uv.lock at {path:?}"))?;
let mut versions = HashMap::new();
for package in lock_file.package {
let name = package.name.to_lowercase().replace('_', "-");
match Version::from_str(&package.version) {
Ok(version) => {
versions.insert(name, version);
}
Err(e) => {
eprintln!(
"Warning: Failed to parse version '{}' for package '{}': {}",
package.version, package.name, e
);
}
}
}
Ok(versions)
}
fn parse_poetry_lock(&self, path: &Path) -> Result<HashMap<String, Version>> {
let content = fs::read_to_string(path)
.with_context(|| format!("Failed to read poetry.lock at {path:?}"))?;
let lock_file: TomlLockFile = toml::from_str(&content)
.with_context(|| format!("Failed to parse poetry.lock at {path:?}"))?;
let mut versions = HashMap::new();
for package in lock_file.package {
let name = package.name.to_lowercase().replace('_', "-");
match Version::from_str(&package.version) {
Ok(version) => {
versions.insert(name, version);
}
Err(e) => {
eprintln!(
"Warning: Failed to parse version '{}' for package '{}': {}",
package.version, package.name, e
);
}
}
}
Ok(versions)
}
fn parse_pdm_lock(&self, path: &Path) -> Result<HashMap<String, Version>> {
let content = fs::read_to_string(path)
.with_context(|| format!("Failed to read pdm.lock at {path:?}"))?;
let lock_file: PdmLockFile = toml::from_str(&content)
.with_context(|| format!("Failed to parse pdm.lock at {path:?}"))?;
let mut versions = HashMap::new();
for package in lock_file.package {
let name = package.name.to_lowercase().replace('_', "-");
match Version::from_str(&package.version) {
Ok(version) => {
versions.insert(name, version);
}
Err(e) => {
eprintln!(
"Warning: Failed to parse version '{}' for package '{}': {}",
package.version, package.name, e
);
}
}
}
Ok(versions)
}
}
impl Default for LockfileParser {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
use std::path::PathBuf;
use tempfile::NamedTempFile;
#[test]
fn test_parse_uv_lock() {
let lock_content = r#"
version = 1
[[package]]
name = "requests"
version = "2.31.0"
[[package]]
name = "numpy"
version = "1.24.3"
[[package]]
name = "flask"
version = "2.3.0"
"#;
let mut temp_file = NamedTempFile::new().unwrap();
temp_file.write_all(lock_content.as_bytes()).unwrap();
let path = temp_file.path().to_path_buf();
let parser = LockfileParser::new();
let versions = parser.parse_uv_lock(&path).unwrap();
assert_eq!(versions.len(), 3);
assert_eq!(versions.get("requests").unwrap().to_string(), "2.31.0");
assert_eq!(versions.get("numpy").unwrap().to_string(), "1.24.3");
assert_eq!(versions.get("flask").unwrap().to_string(), "2.3.0");
}
#[test]
fn test_parse_poetry_lock() {
let lock_content = r#"
[[package]]
name = "requests"
version = "2.31.0"
description = "Python HTTP for Humans."
[[package]]
name = "Django"
version = "4.2.0"
description = "A high-level Python Web framework"
"#;
let mut temp_file = NamedTempFile::new().unwrap();
temp_file.write_all(lock_content.as_bytes()).unwrap();
let path = temp_file.path().to_path_buf();
let parser = LockfileParser::new();
let versions = parser.parse_poetry_lock(&path).unwrap();
assert_eq!(versions.len(), 2);
assert_eq!(versions.get("requests").unwrap().to_string(), "2.31.0");
assert_eq!(versions.get("django").unwrap().to_string(), "4.2.0");
}
#[test]
fn test_parse_pdm_lock() {
let lock_content = r#"
[[package]]
name = "click"
version = "8.1.3"
[[package]]
name = "Flask"
version = "2.3.0"
"#;
let mut temp_file = NamedTempFile::new().unwrap();
temp_file.write_all(lock_content.as_bytes()).unwrap();
let path = temp_file.path().to_path_buf();
let parser = LockfileParser::new();
let versions = parser.parse_pdm_lock(&path).unwrap();
assert_eq!(versions.len(), 2);
assert_eq!(versions.get("click").unwrap().to_string(), "8.1.3");
assert_eq!(versions.get("flask").unwrap().to_string(), "2.3.0");
}
#[test]
fn test_can_parse() {
let parser = LockfileParser::new();
assert!(parser.can_parse(&PathBuf::from("uv.lock")));
assert!(parser.can_parse(&PathBuf::from("poetry.lock")));
assert!(parser.can_parse(&PathBuf::from("pdm.lock")));
assert!(!parser.can_parse(&PathBuf::from("requirements.txt")));
}
#[test]
fn test_find_and_parse() {
let temp_dir = tempfile::tempdir().unwrap();
let dir_path = temp_dir.path().to_path_buf();
let lock_path = dir_path.join("uv.lock");
let lock_content = r#"
[[package]]
name = "requests"
version = "2.31.0"
"#;
fs::write(&lock_path, lock_content).unwrap();
let parser = LockfileParser::new();
let versions = parser.find_and_parse(&dir_path).unwrap();
assert_eq!(versions.len(), 1);
assert_eq!(versions.get("requests").unwrap().to_string(), "2.31.0");
}
#[test]
fn test_find_and_parse_no_lockfile() {
let temp_dir = tempfile::tempdir().unwrap();
let dir_path = temp_dir.path().to_path_buf();
let parser = LockfileParser::new();
let versions = parser.find_and_parse(&dir_path).unwrap();
assert_eq!(versions.len(), 0);
}
}