use anyhow::{bail, Context, Result};
use console::style;
use serde::Serialize;
use std::path::{Path, PathBuf};
use std::process::Command;
use crate::run::detect_project_type;
pub struct DepsOptions {
pub outdated: bool,
pub audit: bool,
pub licenses: bool,
pub json: bool,
}
#[derive(Debug, Serialize)]
struct DepsReport {
ecosystem: String,
source: Option<String>,
dependencies: Vec<Dep>,
}
#[derive(Debug, Serialize)]
struct Dep {
name: String,
version: String,
}
pub fn run(opts: DepsOptions) -> Result<()> {
let project_dir = std::env::current_dir().context("getting current directory")?;
let project_type = detect_project_type(&project_dir);
if project_type == "generic" {
bail!(
"Could not detect project type. Supported: Rust, Node, Go, Python, Ruby, Swift, Java/Kotlin (Gradle/Maven)."
);
}
if opts.outdated {
return run_outdated(project_type, &project_dir);
}
if opts.audit {
return run_audit(project_type, &project_dir);
}
let (lock_file, deps) = parse_dependencies(project_type, &project_dir)?;
if opts.licenses {
return run_licenses(project_type, &project_dir);
}
if opts.json {
let report = DepsReport {
ecosystem: project_type.to_string(),
source: lock_file.map(|p| p.to_string_lossy().to_string()),
dependencies: deps,
};
println!("{}", serde_json::to_string_pretty(&report)?);
return Ok(());
}
let source_label = lock_file
.map(|p| {
p.file_name()
.unwrap_or_default()
.to_string_lossy()
.to_string()
})
.unwrap_or_else(|| "manifest".to_string());
println!(
"\n{} ({} via {})",
style("Dependencies").bold(),
style(project_type).cyan(),
style(&source_label).dim()
);
println!(" {} {}\n", style("Total:").bold(), deps.len());
if deps.is_empty() {
println!(" {}", style("No dependencies found.").dim());
return Ok(());
}
let max_name = deps.iter().map(|d| d.name.len()).max().unwrap_or(20).max(4);
println!(
" {:<width$} {}",
style("Name").underlined(),
style("Version").underlined(),
width = max_name
);
for dep in &deps {
println!(" {:<width$} {}", dep.name, dep.version, width = max_name);
}
println!();
Ok(())
}
fn parse_dependencies(
project_type: &str,
project_dir: &Path,
) -> Result<(Option<PathBuf>, Vec<Dep>)> {
match project_type {
"rust" => parse_cargo_lock(project_dir),
"node" => parse_node_lock(project_dir),
"go" => parse_go_sum(project_dir),
"python" => parse_python_deps(project_dir),
"ruby" => parse_gemfile_lock(project_dir),
"swift" => parse_swift_resolved(project_dir),
"java-gradle" => parse_gradle_deps(project_dir),
"java-maven" => parse_maven_deps(project_dir),
_ => bail!("Unsupported project type: {}", project_type),
}
}
fn parse_cargo_lock(dir: &Path) -> Result<(Option<PathBuf>, Vec<Dep>)> {
let lock_path = dir.join("Cargo.lock");
if !lock_path.exists() {
bail!("No Cargo.lock found. Run `cargo generate-lockfile` first.");
}
let content = std::fs::read_to_string(&lock_path).context("reading Cargo.lock")?;
let mut deps = Vec::new();
let mut current_name: Option<String> = None;
for line in content.lines() {
if line.starts_with("name = ") {
current_name = Some(unquote(line.trim_start_matches("name = ")));
} else if line.starts_with("version = ") {
if let Some(name) = current_name.take() {
let version = unquote(line.trim_start_matches("version = "));
deps.push(Dep { name, version });
}
} else if line == "[[package]]" {
current_name = None;
}
}
deps.sort_by(|a, b| a.name.cmp(&b.name));
Ok((Some(lock_path), deps))
}
fn parse_node_lock(dir: &Path) -> Result<(Option<PathBuf>, Vec<Dep>)> {
let npm_lock = dir.join("package-lock.json");
if npm_lock.exists() {
return parse_npm_lock(&npm_lock);
}
let yarn_lock = dir.join("yarn.lock");
if yarn_lock.exists() {
return parse_yarn_lock(&yarn_lock);
}
let bun_lock = dir.join("bun.lock");
if bun_lock.exists() {
return parse_bun_lock(&bun_lock);
}
let pnpm_lock = dir.join("pnpm-lock.yaml");
if pnpm_lock.exists() {
bail!(
"pnpm-lock.yaml detected but YAML parsing is not supported. Use `pnpm outdated` or `pnpm audit` directly."
);
}
bail!(
"No lock file found (package-lock.json, yarn.lock, bun.lock, or pnpm-lock.yaml). Run your package manager's install command first."
);
}
fn parse_npm_lock(path: &Path) -> Result<(Option<PathBuf>, Vec<Dep>)> {
let content = std::fs::read_to_string(path).context("reading package-lock.json")?;
let parsed: serde_json::Value =
serde_json::from_str(&content).context("parsing package-lock.json")?;
let mut deps = Vec::new();
if let Some(packages) = parsed.get("packages").and_then(|p| p.as_object()) {
for (key, val) in packages {
if key.is_empty() {
continue;
}
let name = key.strip_prefix("node_modules/").unwrap_or(key).to_string();
let version = val
.get("version")
.and_then(|v| v.as_str())
.unwrap_or("?")
.to_string();
deps.push(Dep { name, version });
}
}
deps.sort_by(|a, b| a.name.cmp(&b.name));
Ok((Some(path.to_path_buf()), deps))
}
fn parse_yarn_lock(path: &Path) -> Result<(Option<PathBuf>, Vec<Dep>)> {
let content = std::fs::read_to_string(path).context("reading yarn.lock")?;
let mut deps = Vec::new();
let mut seen = std::collections::HashSet::new();
let mut current_name: Option<String> = None;
for line in content.lines() {
let trimmed = line.trim();
if !trimmed.starts_with('#') && !trimmed.is_empty() && !line.starts_with(' ') {
let raw = trimmed.trim_end_matches(':');
let name = raw
.split('@')
.next()
.unwrap_or(raw)
.trim_matches('"')
.to_string();
if !name.is_empty() {
current_name = Some(name);
}
} else if trimmed.starts_with("version ") {
if let Some(name) = current_name.take() {
let version = unquote(trimmed.trim_start_matches("version "));
if seen.insert(name.clone()) {
deps.push(Dep { name, version });
}
}
}
}
deps.sort_by(|a, b| a.name.cmp(&b.name));
Ok((Some(path.to_path_buf()), deps))
}
fn parse_bun_lock(path: &Path) -> Result<(Option<PathBuf>, Vec<Dep>)> {
let content = std::fs::read_to_string(path).context("reading bun.lock")?;
let cleaned = strip_jsonc(&content);
let parsed: serde_json::Value = serde_json::from_str(&cleaned).context("parsing bun.lock")?;
let mut deps = Vec::new();
if let Some(packages) = parsed.get("packages").and_then(|p| p.as_object()) {
for (name, val) in packages {
let version = val
.as_array()
.and_then(|arr| arr.first())
.and_then(|v| v.as_str())
.and_then(|spec| spec.rsplit_once('@'))
.map(|(_, ver)| ver.to_string())
.unwrap_or_else(|| "?".to_string());
deps.push(Dep {
name: name.clone(),
version,
});
}
}
deps.sort_by(|a, b| a.name.cmp(&b.name));
Ok((Some(path.to_path_buf()), deps))
}
fn strip_jsonc(input: &str) -> String {
let mut out = String::with_capacity(input.len());
let mut chars = input.chars().peekable();
let mut in_string = false;
while let Some(c) = chars.next() {
if in_string {
out.push(c);
if c == '\\' {
if let Some(&next) = chars.peek() {
out.push(next);
chars.next();
}
} else if c == '"' {
in_string = false;
}
continue;
}
match c {
'"' => {
in_string = true;
out.push(c);
}
'/' if chars.peek() == Some(&'/') => {
chars.next();
for ch in chars.by_ref() {
if ch == '\n' {
out.push('\n');
break;
}
}
}
'/' if chars.peek() == Some(&'*') => {
chars.next();
let mut prev = '\0';
for ch in chars.by_ref() {
if prev == '*' && ch == '/' {
break;
}
prev = ch;
}
}
_ => out.push(c),
}
}
let re = regex_lite::Regex::new(r",(\s*[}\]])").unwrap();
re.replace_all(&out, "$1").to_string()
}
fn parse_go_sum(dir: &Path) -> Result<(Option<PathBuf>, Vec<Dep>)> {
let sum_path = dir.join("go.sum");
if !sum_path.exists() {
bail!("No go.sum found. Run `go mod tidy` first.");
}
let content = std::fs::read_to_string(&sum_path).context("reading go.sum")?;
let mut deps = Vec::new();
let mut seen = std::collections::HashSet::new();
for line in content.lines() {
let parts: Vec<&str> = line.split_whitespace().collect();
if parts.len() >= 2 {
let module = parts[0].to_string();
let version = parts[1]
.strip_suffix("/go.mod")
.unwrap_or(parts[1])
.to_string();
let key = format!("{module}@{version}");
if seen.insert(key) {
deps.push(Dep {
name: module,
version,
});
}
}
}
deps.sort_by(|a, b| a.name.cmp(&b.name));
Ok((Some(sum_path), deps))
}
fn parse_python_deps(dir: &Path) -> Result<(Option<PathBuf>, Vec<Dep>)> {
let req_path = dir.join("requirements.txt");
if req_path.exists() {
return parse_requirements_txt(&req_path);
}
let pipfile_lock = dir.join("Pipfile.lock");
if pipfile_lock.exists() {
return parse_pipfile_lock(&pipfile_lock);
}
let uv_lock = dir.join("uv.lock");
if uv_lock.exists() {
return parse_uv_lock(&uv_lock);
}
let poetry_lock = dir.join("poetry.lock");
if poetry_lock.exists() {
return parse_poetry_lock(&poetry_lock);
}
bail!(
"No Python lock/requirements file found (requirements.txt, Pipfile.lock, uv.lock, or poetry.lock)."
);
}
fn parse_requirements_txt(path: &Path) -> Result<(Option<PathBuf>, Vec<Dep>)> {
let content = std::fs::read_to_string(path).context("reading requirements.txt")?;
let mut deps = Vec::new();
for line in content.lines() {
let trimmed = line.trim();
if trimmed.is_empty() || trimmed.starts_with('#') || trimmed.starts_with('-') {
continue;
}
if let Some((name, version)) = trimmed.split_once("==") {
deps.push(Dep {
name: name.trim().to_string(),
version: version.trim().to_string(),
});
} else if let Some((name, version)) = trimmed.split_once(">=") {
deps.push(Dep {
name: name.trim().to_string(),
version: format!(">={}", version.trim()),
});
} else {
deps.push(Dep {
name: trimmed.to_string(),
version: "*".to_string(),
});
}
}
deps.sort_by(|a, b| a.name.cmp(&b.name));
Ok((Some(path.to_path_buf()), deps))
}
fn parse_pipfile_lock(path: &Path) -> Result<(Option<PathBuf>, Vec<Dep>)> {
let content = std::fs::read_to_string(path).context("reading Pipfile.lock")?;
let parsed: serde_json::Value =
serde_json::from_str(&content).context("parsing Pipfile.lock")?;
let mut deps = Vec::new();
if let Some(default) = parsed.get("default").and_then(|d| d.as_object()) {
for (name, val) in default {
let version = val
.get("version")
.and_then(|v| v.as_str())
.unwrap_or("?")
.trim_start_matches("==")
.to_string();
deps.push(Dep {
name: name.clone(),
version,
});
}
}
deps.sort_by(|a, b| a.name.cmp(&b.name));
Ok((Some(path.to_path_buf()), deps))
}
fn parse_poetry_lock(path: &Path) -> Result<(Option<PathBuf>, Vec<Dep>)> {
let content = std::fs::read_to_string(path).context("reading poetry.lock")?;
let mut deps = Vec::new();
let mut current_name: Option<String> = None;
for line in content.lines() {
if line.starts_with("name = ") {
current_name = Some(unquote(line.trim_start_matches("name = ")));
} else if line.starts_with("version = ") {
if let Some(name) = current_name.take() {
let version = unquote(line.trim_start_matches("version = "));
deps.push(Dep { name, version });
}
} else if line == "[[package]]" {
current_name = None;
}
}
deps.sort_by(|a, b| a.name.cmp(&b.name));
Ok((Some(path.to_path_buf()), deps))
}
fn parse_uv_lock(path: &Path) -> Result<(Option<PathBuf>, Vec<Dep>)> {
let content = std::fs::read_to_string(path).context("reading uv.lock")?;
let parsed: toml::Value = toml::from_str(&content).context("parsing uv.lock as TOML")?;
let mut deps = Vec::new();
if let Some(packages) = parsed.get("package").and_then(|v| v.as_array()) {
for pkg in packages {
let name = match pkg.get("name").and_then(|v| v.as_str()) {
Some(n) => n,
None => continue,
};
let version = match pkg.get("version").and_then(|v| v.as_str()) {
Some(v) => v,
None => continue,
};
if let Some(source) = pkg.get("source") {
if source.get("editable").is_some() || source.get("virtual").is_some() {
continue;
}
}
deps.push(Dep {
name: name.to_string(),
version: version.to_string(),
});
}
}
deps.sort_by(|a, b| a.name.cmp(&b.name));
Ok((Some(path.to_path_buf()), deps))
}
fn parse_gemfile_lock(dir: &Path) -> Result<(Option<PathBuf>, Vec<Dep>)> {
let lock_path = dir.join("Gemfile.lock");
if !lock_path.exists() {
bail!("No Gemfile.lock found. Run `bundle install` first.");
}
let content = std::fs::read_to_string(&lock_path).context("reading Gemfile.lock")?;
let mut deps = Vec::new();
let mut in_specs = false;
for line in content.lines() {
let trimmed = line.trim();
if trimmed == "specs:" {
in_specs = true;
continue;
}
if in_specs {
if !line.starts_with(' ') && !line.starts_with('\t') {
in_specs = false;
continue;
}
if line.starts_with(" ") && !line.starts_with(" ") {
if let Some((name, rest)) = trimmed.split_once(' ') {
let version = rest.trim_matches(|c| c == '(' || c == ')').to_string();
deps.push(Dep {
name: name.to_string(),
version,
});
}
}
}
}
deps.sort_by(|a, b| a.name.cmp(&b.name));
Ok((Some(lock_path), deps))
}
fn parse_swift_resolved(dir: &Path) -> Result<(Option<PathBuf>, Vec<Dep>)> {
let resolved_path = dir.join("Package.resolved");
if !resolved_path.exists() {
bail!("No Package.resolved found. Run `swift package resolve` first.");
}
let content = std::fs::read_to_string(&resolved_path).context("reading Package.resolved")?;
let parsed: serde_json::Value =
serde_json::from_str(&content).context("parsing Package.resolved")?;
let mut deps = Vec::new();
let version = parsed.get("version").and_then(|v| v.as_u64()).unwrap_or(1);
let pins = if version >= 2 {
parsed.get("pins").and_then(|p| p.as_array())
} else {
parsed
.get("object")
.and_then(|o| o.get("pins"))
.and_then(|p| p.as_array())
};
if let Some(pins) = pins {
for pin in pins {
let identity = pin
.get("identity")
.or_else(|| pin.get("package"))
.and_then(|v| v.as_str())
.unwrap_or("?");
let version_str = pin
.get("state")
.and_then(|s| s.get("version").or_else(|| s.get("revision")))
.and_then(|v| v.as_str())
.unwrap_or("?");
deps.push(Dep {
name: identity.to_string(),
version: version_str.to_string(),
});
}
}
deps.sort_by(|a, b| a.name.cmp(&b.name));
Ok((Some(resolved_path), deps))
}
fn parse_gradle_deps(dir: &Path) -> Result<(Option<PathBuf>, Vec<Dep>)> {
let lockfile = dir.join("gradle.lockfile");
if lockfile.exists() {
return parse_gradle_lockfile(&lockfile);
}
let gradlew = if cfg!(windows) {
dir.join("gradlew.bat")
} else {
dir.join("gradlew")
};
let cmd = if gradlew.exists() {
gradlew.to_string_lossy().to_string()
} else {
"gradle".to_string()
};
let output = Command::new(&cmd)
.args([
"dependencies",
"--configuration",
"runtimeClasspath",
"--console=plain",
"-q",
])
.current_dir(dir)
.output()
.with_context(|| format!("running {} dependencies", cmd))?;
if !output.status.success() {
let fallback = Command::new(&cmd)
.args(["dependencies", "--console=plain", "-q"])
.current_dir(dir)
.output()
.with_context(|| format!("running {} dependencies", cmd))?;
if !fallback.status.success() {
bail!(
"Failed to run Gradle dependencies. Ensure Gradle wrapper or Gradle is available."
);
}
return parse_gradle_tree_output(&String::from_utf8_lossy(&fallback.stdout));
}
parse_gradle_tree_output(&String::from_utf8_lossy(&output.stdout))
}
fn parse_gradle_lockfile(path: &Path) -> Result<(Option<PathBuf>, Vec<Dep>)> {
let content = std::fs::read_to_string(path).context("reading gradle.lockfile")?;
let mut deps = Vec::new();
let mut seen = std::collections::HashSet::new();
for line in content.lines() {
let trimmed = line.trim();
if trimmed.is_empty() || trimmed.starts_with('#') || trimmed.starts_with("empty=") {
continue;
}
if let Some((coord, _configs)) = trimmed.split_once('=') {
let parts: Vec<&str> = coord.split(':').collect();
if parts.len() >= 3 {
let name = format!("{}:{}", parts[0], parts[1]);
let version = parts[2].to_string();
if seen.insert(name.clone()) {
deps.push(Dep { name, version });
}
}
}
}
deps.sort_by(|a, b| a.name.cmp(&b.name));
Ok((Some(path.to_path_buf()), deps))
}
fn parse_gradle_tree_output(output: &str) -> Result<(Option<PathBuf>, Vec<Dep>)> {
let mut deps = Vec::new();
let mut seen = std::collections::HashSet::new();
let dep_pattern = regex_lite::Regex::new(r"[+\\|`]---\s+(\S+):(\S+):(\S+)").unwrap();
for line in output.lines() {
if let Some(caps) = dep_pattern.captures(line) {
let name = format!("{}:{}", &caps[1], &caps[2]);
let raw_version = caps[3].trim_end_matches(" (*)").to_string();
let after_match = &line[caps.get(0).unwrap().end()..];
let version = if let Some(rest) = after_match.strip_prefix(" -> ") {
rest.split_whitespace()
.next()
.unwrap_or(&raw_version)
.to_string()
} else {
raw_version
};
if seen.insert(name.clone()) {
deps.push(Dep { name, version });
}
}
}
deps.sort_by(|a, b| a.name.cmp(&b.name));
Ok((None, deps))
}
fn parse_maven_deps(dir: &Path) -> Result<(Option<PathBuf>, Vec<Dep>)> {
let output = Command::new("mvn")
.args([
"dependency:list",
"-DoutputAbsoluteArtifactFilename=false",
"-q",
])
.current_dir(dir)
.output()
.context("running mvn dependency:list")?;
if !output.status.success() {
bail!("Failed to run mvn dependency:list. Ensure Maven is installed and pom.xml is valid.");
}
let stdout = String::from_utf8_lossy(&output.stdout);
let mut deps = Vec::new();
let mut seen = std::collections::HashSet::new();
for line in stdout.lines() {
let trimmed = line.trim();
let parts: Vec<&str> = trimmed.split(':').collect();
if parts.len() >= 4 {
let name = format!("{}:{}", parts[0], parts[1]);
let version = parts[3].to_string();
if seen.insert(name.clone()) {
deps.push(Dep { name, version });
}
}
}
deps.sort_by(|a, b| a.name.cmp(&b.name));
Ok((None, deps))
}
fn run_outdated(project_type: &str, dir: &Path) -> Result<()> {
let (cmd, args, tool_name) = match project_type {
"rust" => ("cargo", vec!["outdated"], "cargo-outdated"),
"node" => ("npm", vec!["outdated"], "npm"),
"go" => ("go", vec!["list", "-m", "-u", "all"], "go"),
"python" => {
if dir.join("uv.lock").exists() {
("uv", vec!["pip", "list", "--outdated"], "uv")
} else {
("pip", vec!["list", "--outdated"], "pip")
}
}
"ruby" => ("bundle", vec!["outdated"], "bundler"),
"swift" => (
"swift",
vec!["package", "show-dependencies"],
"swift-package-manager",
),
"java-gradle" => {
return run_gradle_outdated(dir);
}
"java-maven" => (
"mvn",
vec!["versions:display-dependency-updates"],
"versions-maven-plugin",
),
_ => bail!("Outdated check not supported for {}", project_type),
};
run_ecosystem_command(cmd, &args, dir, tool_name, "outdated check")
}
fn run_gradle_outdated(dir: &Path) -> Result<()> {
let gradlew = if cfg!(windows) {
dir.join("gradlew.bat")
} else {
dir.join("gradlew")
};
let cmd = if gradlew.exists() {
gradlew.to_string_lossy().to_string()
} else {
"gradle".to_string()
};
println!(
"{} Running {} ({} dependencyUpdates)...\n",
style("▶️").cyan().bold(),
style("outdated check").bold(),
cmd,
);
let result = Command::new(&cmd)
.args(["dependencyUpdates", "--console=plain"])
.current_dir(dir)
.status();
match result {
Ok(status) if !status.success() => {
println!(
"\n{} dependencyUpdates task not available. Install the com.github.ben-manes.versions plugin, or run {} to view the dependency tree.",
style("!").yellow().bold(),
style(format!("{} dependencies", cmd)).cyan()
);
Ok(())
}
Ok(_) => Ok(()),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
bail!(
"'{}' not found. Ensure Gradle wrapper or Gradle is installed.",
cmd
);
}
Err(e) => Err(e).with_context(|| format!("running {cmd}")),
}
}
fn run_audit(project_type: &str, dir: &Path) -> Result<()> {
let (cmd, args, tool_name) = match project_type {
"rust" => ("cargo", vec!["audit"], "cargo-audit"),
"node" => ("npm", vec!["audit"], "npm"),
"go" => ("govulncheck", vec!["./..."], "govulncheck"),
"python" => ("pip-audit", vec![], "pip-audit"),
"ruby" => ("bundle", vec!["audit", "check"], "bundler-audit"),
"swift" => ("swift", vec!["package", "audit"], "swift-package-manager"),
"java-gradle" => {
return run_gradle_audit(dir);
}
"java-maven" => (
"mvn",
vec!["org.owasp:dependency-check-maven:check"],
"dependency-check-maven",
),
_ => bail!("Security audit not supported for {}", project_type),
};
run_ecosystem_command(cmd, &args, dir, tool_name, "security audit")
}
fn run_gradle_audit(dir: &Path) -> Result<()> {
let gradlew = if cfg!(windows) {
dir.join("gradlew.bat")
} else {
dir.join("gradlew")
};
let cmd = if gradlew.exists() {
gradlew.to_string_lossy().to_string()
} else {
"gradle".to_string()
};
println!(
"{} Running {} ({} dependencyCheckAnalyze)...\n",
style("▶️").cyan().bold(),
style("security audit").bold(),
cmd,
);
let result = Command::new(&cmd)
.args(["dependencyCheckAnalyze", "--console=plain"])
.current_dir(dir)
.status();
match result {
Ok(status) if !status.success() => {
println!(
"\n{} dependencyCheckAnalyze task not available. Install the org.owasp.dependencycheck plugin for security audits.",
style("!").yellow().bold(),
);
Ok(())
}
Ok(_) => Ok(()),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
bail!(
"'{}' not found. Ensure Gradle wrapper or Gradle is installed.",
cmd
);
}
Err(e) => Err(e).with_context(|| format!("running {cmd}")),
}
}
fn run_licenses(project_type: &str, dir: &Path) -> Result<()> {
let (cmd, args, tool_name) = match project_type {
"rust" => ("cargo", vec!["license"], "cargo-license"),
"node" => (
"npx",
vec!["license-checker", "--summary"],
"license-checker",
),
"go" => ("go-licenses", vec!["report", "./..."], "go-licenses"),
"python" => ("pip-licenses", vec![], "pip-licenses"),
"ruby" => ("bundle", vec!["exec", "license_finder"], "license_finder"),
"swift" => bail!("License scanning not yet supported for Swift. Check package repositories directly."),
"java-gradle" | "java-maven" => bail!("License scanning not yet supported for Java/Kotlin. Check dependency POM files directly."),
_ => bail!("License scanning not supported for {}", project_type),
};
run_ecosystem_command(cmd, &args, dir, tool_name, "license scan")
}
fn run_ecosystem_command(
cmd: &str,
args: &[&str],
dir: &Path,
tool_name: &str,
action: &str,
) -> Result<()> {
println!(
"{} Running {} ({} {})...\n",
style("▶️").cyan().bold(),
style(action).bold(),
cmd,
args.join(" ")
);
let result = Command::new(cmd).args(args).current_dir(dir).status();
match result {
Ok(status) => {
if !status.success() {
let code = status.code().unwrap_or(1);
if code != 0 {
println!(
"\n{} {} exited with code {} (this may indicate issues were found)",
style("!").yellow().bold(),
tool_name,
code
);
}
}
Ok(())
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
bail!(
"'{}' not found. Install it to use {}.\n See: {} docs for installation instructions.",
cmd,
action,
tool_name,
);
}
Err(e) => Err(e).with_context(|| format!("running {cmd}")),
}
}
fn unquote(s: &str) -> String {
s.trim().trim_matches('"').to_string()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_cargo_lock_basic() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("Cargo.toml"), "[package]\nname = \"test\"").unwrap();
std::fs::write(
dir.path().join("Cargo.lock"),
r#"# This file is generated
[[package]]
name = "anyhow"
version = "1.0.86"
[[package]]
name = "clap"
version = "4.5.0"
"#,
)
.unwrap();
let (lock, deps) = parse_cargo_lock(dir.path()).unwrap();
assert!(lock.is_some());
assert_eq!(deps.len(), 2);
assert_eq!(deps[0].name, "anyhow");
assert_eq!(deps[0].version, "1.0.86");
assert_eq!(deps[1].name, "clap");
assert_eq!(deps[1].version, "4.5.0");
}
#[test]
fn parse_cargo_lock_missing() {
let dir = tempfile::tempdir().unwrap();
let result = parse_cargo_lock(dir.path());
assert!(result.is_err());
}
#[test]
fn parse_npm_lock_basic() {
let dir = tempfile::tempdir().unwrap();
let lock_content = serde_json::json!({
"name": "test",
"lockfileVersion": 3,
"packages": {
"": { "name": "test", "version": "1.0.0" },
"node_modules/express": { "version": "4.18.0" },
"node_modules/lodash": { "version": "4.17.21" }
}
});
let lock_path = dir.path().join("package-lock.json");
std::fs::write(
&lock_path,
serde_json::to_string_pretty(&lock_content).unwrap(),
)
.unwrap();
let (lock, deps) = parse_npm_lock(&lock_path).unwrap();
assert!(lock.is_some());
assert_eq!(deps.len(), 2);
assert_eq!(deps[0].name, "express");
assert_eq!(deps[1].name, "lodash");
}
#[test]
fn parse_go_sum_basic() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("go.mod"), "module test").unwrap();
std::fs::write(
dir.path().join("go.sum"),
"github.com/pkg/errors v0.9.1 h1:abc=\ngithub.com/pkg/errors v0.9.1/go.mod h1:def=\n",
)
.unwrap();
let (lock, deps) = parse_go_sum(dir.path()).unwrap();
assert!(lock.is_some());
assert_eq!(deps.len(), 1);
assert_eq!(deps[0].name, "github.com/pkg/errors");
assert_eq!(deps[0].version, "v0.9.1");
}
#[test]
fn parse_requirements_txt_basic() {
let dir = tempfile::tempdir().unwrap();
let req_path = dir.path().join("requirements.txt");
std::fs::write(
&req_path,
"# comment\nflask==2.3.0\nrequests>=2.28.0\nnumpy\n",
)
.unwrap();
let (lock, deps) = parse_requirements_txt(&req_path).unwrap();
assert!(lock.is_some());
assert_eq!(deps.len(), 3);
assert_eq!(deps[0].name, "flask");
assert_eq!(deps[0].version, "2.3.0");
assert_eq!(deps[1].name, "numpy");
assert_eq!(deps[1].version, "*");
assert_eq!(deps[2].name, "requests");
assert_eq!(deps[2].version, ">=2.28.0");
}
#[test]
fn parse_pipfile_lock_basic() {
let dir = tempfile::tempdir().unwrap();
let lock_path = dir.path().join("Pipfile.lock");
let content = serde_json::json!({
"_meta": {},
"default": {
"flask": { "version": "==2.3.0" },
"requests": { "version": "==2.28.0" }
},
"develop": {}
});
std::fs::write(&lock_path, serde_json::to_string_pretty(&content).unwrap()).unwrap();
let (lock, deps) = parse_pipfile_lock(&lock_path).unwrap();
assert!(lock.is_some());
assert_eq!(deps.len(), 2);
assert_eq!(deps[0].name, "flask");
assert_eq!(deps[0].version, "2.3.0");
}
#[test]
fn parse_uv_lock_basic() {
let dir = tempfile::tempdir().unwrap();
let lock_path = dir.path().join("uv.lock");
std::fs::write(
&lock_path,
r#"version = 1
requires-python = ">=3.12"
[[package]]
name = "fastapi"
version = "0.111.0"
source = { registry = "https://pypi.org/simple" }
[[package]]
name = "pydantic"
version = "2.7.4"
source = { registry = "https://pypi.org/simple" }
"#,
)
.unwrap();
let (lock, deps) = parse_uv_lock(&lock_path).unwrap();
assert!(lock.is_some());
assert_eq!(deps.len(), 2);
assert_eq!(deps[0].name, "fastapi");
assert_eq!(deps[0].version, "0.111.0");
assert_eq!(deps[1].name, "pydantic");
assert_eq!(deps[1].version, "2.7.4");
}
#[test]
fn parse_uv_lock_skips_root_and_handles_field_order() {
let dir = tempfile::tempdir().unwrap();
let lock_path = dir.path().join("uv.lock");
std::fs::write(
&lock_path,
r#"version = 1
requires-python = ">=3.12"
[[package]]
name = "myproject"
version = "0.1.0"
source = { editable = "." }
[[package]]
version = "1.2.3"
name = "reversed-fields"
source = { registry = "https://pypi.org/simple" }
[[package]]
name = "normal-dep"
version = "4.5.6"
source = { registry = "https://pypi.org/simple" }
[[package]]
name = "virtual-root"
version = "0.0.0"
source = { virtual = "." }
"#,
)
.unwrap();
let (lock, deps) = parse_uv_lock(&lock_path).unwrap();
assert!(lock.is_some());
assert_eq!(deps.len(), 2);
assert_eq!(deps[0].name, "normal-dep");
assert_eq!(deps[0].version, "4.5.6");
assert_eq!(deps[1].name, "reversed-fields");
assert_eq!(deps[1].version, "1.2.3");
}
#[test]
fn parse_gemfile_lock_basic() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("Gemfile"), "source 'https://rubygems.org'").unwrap();
std::fs::write(
dir.path().join("Gemfile.lock"),
r#"GEM
remote: https://rubygems.org/
specs:
rails (7.0.0)
actionpack (= 7.0.0)
actionpack (7.0.0)
PLATFORMS
ruby
"#,
)
.unwrap();
let (lock, deps) = parse_gemfile_lock(dir.path()).unwrap();
assert!(lock.is_some());
assert_eq!(deps.len(), 2);
assert_eq!(deps[0].name, "actionpack");
assert_eq!(deps[0].version, "7.0.0");
assert_eq!(deps[1].name, "rails");
assert_eq!(deps[1].version, "7.0.0");
}
#[test]
fn parse_poetry_lock_basic() {
let dir = tempfile::tempdir().unwrap();
let lock_path = dir.path().join("poetry.lock");
std::fs::write(
&lock_path,
r#"[[package]]
name = "certifi"
version = "2024.2.2"
[[package]]
name = "urllib3"
version = "2.2.1"
"#,
)
.unwrap();
let (lock, deps) = parse_poetry_lock(&lock_path).unwrap();
assert!(lock.is_some());
assert_eq!(deps.len(), 2);
assert_eq!(deps[0].name, "certifi");
assert_eq!(deps[1].name, "urllib3");
}
#[test]
fn parse_yarn_lock_basic() {
let dir = tempfile::tempdir().unwrap();
let lock_path = dir.path().join("yarn.lock");
std::fs::write(
&lock_path,
r#"# yarn lockfile v1
express@^4.18.0:
version "4.18.2"
resolved "https://registry.yarnpkg.com/express/-/express-4.18.2.tgz"
lodash@^4.17.0:
version "4.17.21"
resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz"
"#,
)
.unwrap();
let (lock, deps) = parse_yarn_lock(&lock_path).unwrap();
assert!(lock.is_some());
assert_eq!(deps.len(), 2);
assert_eq!(deps[0].name, "express");
assert_eq!(deps[1].name, "lodash");
}
#[test]
fn parse_bun_lock_basic() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("package.json"), r#"{"name":"test"}"#).unwrap();
let lock_path = dir.path().join("bun.lock");
std::fs::write(
&lock_path,
r#"{
"lockfileVersion": 1,
"workspaces": {
"": {
"name": "test",
"dependencies": {
"hono": "^4.0.0",
},
"devDependencies": {
"@types/node": "^20.0.0",
},
},
},
"packages": {
"@types/node": ["@types/node@20.11.5", "", {}, "sha512-abc=="],
"hono": ["hono@4.4.0", "", {}, "sha512-def=="],
"zod": ["zod@3.23.0", "", {}, "sha512-ghi=="],
},
}
"#,
)
.unwrap();
let (lock, deps) = parse_bun_lock(&lock_path).unwrap();
assert!(lock.is_some());
assert_eq!(deps.len(), 3);
assert_eq!(deps[0].name, "@types/node");
assert_eq!(deps[0].version, "20.11.5");
assert_eq!(deps[1].name, "hono");
assert_eq!(deps[1].version, "4.4.0");
assert_eq!(deps[2].name, "zod");
assert_eq!(deps[2].version, "3.23.0");
}
#[test]
fn parse_bun_lock_missing() {
let dir = tempfile::tempdir().unwrap();
let result = parse_bun_lock(&dir.path().join("bun.lock"));
assert!(result.is_err());
}
#[test]
fn strip_jsonc_removes_trailing_commas() {
let input = r#"{"a": 1, "b": [2, 3,],}"#;
let cleaned = strip_jsonc(input);
let parsed: serde_json::Value = serde_json::from_str(&cleaned).unwrap();
assert_eq!(parsed["a"], 1);
}
#[test]
fn strip_jsonc_removes_comments() {
let input = "{\n// comment\n\"a\": 1\n/* block */\n}";
let cleaned = strip_jsonc(input);
let parsed: serde_json::Value = serde_json::from_str(&cleaned).unwrap();
assert_eq!(parsed["a"], 1);
}
#[test]
fn unquote_strips_quotes() {
assert_eq!(unquote("\"hello\""), "hello");
assert_eq!(unquote(" \"world\" "), "world");
assert_eq!(unquote("bare"), "bare");
}
#[test]
fn parse_swift_resolved_v2() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("Package.swift"),
"// swift-tools-version:5.9",
)
.unwrap();
std::fs::write(
dir.path().join("Package.resolved"),
r#"{
"originHash": "abc123",
"pins": [
{
"identity": "swift-argument-parser",
"kind": "remoteSourceControl",
"location": "https://github.com/apple/swift-argument-parser.git",
"state": {
"revision": "abc",
"version": "1.3.0"
}
},
{
"identity": "swift-nio",
"kind": "remoteSourceControl",
"location": "https://github.com/apple/swift-nio.git",
"state": {
"revision": "def",
"version": "2.65.0"
}
}
],
"version": 3
}"#,
)
.unwrap();
let (lock, deps) = parse_swift_resolved(dir.path()).unwrap();
assert!(lock.is_some());
assert_eq!(deps.len(), 2);
assert_eq!(deps[0].name, "swift-argument-parser");
assert_eq!(deps[0].version, "1.3.0");
assert_eq!(deps[1].name, "swift-nio");
assert_eq!(deps[1].version, "2.65.0");
}
#[test]
fn parse_swift_resolved_v1() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("Package.swift"),
"// swift-tools-version:5.5",
)
.unwrap();
std::fs::write(
dir.path().join("Package.resolved"),
r#"{
"object": {
"pins": [
{
"package": "Alamofire",
"state": {
"revision": "abc",
"version": "5.9.1"
}
}
]
},
"version": 1
}"#,
)
.unwrap();
let (lock, deps) = parse_swift_resolved(dir.path()).unwrap();
assert!(lock.is_some());
assert_eq!(deps.len(), 1);
assert_eq!(deps[0].name, "Alamofire");
assert_eq!(deps[0].version, "5.9.1");
}
#[test]
fn parse_swift_resolved_missing() {
let dir = tempfile::tempdir().unwrap();
let result = parse_swift_resolved(dir.path());
assert!(result.is_err());
}
#[test]
fn parse_gradle_lockfile_basic() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("build.gradle.kts"), "plugins {}").unwrap();
std::fs::write(
dir.path().join("gradle.lockfile"),
r#"# This is a Gradle generated file for dependency locking.
# Manual edits can mess up your build.
# This file is expected to be part of source control.
com.google.code.gson:gson:2.10.1=runtimeClasspath
com.squareup.okhttp3:okhttp:4.12.0=compileClasspath,runtimeClasspath
org.jetbrains.kotlin:kotlin-stdlib:1.9.22=compileClasspath,runtimeClasspath
empty=
"#,
)
.unwrap();
let (lock, deps) = parse_gradle_lockfile(&dir.path().join("gradle.lockfile")).unwrap();
assert!(lock.is_some());
assert_eq!(deps.len(), 3);
assert_eq!(deps[0].name, "com.google.code.gson:gson");
assert_eq!(deps[0].version, "2.10.1");
assert_eq!(deps[1].name, "com.squareup.okhttp3:okhttp");
assert_eq!(deps[1].version, "4.12.0");
assert_eq!(deps[2].name, "org.jetbrains.kotlin:kotlin-stdlib");
assert_eq!(deps[2].version, "1.9.22");
}
#[test]
fn parse_gradle_tree_output_basic() {
let output = r#"
runtimeClasspath - Runtime classpath of source set 'main'.
+--- org.jetbrains.kotlin:kotlin-stdlib:1.9.22
+--- com.google.code.gson:gson:2.10.1
+--- com.squareup.okhttp3:okhttp:4.12.0
| +--- com.squareup.okio:okio:3.6.0
| \--- org.jetbrains.kotlin:kotlin-stdlib:1.9.22 (*)
\--- org.jetbrains.kotlinx:kotlinx-coroutines-core:1.8.0
+--- org.jetbrains.kotlin:kotlin-stdlib:1.9.22 (*)
"#;
let (lock, deps) = parse_gradle_tree_output(output).unwrap();
assert!(lock.is_none());
assert_eq!(deps.len(), 5);
assert_eq!(deps[0].name, "com.google.code.gson:gson");
assert_eq!(deps[0].version, "2.10.1");
}
#[test]
fn parse_gradle_tree_handles_version_resolution() {
let output = r#"
+--- org.slf4j:slf4j-api:1.7.36 -> 2.0.9
\--- com.google.guava:guava:32.1.3-jre
"#;
let (_lock, deps) = parse_gradle_tree_output(output).unwrap();
assert_eq!(deps.len(), 2);
assert_eq!(deps[1].name, "org.slf4j:slf4j-api");
assert_eq!(deps[1].version, "2.0.9");
}
#[test]
fn generic_project_returns_error() {
let dir = tempfile::tempdir().unwrap();
assert_eq!(detect_project_type(dir.path()), "generic");
}
}