lockdiff/
lib.rs

1use std::fmt::Display;
2use std::path::PathBuf;
3use std::{ffi::OsStr, path::Path};
4
5use anyhow::{Context, Result, bail};
6
7mod crystal;
8mod dart;
9mod go;
10mod javascript;
11mod php;
12mod python;
13mod ruby;
14mod rust;
15use crystal::parse_shard_lock;
16use dart::parse_pubspec_lock;
17use go::parse_go_sum;
18use javascript::{parse_npm_lock, parse_yarn_lock};
19use php::parse_composer_lock;
20use python::parse_requirements_txt;
21use ruby::parse_gemfile_lock;
22use serde::Deserialize;
23
24#[derive(PartialEq, Eq, Debug, Deserialize)]
25// This is the struct we want to use for the final display
26// of the lock contents.
27// It only contains the name and version of the package.
28struct Package {
29    name: String,
30    version: String,
31}
32
33impl Package {
34    fn new(name: &str, version: &str) -> Self {
35        Self {
36            name: name.to_string(),
37            version: version.to_string(),
38        }
39    }
40}
41
42#[derive(PartialEq, Eq, Debug, Deserialize)]
43// Sometimes we need a struct to represent package metadata while parsing
44// locks because we already got the name. In this case the struct
45// only contains the package version.
46struct PackageMetadata {
47    version: String,
48}
49
50impl Display for Package {
51    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
52        write!(f, "{} ({})", self.name, self.version)
53    }
54}
55
56#[derive(Deserialize, Debug)]
57// poetry, uv and Cargo uses exact same format for the lock file
58struct TomlLock {
59    #[serde(rename = "package")]
60    packages: Vec<Package>,
61}
62
63impl TomlLock {
64    fn packages(self) -> Vec<Package> {
65        self.packages.into_iter().collect()
66    }
67}
68
69fn parse_toml_lock(contents: &str) -> Result<Vec<Package>> {
70    let lock: TomlLock = toml::from_str(contents)?;
71    Ok(lock.packages())
72}
73
74fn parse_lock(name: &str, contents: &str) -> Result<Vec<Package>> {
75    fn is_python_requirements(name: &str) -> bool {
76        name.contains("requirements") && name.ends_with(".txt")
77    }
78
79    match name {
80        "Cargo.lock" | "poetry.lock" | "uv.lock" => parse_toml_lock(contents),
81        "Gemfile.lock" => parse_gemfile_lock(contents),
82        "composer.lock" => parse_composer_lock(contents),
83        "go.sum" => parse_go_sum(contents),
84        "package-lock.json" => parse_npm_lock(contents),
85        "pubspec.lock" => parse_pubspec_lock(contents),
86        "shard.lock" => parse_shard_lock(contents),
87        "yarn.lock" => parse_yarn_lock(contents),
88        name if is_python_requirements(name) => parse_requirements_txt(contents),
89        _ => bail!("Unknown lock name: {name}"),
90    }
91}
92
93pub fn run() -> Result<()> {
94    // Main entry point
95    //
96    // Note that if anything goes wrong, we would rather print the original contents
97    // of the lock file rather than just an error message
98    //
99    // So, first make sure we have *something* to print
100    let args: Vec<_> = std::env::args().collect();
101    if args.len() != 2 {
102        bail!("Expected exactly one arg");
103    }
104    let lock_path = &args[1];
105    let lock_path = PathBuf::from(lock_path);
106    let file_name = lock_path
107        .file_name()
108        .context("|| lock path should have a file name")?;
109    let lock_contents = std::fs::read_to_string(&lock_path).context("Could not read lock file")?;
110
111    // Then, try and convert the lock, and if it fails, just print the contents followed
112    // by an error message
113    if let Err(e) = print_lock(file_name, &lock_path, &lock_contents) {
114        println!("{lock_contents}");
115        eprintln!("Note: {e:#}");
116    }
117    Ok(())
118}
119
120fn print_lock(file_name: &OsStr, lock_path: &Path, contents: &str) -> Result<()> {
121    let file_name = file_name
122        .to_str()
123        .context("File name {file_name:?} should be UTF-8")?;
124    let packages = parse_lock(file_name, contents)
125        .context(format!("Could not parse {}", lock_path.display()))?;
126    for package in packages {
127        println!("{package}");
128    }
129    Ok(())
130}
131
132#[cfg(test)]
133mod tests {
134    use super::*;
135
136    #[test]
137    fn test_parse_toml_lock() {
138        let contents = r#"
139# This file is automatically @generated by Poetry 2.0.1 and should not be changed by hand.
140
141[[package]]
142name = "anyio"
143version = "4.8.0"
144description = "High level compatibility layer for multiple asynchronous event loop implementations"
145files = [
146]
147
148[package.dependencies]
149idna = ">=2.8"
150
151[package.extras]
152doc = ["Sphinx (>=7.4,<8.0)", "packaging", "sphinx-autodoc-typehints (>=1.2.0)", "sphinx_rtd_theme"]
153
154[[package]]
155name = "asgiref"
156version = "3.8.1"
157
158[metadata]
159lock-version = "2.1"
160python-versions = ">= 3.13"
161"#;
162        let packages = parse_toml_lock(contents).unwrap();
163        assert_eq!(
164            &packages,
165            &[
166                Package::new("anyio", "4.8.0"),
167                Package::new("asgiref", "3.8.1")
168            ]
169        );
170    }
171}