use std::collections::BTreeSet;
use std::path::{Path, PathBuf};
use serde_json::Value;
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Dependencies {
pub packages: BTreeSet<(String, String)>,
pub files: Vec<PathBuf>,
}
const FILES: [&str; 10] = [
"Cargo.lock",
"go.mod",
"go.sum",
"requirements.txt",
"poetry.lock",
"uv.lock",
"Pipfile.lock",
"Gemfile.lock",
"pom.xml",
"gradle.lockfile",
];
fn is_dependency_file(name: &str) -> bool {
FILES.contains(&name) || (name.starts_with("requirements") && name.ends_with(".txt"))
}
impl Dependencies {
pub fn find(root: &Path) -> Dependencies {
let mut deps = Dependencies::default();
let paths: Vec<PathBuf> = if root.is_file() {
vec![root.to_path_buf()]
} else {
ignore::WalkBuilder::new(root)
.build()
.filter_map(|e| e.ok())
.filter(|e| e.file_type().is_some_and(|t| t.is_file()))
.map(|e| e.into_path())
.filter(|p| {
p.file_name()
.and_then(|n| n.to_str())
.is_some_and(is_dependency_file)
})
.collect()
};
for path in paths {
if let Ok(text) = std::fs::read_to_string(&path)
&& let Some(name) = path.file_name().and_then(|n| n.to_str())
{
deps.add_file(name, &text);
deps.files.push(path);
}
}
deps.files.sort();
deps
}
pub fn find_around(root: &Path) -> Dependencies {
let mut deps = Dependencies::find(root);
let start = if root.is_file() {
root.parent()
} else {
Some(root)
};
let Some(start) = start.and_then(|p| p.canonicalize().ok()) else {
return deps;
};
if start.join(".git").exists() {
return deps;
}
for dir in start.ancestors().skip(1) {
let mut above = Dependencies::default();
for name in std::fs::read_dir(dir).into_iter().flatten().flatten() {
let path = name.path();
if path.is_file()
&& let Some(file) = path.file_name().and_then(|n| n.to_str())
&& is_dependency_file(file)
&& let Ok(text) = std::fs::read_to_string(&path)
{
above.add_file(file, &text);
above.files.push(path);
}
}
deps.extend(above);
if dir.join(".git").exists() {
break;
}
}
deps
}
pub fn add_file(&mut self, file_name: &str, text: &str) {
let found = match file_name {
"Cargo.lock" => cargo_lock(text),
"go.mod" => go_mod(text),
"go.sum" => go_sum(text),
"poetry.lock" | "uv.lock" => toml_packages(text, "pypi"),
"Pipfile.lock" => pipfile_lock(text),
"Gemfile.lock" => gemfile_lock(text),
"pom.xml" => pom_xml(text),
"gradle.lockfile" => gradle_lockfile(text),
name if is_dependency_file(name) => requirements(text),
_ => Vec::new(),
};
self.packages.extend(found);
}
pub fn contains(&self, ecosystem: &str, name: &str) -> bool {
self.packages
.contains(&(ecosystem.to_string(), name.to_string()))
}
pub fn extend(&mut self, other: Dependencies) {
self.packages.extend(other.packages);
self.files.extend(other.files);
self.files.sort();
self.files.dedup();
}
}
fn pair(ecosystem: &str, name: impl Into<String>) -> (String, String) {
(ecosystem.to_string(), name.into())
}
fn pypi_name(name: &str) -> String {
let mut out = String::with_capacity(name.len());
let mut separator = false;
for c in name.chars() {
if matches!(c, '-' | '_' | '.') {
separator = true;
} else {
if separator && !out.is_empty() {
out.push('-');
}
separator = false;
out.extend(c.to_lowercase());
}
}
out
}
fn cargo_lock(text: &str) -> Vec<(String, String)> {
let Ok(value) = text.parse::<toml::Table>() else {
return Vec::new();
};
package_names(&value)
.into_iter()
.map(|name| pair("crates", name))
.collect()
}
fn toml_packages(text: &str, ecosystem: &str) -> Vec<(String, String)> {
let Ok(value) = text.parse::<toml::Table>() else {
return Vec::new();
};
package_names(&value)
.into_iter()
.map(|name| pair(ecosystem, pypi_name(&name)))
.collect()
}
fn package_names(table: &toml::Table) -> Vec<String> {
table
.get("package")
.and_then(|p| p.as_array())
.map(|packages| {
packages
.iter()
.filter_map(|p| p.get("name")?.as_str().map(String::from))
.collect()
})
.unwrap_or_default()
}
fn go_mod(text: &str) -> Vec<(String, String)> {
let mut found = Vec::new();
let mut in_block = false;
for line in text.lines() {
let line = line.split("//").next().unwrap_or("").trim();
if in_block {
if line == ")" {
in_block = false;
} else if let Some(module) = line.split_whitespace().next() {
found.push(pair("go", module));
}
} else if let Some(rest) = line.strip_prefix("require") {
let rest = rest.trim();
if rest == "(" {
in_block = true;
} else if let Some(module) = rest.split_whitespace().next() {
found.push(pair("go", module));
}
}
}
found
}
fn go_sum(text: &str) -> Vec<(String, String)> {
text.lines()
.filter_map(|line| line.split_whitespace().next())
.map(|module| pair("go", module))
.collect()
}
fn requirements(text: &str) -> Vec<(String, String)> {
text.lines()
.map(|line| line.split(" #").next().unwrap_or("").trim())
.filter(|line| !line.is_empty() && !line.starts_with('#') && !line.starts_with('-'))
.filter(|line| !line.contains("://"))
.filter_map(|line| {
let end = line
.find(|c: char| !(c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.')))
.unwrap_or(line.len());
let name = &line[..end];
(!name.is_empty()).then(|| pair("pypi", pypi_name(name)))
})
.collect()
}
fn pipfile_lock(text: &str) -> Vec<(String, String)> {
let Ok(value) = serde_json::from_str::<Value>(text) else {
return Vec::new();
};
["default", "develop"]
.iter()
.filter_map(|section| value[section].as_object())
.flat_map(|packages| packages.keys())
.map(|name| pair("pypi", pypi_name(name)))
.collect()
}
fn gemfile_lock(text: &str) -> Vec<(String, String)> {
let mut found = Vec::new();
let mut in_gem = false;
for line in text.lines() {
if !line.starts_with(' ') {
in_gem = line.trim() == "GEM";
continue;
}
if in_gem
&& line.starts_with(" ")
&& !line.starts_with(" ")
&& let Some(name) = line.split_whitespace().next()
{
found.push(pair("rubygems", name));
}
}
found
}
fn pom_xml(text: &str) -> Vec<(String, String)> {
let Ok(doc) = roxmltree::Document::parse(text) else {
return Vec::new();
};
doc.descendants()
.filter(|n| n.tag_name().name() == "dependency")
.filter_map(|d| {
let child = |tag: &str| {
d.children()
.find(|c| c.tag_name().name() == tag)
.and_then(|c| c.text())
.map(str::trim)
};
Some(pair(
"maven",
format!("{}:{}", child("groupId")?, child("artifactId")?),
))
})
.collect()
}
fn gradle_lockfile(text: &str) -> Vec<(String, String)> {
text.lines()
.filter(|line| !line.starts_with('#') && !line.starts_with("empty="))
.filter_map(|line| {
let coordinates = line.split('=').next()?;
let mut parts = coordinates.split(':');
let (group, artifact) = (parts.next()?, parts.next()?);
parts.next()?;
Some(pair("maven", format!("{group}:{artifact}")))
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
fn names(found: Vec<(String, String)>) -> Vec<String> {
found.into_iter().map(|(e, n)| format!("{e}:{n}")).collect()
}
#[test]
fn reads_cargo_lock() {
let text = r#"
version = 4
[[package]]
name = "pelicode-core"
version = "0.1.0"
[[package]]
name = "serde_json"
version = "1.0.140"
source = "registry+https://github.com/rust-lang/crates.io-index"
"#;
assert_eq!(
names(cargo_lock(text)),
["crates:pelicode-core", "crates:serde_json"]
);
}
#[test]
fn reads_go_mod_and_sum() {
let gomod = "module example.com/m\n\ngo 1.22\n\nrequire (\n\tgithub.com/google/uuid v1.6.0\n\tgolang.org/x/mod v0.20.0 // indirect\n)\n\nrequire github.com/BurntSushi/toml v1.4.0\n";
assert_eq!(
names(go_mod(gomod)),
[
"go:github.com/google/uuid",
"go:golang.org/x/mod",
"go:github.com/BurntSushi/toml"
]
);
let gosum = "github.com/google/uuid v1.6.0 h1:NIvaJ=\ngithub.com/google/uuid v1.6.0/go.mod h1:TIy=\n";
assert!(
names(go_sum(gosum))
.iter()
.all(|n| n == "go:github.com/google/uuid")
);
}
#[test]
fn reads_python_files() {
let reqs = "# comment\nRequests==2.32.3\nnumpy>=2 # inline\ntyping_extensions[x]; python_version < \"3.12\"\n-r other.txt\n-e .\ngit+https://example.org/x.git\n";
assert_eq!(
names(requirements(reqs)),
["pypi:requests", "pypi:numpy", "pypi:typing-extensions"]
);
let poetry = "[[package]]\nname = \"Charset_Normalizer\"\nversion = \"3.4.0\"\n";
assert_eq!(
names(toml_packages(poetry, "pypi")),
["pypi:charset-normalizer"]
);
let pipfile = r#"{"_meta": {}, "default": {"six": {"version": "==1.17.0"}}, "develop": {"PyTest": {}}}"#;
assert_eq!(names(pipfile_lock(pipfile)), ["pypi:six", "pypi:pytest"]);
}
#[test]
fn reads_gemfile_lock() {
let text = "GEM\n remote: https://rubygems.org/\n specs:\n rack (3.1.8)\n thor (1.3.0)\n rake (>= 12)\n\nPLATFORMS\n ruby\n\nDEPENDENCIES\n rack\n";
assert_eq!(
names(gemfile_lock(text)),
["rubygems:rack", "rubygems:thor"]
);
}
#[test]
fn reads_maven_and_gradle() {
let pom = r#"<project xmlns="http://maven.apache.org/POM/4.0.0"><dependencies>
<dependency><groupId>com.google.code.gson</groupId><artifactId>gson</artifactId><version>2.11.0</version></dependency>
</dependencies></project>"#;
assert_eq!(names(pom_xml(pom)), ["maven:com.google.code.gson:gson"]);
let gradle = "# This is a Gradle generated file\norg.apache.commons:commons-lang3:3.14.0=compileClasspath\nempty=annotationProcessor\n";
assert_eq!(
names(gradle_lockfile(gradle)),
["maven:org.apache.commons:commons-lang3"]
);
}
#[test]
fn finds_files_in_a_tree() {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir_all(dir.path().join("svc/api")).unwrap();
std::fs::write(dir.path().join("svc/api/requirements-dev.txt"), "six\n").unwrap();
std::fs::write(dir.path().join("README.md"), "requests\n").unwrap();
let deps = Dependencies::find(dir.path());
assert!(deps.contains("pypi", "six"));
assert_eq!(
deps.files,
[dir.path().join("svc/api/requirements-dev.txt")]
);
}
}