use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::Path;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Dependency {
pub name: String,
pub version: String,
pub dev: bool,
pub manifest_path: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Workspace {
pub root: String,
pub members: Vec<String>,
pub kind: String,
}
#[derive(Debug, Clone)]
pub struct ManifestResult {
pub workspaces: Vec<Workspace>,
pub dependencies: Vec<Dependency>,
pub packages: HashMap<String, String>,
}
impl ManifestResult {
pub fn new() -> Self {
Self {
workspaces: Vec::new(),
dependencies: Vec::new(),
packages: HashMap::new(),
}
}
pub fn merge(&mut self, other: ManifestResult) {
self.workspaces.extend(other.workspaces);
self.dependencies.extend(other.dependencies);
self.packages.extend(other.packages);
}
}
impl Default for ManifestResult {
fn default() -> Self {
Self::new()
}
}
pub fn parse_cargo_toml(path: &Path) -> Option<ManifestResult> {
let content = std::fs::read_to_string(path).ok()?;
let manifest_path = path.to_string_lossy().to_string();
let toml_value: toml::Value = toml::from_str(&content).ok()?;
let table = toml_value.as_table()?;
let mut result = ManifestResult::new();
if let Some(package) = table.get("package").and_then(|v| v.as_table()) {
if let Some(name) = package.get("name").and_then(|v| v.as_str()) {
result
.packages
.insert(name.to_string(), manifest_path.clone());
}
}
if let Some(workspace) = table.get("workspace").and_then(|v| v.as_table()) {
if let Some(members) = workspace.get("members").and_then(|v| v.as_array()) {
let member_strings: Vec<String> = members
.iter()
.filter_map(|m| m.as_str().map(|s| s.to_string()))
.collect();
if !member_strings.is_empty() {
let root = path
.parent()
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_default();
result.workspaces.push(Workspace {
root,
members: member_strings,
kind: "cargo".to_string(),
});
}
}
}
if let Some(deps) = table.get("dependencies").and_then(|v| v.as_table()) {
for (name, value) in deps {
let version = extract_cargo_dep_version(value);
result.dependencies.push(Dependency {
name: name.clone(),
version,
dev: false,
manifest_path: manifest_path.clone(),
});
}
}
if let Some(deps) = table.get("dev-dependencies").and_then(|v| v.as_table()) {
for (name, value) in deps {
let version = extract_cargo_dep_version(value);
result.dependencies.push(Dependency {
name: name.clone(),
version,
dev: true,
manifest_path: manifest_path.clone(),
});
}
}
if let Some(deps) = table.get("build-dependencies").and_then(|v| v.as_table()) {
for (name, value) in deps {
let version = extract_cargo_dep_version(value);
result.dependencies.push(Dependency {
name: name.clone(),
version,
dev: false,
manifest_path: manifest_path.clone(),
});
}
}
Some(result)
}
fn extract_cargo_dep_version(value: &toml::Value) -> String {
match value {
toml::Value::String(s) => s.clone(),
toml::Value::Table(t) => t
.get("version")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string(),
_ => String::new(),
}
}
pub fn parse_package_json(path: &Path) -> Option<ManifestResult> {
let content = std::fs::read_to_string(path).ok()?;
let manifest_path = path.to_string_lossy().to_string();
let json: serde_json::Value = serde_json::from_str(&content).ok()?;
let obj = json.as_object()?;
let mut result = ManifestResult::new();
if let Some(name) = obj.get("name").and_then(|v| v.as_str()) {
result
.packages
.insert(name.to_string(), manifest_path.clone());
}
if let Some(workspaces) = obj.get("workspaces") {
let member_strings = match workspaces {
serde_json::Value::Array(arr) => arr
.iter()
.filter_map(|v| v.as_str().map(|s| s.to_string()))
.collect::<Vec<_>>(),
serde_json::Value::Object(obj) => {
obj.get("packages")
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str().map(|s| s.to_string()))
.collect()
})
.unwrap_or_default()
}
_ => Vec::new(),
};
if !member_strings.is_empty() {
let root = path
.parent()
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_default();
result.workspaces.push(Workspace {
root,
members: member_strings,
kind: "npm".to_string(),
});
}
}
if let Some(deps) = obj.get("dependencies").and_then(|v| v.as_object()) {
for (name, value) in deps {
let version = value.as_str().unwrap_or("").to_string();
result.dependencies.push(Dependency {
name: name.clone(),
version,
dev: false,
manifest_path: manifest_path.clone(),
});
}
}
if let Some(deps) = obj.get("devDependencies").and_then(|v| v.as_object()) {
for (name, value) in deps {
let version = value.as_str().unwrap_or("").to_string();
result.dependencies.push(Dependency {
name: name.clone(),
version,
dev: true,
manifest_path: manifest_path.clone(),
});
}
}
if let Some(deps) = obj.get("peerDependencies").and_then(|v| v.as_object()) {
for (name, value) in deps {
let version = value.as_str().unwrap_or("").to_string();
result.dependencies.push(Dependency {
name: name.clone(),
version,
dev: false,
manifest_path: manifest_path.clone(),
});
}
}
Some(result)
}
pub fn parse_go_mod(path: &Path) -> Option<ManifestResult> {
let content = std::fs::read_to_string(path).ok()?;
let manifest_path = path.to_string_lossy().to_string();
let mut result = ManifestResult::new();
for line in content.lines() {
let trimmed = line.trim();
if let Some(module) = trimmed.strip_prefix("module ") {
let module = module.trim();
result
.packages
.insert(module.to_string(), manifest_path.clone());
break;
}
}
let mut in_require_block = false;
for line in content.lines() {
let trimmed = line.trim();
if trimmed == "require (" {
in_require_block = true;
continue;
}
if in_require_block && trimmed == ")" {
in_require_block = false;
continue;
}
if let Some(rest) = trimmed.strip_prefix("require ") {
if !rest.starts_with('(') {
if let Some(dep) = parse_go_require_line(rest, &manifest_path) {
result.dependencies.push(dep);
}
}
continue;
}
if in_require_block {
if trimmed.is_empty() || trimmed.starts_with("//") {
continue;
}
if let Some(dep) = parse_go_require_line(trimmed, &manifest_path) {
result.dependencies.push(dep);
}
}
}
Some(result)
}
fn parse_go_require_line(line: &str, manifest_path: &str) -> Option<Dependency> {
let is_indirect = line.contains("// indirect");
let cleaned = line.split("//").next()?.trim();
let mut parts = cleaned.split_whitespace();
let name = parts.next()?;
let version = parts.next().unwrap_or("");
Some(Dependency {
name: name.to_string(),
version: version.to_string(),
dev: is_indirect,
manifest_path: manifest_path.to_string(),
})
}
pub fn parse_pyproject_toml(path: &Path) -> Option<ManifestResult> {
let content = std::fs::read_to_string(path).ok()?;
let manifest_path = path.to_string_lossy().to_string();
let toml_value: toml::Value = toml::from_str(&content).ok()?;
let table = toml_value.as_table()?;
let mut result = ManifestResult::new();
if let Some(project) = table.get("project").and_then(|v| v.as_table()) {
if let Some(name) = project.get("name").and_then(|v| v.as_str()) {
result
.packages
.insert(name.to_string(), manifest_path.clone());
}
if let Some(deps) = project.get("dependencies").and_then(|v| v.as_array()) {
for dep in deps {
if let Some(spec) = dep.as_str() {
if let Some(d) = parse_python_dep_spec(spec, false, &manifest_path) {
result.dependencies.push(d);
}
}
}
}
if let Some(opt_deps) = project
.get("optional-dependencies")
.and_then(|v| v.as_table())
{
for (_group, deps) in opt_deps {
if let Some(arr) = deps.as_array() {
for dep in arr {
if let Some(spec) = dep.as_str() {
if let Some(d) = parse_python_dep_spec(spec, true, &manifest_path) {
result.dependencies.push(d);
}
}
}
}
}
}
}
if let Some(tool) = table.get("tool").and_then(|v| v.as_table()) {
if let Some(poetry) = tool.get("poetry").and_then(|v| v.as_table()) {
if let Some(name) = poetry.get("name").and_then(|v| v.as_str()) {
result
.packages
.insert(name.to_string(), manifest_path.clone());
}
if let Some(deps) = poetry.get("dependencies").and_then(|v| v.as_table()) {
for (name, value) in deps {
if name == "python" {
continue;
}
let version = extract_poetry_version(value);
result.dependencies.push(Dependency {
name: name.clone(),
version,
dev: false,
manifest_path: manifest_path.clone(),
});
}
}
if let Some(deps) = poetry.get("dev-dependencies").and_then(|v| v.as_table()) {
for (name, value) in deps {
let version = extract_poetry_version(value);
result.dependencies.push(Dependency {
name: name.clone(),
version,
dev: true,
manifest_path: manifest_path.clone(),
});
}
}
}
}
Some(result)
}
fn parse_python_dep_spec(spec: &str, dev: bool, manifest_path: &str) -> Option<Dependency> {
let spec = spec.trim();
if spec.is_empty() {
return None;
}
let name_end = spec
.find(['>', '<', '=', '!', '~', ';', '['])
.unwrap_or(spec.len());
let name = spec[..name_end].trim();
let version_part = &spec[name_end..];
let version_part = if version_part.starts_with('[') {
version_part
.find(']')
.map(|i| &version_part[i + 1..])
.unwrap_or(version_part)
} else {
version_part
};
let version_part = version_part.split(';').next().unwrap_or("").trim();
Some(Dependency {
name: name.to_string(),
version: version_part.to_string(),
dev,
manifest_path: manifest_path.to_string(),
})
}
fn extract_poetry_version(value: &toml::Value) -> String {
match value {
toml::Value::String(s) => s.clone(),
toml::Value::Table(t) => t
.get("version")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string(),
_ => String::new(),
}
}
pub fn parse_pom_xml(path: &Path) -> Option<ManifestResult> {
let content = std::fs::read_to_string(path).ok()?;
let manifest_path = path.to_string_lossy().to_string();
let mut result = ManifestResult::new();
if let Some(artifact_id) = extract_xml_tag_before_deps(&content, "artifactId") {
let group_id = extract_xml_tag_before_deps(&content, "groupId").unwrap_or_default();
let name = if group_id.is_empty() {
artifact_id.clone()
} else {
format!("{group_id}:{artifact_id}")
};
result.packages.insert(name, manifest_path.clone());
}
let re_dep = regex::Regex::new(r"(?s)<dependency>(.*?)</dependency>").ok()?;
for cap in re_dep.captures_iter(&content) {
let dep_block = &cap[1];
let group = extract_xml_tag(dep_block, "groupId").unwrap_or_default();
let artifact = match extract_xml_tag(dep_block, "artifactId") {
Some(a) => a,
None => continue,
};
let version = extract_xml_tag(dep_block, "version").unwrap_or_default();
let scope = extract_xml_tag(dep_block, "scope").unwrap_or_default();
let name = if group.is_empty() {
artifact
} else {
format!("{group}:{artifact}")
};
result.dependencies.push(Dependency {
name,
version,
dev: scope == "test",
manifest_path: manifest_path.clone(),
});
}
Some(result)
}
fn extract_xml_tag(content: &str, tag: &str) -> Option<String> {
let pattern = format!(r"<{tag}>\s*(.*?)\s*</{tag}>");
let re = regex::Regex::new(&pattern).ok()?;
re.captures(content).map(|c| c[1].to_string())
}
fn extract_xml_tag_before_deps(content: &str, tag: &str) -> Option<String> {
let deps_pos = content.find("<dependencies>");
let search_area = match deps_pos {
Some(pos) => &content[..pos],
None => content,
};
extract_xml_tag(search_area, tag)
}
pub fn parse_csproj(path: &Path) -> Option<ManifestResult> {
let content = std::fs::read_to_string(path).ok()?;
let manifest_path = path.to_string_lossy().to_string();
let mut result = ManifestResult::new();
let name = path
.file_stem()
.and_then(|n| n.to_str())
.unwrap_or("unknown");
result
.packages
.insert(name.to_string(), manifest_path.clone());
let re =
regex::Regex::new(r#"<PackageReference\s+Include="([^"]+)"\s+Version="([^"]*)"[^/]*/>"#)
.ok()?;
for cap in re.captures_iter(&content) {
result.dependencies.push(Dependency {
name: cap[1].to_string(),
version: cap[2].to_string(),
dev: false,
manifest_path: manifest_path.clone(),
});
}
let re2 =
regex::Regex::new(r#"<PackageReference\s+Include="([^"]+)"\s*Version="([^"]*)"[^>]*>"#)
.ok()?;
let existing_names: std::collections::HashSet<String> =
result.dependencies.iter().map(|d| d.name.clone()).collect();
for cap in re2.captures_iter(&content) {
let name = cap[1].to_string();
if !existing_names.contains(&name) {
result.dependencies.push(Dependency {
name,
version: cap[2].to_string(),
dev: false,
manifest_path: manifest_path.clone(),
});
}
}
Some(result)
}
pub fn parse_gemfile(path: &Path) -> Option<ManifestResult> {
let content = std::fs::read_to_string(path).ok()?;
let manifest_path = path.to_string_lossy().to_string();
let mut result = ManifestResult::new();
let re = regex::Regex::new(r#"gem\s+['"]([^'"]+)['"](?:\s*,\s*['"]([^'"]*)['"]\s*)?"#).ok()?;
let mut in_dev_group = false;
for line in content.lines() {
let trimmed = line.trim();
if trimmed.starts_with("group")
&& (trimmed.contains(":development") || trimmed.contains(":test"))
{
in_dev_group = true;
continue;
}
if in_dev_group && trimmed == "end" {
in_dev_group = false;
continue;
}
if let Some(cap) = re.captures(trimmed) {
let name = cap[1].to_string();
let version = cap
.get(2)
.map(|m| m.as_str().to_string())
.unwrap_or_default();
result.dependencies.push(Dependency {
name,
version,
dev: in_dev_group,
manifest_path: manifest_path.clone(),
});
}
}
Some(result)
}
pub fn parse_composer_json(path: &Path) -> Option<ManifestResult> {
let content = std::fs::read_to_string(path).ok()?;
let manifest_path = path.to_string_lossy().to_string();
let json: serde_json::Value = serde_json::from_str(&content).ok()?;
let obj = json.as_object()?;
let mut result = ManifestResult::new();
if let Some(name) = obj.get("name").and_then(|v| v.as_str()) {
result
.packages
.insert(name.to_string(), manifest_path.clone());
}
if let Some(deps) = obj.get("require").and_then(|v| v.as_object()) {
for (name, value) in deps {
if name == "php" {
continue;
}
let version = value.as_str().unwrap_or("").to_string();
result.dependencies.push(Dependency {
name: name.clone(),
version,
dev: false,
manifest_path: manifest_path.clone(),
});
}
}
if let Some(deps) = obj.get("require-dev").and_then(|v| v.as_object()) {
for (name, value) in deps {
let version = value.as_str().unwrap_or("").to_string();
result.dependencies.push(Dependency {
name: name.clone(),
version,
dev: true,
manifest_path: manifest_path.clone(),
});
}
}
Some(result)
}
pub fn scan_manifests(root: &Path) -> ManifestResult {
let mut result = ManifestResult::new();
let walker = ignore::WalkBuilder::new(root)
.hidden(true) .git_ignore(true) .git_global(true) .git_exclude(true) .build();
for entry in walker {
let entry = match entry {
Ok(e) => e,
Err(_) => continue,
};
if !entry.file_type().is_some_and(|ft| ft.is_file()) {
continue;
}
let path = entry.path();
let file_name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
match file_name {
"Cargo.toml" => {
if let Some(manifest) = parse_cargo_toml(path) {
result.merge(manifest);
}
}
"package.json" => {
if let Some(manifest) = parse_package_json(path) {
result.merge(manifest);
}
}
"go.mod" => {
if let Some(manifest) = parse_go_mod(path) {
result.merge(manifest);
}
}
"pyproject.toml" => {
if let Some(manifest) = parse_pyproject_toml(path) {
result.merge(manifest);
}
}
"pom.xml" => {
if let Some(manifest) = parse_pom_xml(path) {
result.merge(manifest);
}
}
"Gemfile" => {
if let Some(manifest) = parse_gemfile(path) {
result.merge(manifest);
}
}
"composer.json" => {
if let Some(manifest) = parse_composer_json(path) {
result.merge(manifest);
}
}
_ => {
if file_name.ends_with(".csproj") {
if let Some(manifest) = parse_csproj(path) {
result.merge(manifest);
}
}
}
}
}
result
}
#[cfg(test)]
#[path = "tests/manifest_tests.rs"]
mod tests;