use std::collections::BTreeSet;
use std::fs;
use std::path::{Path, PathBuf};
use serde_json::Value as JsonValue;
use toml::Value as TomlValue;
pub(crate) const LANGUAGES: &[&str] = &["rust", "python", "typescript"];
const SKIP_DIRS: &[&str] = &[
".git",
"target",
"node_modules",
"__pycache__",
".venv",
"venv",
".mypy_cache",
".pytest_cache",
"dist",
"build",
];
#[derive(Debug, Clone)]
pub(crate) struct ManifestProject {
pub root: PathBuf,
pub name: String,
pub language: &'static str,
pub dependencies: Vec<(String, String)>,
}
pub(crate) fn discover_manifests(
path: &Path,
languages: &BTreeSet<&'static str>,
) -> std::io::Result<Vec<ManifestProject>> {
let mut out = Vec::new();
walk_dir(path, languages, &mut out)?;
Ok(out)
}
fn walk_dir(
dir: &Path,
languages: &BTreeSet<&'static str>,
out: &mut Vec<ManifestProject>,
) -> std::io::Result<()> {
if languages.contains("rust") {
let cargo_toml = dir.join("Cargo.toml");
if cargo_toml.is_file() {
if let Ok(text) = fs::read_to_string(&cargo_toml) {
if let Some(project) = parse_cargo_toml(dir, &text) {
out.push(project);
}
}
}
}
if languages.contains("python") {
let pyproject = dir.join("pyproject.toml");
if pyproject.is_file() {
if let Ok(text) = fs::read_to_string(&pyproject) {
if let Some(project) = parse_pyproject_toml(dir, &text) {
out.push(project);
}
}
}
}
if languages.contains("typescript") {
let package_json = dir.join("package.json");
if package_json.is_file() {
if let Ok(text) = fs::read_to_string(&package_json) {
if let Some(project) = parse_package_json(dir, &text) {
out.push(project);
}
}
}
}
for entry in fs::read_dir(dir)? {
let entry = entry?;
let file_type = entry.file_type()?;
if !file_type.is_dir() {
continue;
}
let name = entry.file_name();
let name = name.to_string_lossy();
if SKIP_DIRS.contains(&name.as_ref()) || name.starts_with('.') {
continue;
}
walk_dir(&entry.path(), languages, out)?;
}
Ok(())
}
pub(crate) fn parse_cargo_toml(root: &Path, text: &str) -> Option<ManifestProject> {
let doc: TomlValue = text.parse().ok()?;
let name = doc.get("package")?.get("name")?.as_str()?.to_string();
let mut dependencies = Vec::new();
for (section, kind) in [
("dependencies", "dependencies"),
("dev-dependencies", "dev-dependencies"),
("build-dependencies", "build-dependencies"),
] {
if let Some(TomlValue::Table(table)) = doc.get(section) {
for dep_name in table.keys() {
dependencies.push((dep_name.clone(), kind.to_string()));
}
}
}
Some(ManifestProject {
root: root.to_path_buf(),
name,
language: "rust",
dependencies,
})
}
fn pep508_name(spec: &str) -> Option<String> {
let end = spec
.find(|c: char| c.is_whitespace() || "<>=!~;[".contains(c))
.unwrap_or(spec.len());
let name = spec[..end].trim();
if name.is_empty() {
None
} else {
Some(name.to_string())
}
}
pub(crate) fn parse_pyproject_toml(root: &Path, text: &str) -> Option<ManifestProject> {
let doc: TomlValue = text.parse().ok()?;
let project = doc.get("project")?;
let name = project.get("name")?.as_str()?.to_string();
let mut dependencies = Vec::new();
if let Some(TomlValue::Array(arr)) = project.get("dependencies") {
for item in arr {
if let Some(spec) = item.as_str() {
if let Some(dep_name) = pep508_name(spec) {
dependencies.push((dep_name, "dependencies".to_string()));
}
}
}
}
if let Some(TomlValue::Table(groups)) = project.get("optional-dependencies") {
for (group, arr) in groups {
if let TomlValue::Array(arr) = arr {
for item in arr {
if let Some(spec) = item.as_str() {
if let Some(dep_name) = pep508_name(spec) {
dependencies.push((dep_name, format!("optional-dependencies:{group}")));
}
}
}
}
}
}
Some(ManifestProject {
root: root.to_path_buf(),
name,
language: "python",
dependencies,
})
}
pub(crate) fn parse_package_json(root: &Path, text: &str) -> Option<ManifestProject> {
let doc: JsonValue = serde_json::from_str(text).ok()?;
let name = doc.get("name")?.as_str()?.to_string();
let mut dependencies = Vec::new();
for (section, kind) in [
("dependencies", "dependencies"),
("devDependencies", "devDependencies"),
("peerDependencies", "peerDependencies"),
("optionalDependencies", "optionalDependencies"),
] {
if let Some(JsonValue::Object(obj)) = doc.get(section) {
for dep_name in obj.keys() {
dependencies.push((dep_name.clone(), kind.to_string()));
}
}
}
Some(ManifestProject {
root: root.to_path_buf(),
name,
language: "typescript",
dependencies,
})
}
pub(crate) fn find_governing_manifest(
file_dir: &Path,
ingest_root: &Path,
language: &str,
) -> Option<(PathBuf, String)> {
let mut dir = Some(file_dir);
while let Some(d) = dir {
let found = match language {
"rust" => fs::read_to_string(d.join("Cargo.toml"))
.ok()
.and_then(|t| parse_cargo_toml(d, &t)),
"python" => fs::read_to_string(d.join("pyproject.toml"))
.ok()
.and_then(|t| parse_pyproject_toml(d, &t)),
"typescript" => fs::read_to_string(d.join("package.json"))
.ok()
.and_then(|t| parse_package_json(d, &t)),
_ => None,
};
if let Some(project) = found {
return Some((project.root, project.name));
}
if d == ingest_root {
break;
}
dir = d.parent();
}
None
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn cargo_toml_without_package_is_not_governing() {
let text = "[workspace]\nmembers = [\"a\", \"b\"]\n";
assert!(parse_cargo_toml(Path::new("/tmp"), text).is_none());
}
#[test]
fn cargo_toml_with_package_collects_dependency_kinds() {
let text = r#"
[package]
name = "foo"
[dependencies]
serde = "1.0"
[dev-dependencies]
tempfile = "3"
"#;
let project = parse_cargo_toml(Path::new("/tmp"), text).expect("governing");
assert_eq!(project.name, "foo");
assert_eq!(project.language, "rust");
assert!(project
.dependencies
.contains(&("serde".to_string(), "dependencies".to_string())));
assert!(project
.dependencies
.contains(&("tempfile".to_string(), "dev-dependencies".to_string())));
}
#[test]
fn pyproject_toml_extracts_pep508_names() {
let text = r#"
[project]
name = "bar"
dependencies = ["requests>=2.0", "click"]
"#;
let project = parse_pyproject_toml(Path::new("/tmp"), text).expect("governing");
assert_eq!(project.name, "bar");
assert!(project
.dependencies
.contains(&("requests".to_string(), "dependencies".to_string())));
assert!(project
.dependencies
.contains(&("click".to_string(), "dependencies".to_string())));
}
#[test]
fn package_json_extracts_dependency_sections() {
let text = r#"{"name": "baz", "dependencies": {"left-pad": "1.0.0"}, "devDependencies": {"jest": "29.0.0"}}"#;
let project = parse_package_json(Path::new("/tmp"), text).expect("governing");
assert_eq!(project.name, "baz");
assert!(project
.dependencies
.contains(&("left-pad".to_string(), "dependencies".to_string())));
assert!(project
.dependencies
.contains(&("jest".to_string(), "devDependencies".to_string())));
}
}