use std::path::Path;
use std::process::Command;
use serde::Deserialize;
use crate::{DepError, DepKind, OutdatedDep};
pub(crate) fn run(
workdir: Option<&Path>,
workspace: bool,
excludes: &[String],
) -> Result<Vec<OutdatedDep>, DepError> {
detect()?;
let mut cmd = Command::new("cargo");
cmd.args(["outdated", "--format", "json"]);
if workspace {
cmd.args(["--workspace", "--root-deps-only"]);
}
for ex in excludes {
cmd.args(["--exclude", ex]);
}
if let Some(d) = workdir {
cmd.current_dir(d);
}
let output = cmd
.output()
.map_err(|e| DepError::SubprocessFailed(e.to_string()))?;
let stdout = String::from_utf8_lossy(&output.stdout).into_owned();
if stdout.trim().is_empty() && !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr).into_owned();
return Err(DepError::SubprocessFailed(stderr));
}
parse(&stdout)
}
fn detect() -> Result<(), DepError> {
let probe = Command::new("cargo")
.args(["outdated", "--version"])
.output();
match probe {
Ok(o) if o.status.success() => Ok(()),
_ => Err(DepError::OutdatedToolNotInstalled),
}
}
#[derive(Deserialize, Default)]
struct OutdatedReport {
#[serde(default)]
dependencies: Vec<OutdatedEntry>,
}
#[derive(Deserialize)]
struct OutdatedEntry {
#[serde(default)]
name: String,
#[serde(default)]
project: String,
#[serde(default)]
latest: String,
#[serde(default)]
kind: Option<String>,
}
pub(crate) fn parse(json: &str) -> Result<Vec<OutdatedDep>, DepError> {
let trimmed = json.trim();
if trimmed.is_empty() {
return Ok(Vec::new());
}
let mut findings = Vec::new();
let stream = serde_json::Deserializer::from_str(trimmed).into_iter::<OutdatedReport>();
let mut saw_any = false;
for report in stream {
let report = report.map_err(|e| {
DepError::ParseError(format!("{e}; first 200 chars: {:?}", first_200(trimmed)))
})?;
saw_any = true;
for dep in report.dependencies {
if dep.project.is_empty() || dep.latest.is_empty() {
continue;
}
if dep.project == dep.latest {
continue;
}
if dep.name.is_empty() || dep.name == "---" {
continue;
}
findings.push(OutdatedDep {
major_behind: major_diff(&dep.project, &dep.latest),
crate_name: dep.name,
current: dep.project,
latest: dep.latest,
kind: dep_kind_from_label(dep.kind.as_deref()),
});
}
}
if !saw_any && !trimmed.is_empty() {
return Err(DepError::ParseError(format!(
"no JSON objects found in `cargo outdated` output; first 200 chars: {:?}",
first_200(trimmed)
)));
}
Ok(findings)
}
fn dep_kind_from_label(label: Option<&str>) -> Option<DepKind> {
match label.map(str::to_ascii_lowercase).as_deref() {
Some("normal") => Some(DepKind::Normal),
Some("development") | Some("dev") => Some(DepKind::Development),
Some("build") => Some(DepKind::Build),
_ => None,
}
}
fn major_diff(current: &str, latest: &str) -> u32 {
let c = major(current).unwrap_or(0);
let l = major(latest).unwrap_or(0);
l.saturating_sub(c)
}
fn major(s: &str) -> Option<u32> {
s.trim_start_matches(|c: char| !c.is_ascii_digit())
.split('.')
.next()
.and_then(|m| m.parse::<u32>().ok())
}
fn first_200(s: &str) -> &str {
if s.len() <= 200 {
s
} else {
&s[..200]
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn empty_input_yields_no_findings() {
assert!(parse("").unwrap().is_empty());
}
#[test]
fn parses_a_single_workspace_member() {
let json = r#"{
"crate_name": "root",
"dependencies": [
{ "name": "serde", "project": "1.0.0", "compat": "1.0.5", "latest": "2.0.0", "kind": "Normal" },
{ "name": "tokio", "project": "1.2.0", "latest": "1.30.0", "kind": "Normal" }
]
}"#;
let findings = parse(json).unwrap();
assert_eq!(findings.len(), 2);
let serde_finding = findings.iter().find(|f| f.crate_name == "serde").unwrap();
assert_eq!(serde_finding.current, "1.0.0");
assert_eq!(serde_finding.latest, "2.0.0");
assert_eq!(serde_finding.major_behind, 1);
let tokio_finding = findings.iter().find(|f| f.crate_name == "tokio").unwrap();
assert_eq!(tokio_finding.major_behind, 0); }
#[test]
fn parses_multiple_workspace_members_concatenated() {
let json = concat!(
r#"{"crate_name":"a","dependencies":[{"name":"serde","project":"1.0.0","latest":"2.0.0","kind":"Normal"}]}"#,
r#"{"crate_name":"b","dependencies":[{"name":"tokio","project":"1.0.0","latest":"1.5.0","kind":"Normal"}]}"#,
);
let findings = parse(json).unwrap();
assert_eq!(findings.len(), 2);
}
#[test]
fn skips_entries_with_no_update() {
let json = r#"{
"dependencies": [
{ "name": "serde", "project": "1.0.5", "latest": "1.0.5", "kind": "Normal" }
]
}"#;
assert!(parse(json).unwrap().is_empty());
}
#[test]
fn skips_sentinel_dashes() {
let json = r#"{
"dependencies": [
{ "name": "---", "project": "---", "latest": "---", "kind": "Normal" }
]
}"#;
assert!(parse(json).unwrap().is_empty());
}
#[test]
fn major_diff_handles_multi_digit_majors() {
assert_eq!(major_diff("1.0.0", "10.0.0"), 9);
assert_eq!(major_diff("0.9.2", "0.9.5"), 0);
assert_eq!(major_diff("3.0.0", "1.0.0"), 0); }
#[test]
fn major_strips_leading_caret_or_tilde() {
assert_eq!(major("^1.2.3"), Some(1));
assert_eq!(major("~2.0"), Some(2));
assert_eq!(major("not-a-version"), None);
}
#[test]
fn dep_kind_from_label_handles_known_strings() {
assert_eq!(dep_kind_from_label(Some("Normal")), Some(DepKind::Normal));
assert_eq!(
dep_kind_from_label(Some("development")),
Some(DepKind::Development)
);
assert_eq!(dep_kind_from_label(Some("dev")), Some(DepKind::Development));
assert_eq!(dep_kind_from_label(Some("build")), Some(DepKind::Build));
assert_eq!(dep_kind_from_label(Some("???")), None);
assert_eq!(dep_kind_from_label(None), None);
}
#[test]
fn rejects_garbage_input() {
let err = parse("not json").err().unwrap();
assert!(matches!(err, DepError::ParseError(_)));
}
}