Skip to main content

pma_core/
deps.rs

1//! Outdated dependencies, per ecosystem, as each tool reports them.
2//!
3//! - cargo: `cargo update --dry-run`, semver-compatible updates in `Cargo.lock`,
4//!   transitive ones included.
5//! - uv: `uv tree --frozen --outdated --depth 1`, direct dependencies with any
6//!   newer release.
7//! - go: `go list -u -m all`, direct modules with any newer version.
8//!
9//! None of the three writes to the project. Each needs the network.
10
11use std::path::Path;
12use std::process::Command;
13use std::sync::atomic::{AtomicUsize, Ordering};
14use std::time::Duration;
15
16use crate::agent;
17use crate::scan::DepsFacts;
18
19/// How long one tool may run.
20const TIMEOUT: Duration = Duration::from_secs(180);
21
22#[derive(Debug, Clone, PartialEq)]
23pub struct Outdated {
24    pub name: String,
25    pub current: String,
26    pub latest: String,
27}
28
29/// Runs every tool the project's root files call for. `outdated` is `None`
30/// when no ecosystem applies or every tool failed.
31pub fn measure(dir: &Path) -> DepsFacts {
32    let mut found: Option<i64> = None;
33    let mut detail = Vec::new();
34    for (ecosystem, marker) in [("cargo", "Cargo.lock"), ("uv", "uv.lock"), ("go", "go.mod")] {
35        if !dir.join(marker).is_file() {
36            continue;
37        }
38        match run(ecosystem, dir) {
39            Ok(list) => {
40                found = Some(found.unwrap_or(0) + list.len() as i64);
41                detail.extend(
42                    list.iter()
43                        .map(|o| format!("{ecosystem}: {} {} -> {}", o.name, o.current, o.latest)),
44                );
45            }
46            Err(e) => detail.push(format!("{ecosystem}: error: {e}")),
47        }
48    }
49    DepsFacts {
50        outdated: found,
51        detail: detail.join("\n"),
52    }
53}
54
55fn run(ecosystem: &str, dir: &Path) -> Result<Vec<Outdated>, String> {
56    let mut cmd = match ecosystem {
57        "cargo" => {
58            let mut c = Command::new("cargo");
59            c.args(["update", "--dry-run"]);
60            c
61        }
62        "uv" => {
63            let mut c = Command::new("uv");
64            c.args(["tree", "--frozen", "--outdated", "--depth", "1"]);
65            c
66        }
67        _ => {
68            let mut c = Command::new("go");
69            c.args([
70                "list",
71                "-u",
72                "-m",
73                "-f",
74                "{{if and .Update (not .Indirect)}}{{.Path}} {{.Version}} {{.Update.Version}}{{end}}",
75                "all",
76            ]);
77            c
78        }
79    };
80    cmd.current_dir(dir)
81        .env("NO_COLOR", "1")
82        .env("CARGO_TERM_COLOR", "never");
83
84    static NEXT: AtomicUsize = AtomicUsize::new(0);
85    let log = std::env::temp_dir().join(format!(
86        "pma-deps-{}-{}.log",
87        std::process::id(),
88        NEXT.fetch_add(1, Ordering::Relaxed)
89    ));
90    let finished = agent::run_limited(cmd, None, &log, TIMEOUT).map_err(|e| e.to_string());
91    let output = std::fs::read_to_string(&log).unwrap_or_default();
92    let _ = std::fs::remove_file(&log);
93    match finished?.success {
94        None => Err(format!("timed out after {}s", TIMEOUT.as_secs())),
95        Some(false) => Err(agent::tail(&output, 1)),
96        Some(true) => Ok(match ecosystem {
97            "cargo" => parse_cargo(&output),
98            "uv" => parse_uv(&output),
99            _ => parse_go(&output),
100        }),
101    }
102}
103
104/// `Updating clap v4.6.6 -> v4.6.7` lines.
105fn parse_cargo(output: &str) -> Vec<Outdated> {
106    output
107        .lines()
108        .filter_map(|l| {
109            let w: Vec<&str> = l.split_whitespace().collect();
110            match w[..] {
111                ["Updating", name, current, "->", latest] => Some(Outdated {
112                    name: name.into(),
113                    current: current.into(),
114                    latest: latest.into(),
115                }),
116                _ => None,
117            }
118        })
119        .collect()
120}
121
122/// `├── ruff v0.14.10 (group: dev) (latest: v0.16.7)` lines.
123fn parse_uv(output: &str) -> Vec<Outdated> {
124    output
125        .lines()
126        .filter_map(|l| {
127            let latest = l.split("(latest: ").nth(1)?.strip_suffix(')')?;
128            let mut w = l
129                .trim_start_matches(|c: char| !c.is_ascii_alphanumeric())
130                .split_whitespace();
131            Some(Outdated {
132                name: w.next()?.into(),
133                current: w.next()?.into(),
134                latest: latest.into(),
135            })
136        })
137        .collect()
138}
139
140/// `path current latest` lines from the `go list` template.
141fn parse_go(output: &str) -> Vec<Outdated> {
142    output
143        .lines()
144        .filter_map(|l| match l.split_whitespace().collect::<Vec<_>>()[..] {
145            [name, current, latest] if !name.ends_with(':') && current.starts_with('v') => {
146                Some(Outdated {
147                    name: name.into(),
148                    current: current.into(),
149                    latest: latest.into(),
150                })
151            }
152            _ => None,
153        })
154        .collect()
155}
156
157#[cfg(test)]
158mod tests {
159    use super::*;
160
161    fn names(list: Vec<Outdated>) -> Vec<String> {
162        list.into_iter()
163            .map(|o| format!("{} {} {}", o.name, o.current, o.latest))
164            .collect()
165    }
166
167    #[test]
168    fn cargo_dry_run_output() {
169        let out = "    Updating crates.io index\n     Locking 2 packages to latest Rust 1.88 compatible versions\n    Updating clap v4.6.6 -> v4.6.7\n    Updating clap_lex v1.1.0 -> v1.1.1\nwarning: not updating lockfile due to dry run\n";
170        assert_eq!(
171            names(parse_cargo(out)),
172            ["clap v4.6.6 v4.6.7", "clap_lex v1.1.0 v1.1.1"]
173        );
174    }
175
176    #[test]
177    fn uv_tree_output() {
178        let out = "aldakit v0.4.0\n├── mkdocs v1.6.1 (group: dev)\n├── ruff v0.14.10 (group: dev) (latest: v0.16.7)\n└── ty v0.0.8 (latest: v0.0.81)\n";
179        assert_eq!(
180            names(parse_uv(out)),
181            ["ruff v0.14.10 v0.16.7", "ty v0.0.8 v0.0.81"]
182        );
183    }
184
185    #[test]
186    fn go_list_output() {
187        let out = "go: downloading github.com/x/y v1.0.0\ngithub.com/openai/openai-go/v3 v3.33.0 v3.61.0\n\ngithub.com/ledongthuc/pdf v0.0.0-2025 v0.0.0-2026\n";
188        assert_eq!(
189            names(parse_go(out)),
190            [
191                "github.com/openai/openai-go/v3 v3.33.0 v3.61.0",
192                "github.com/ledongthuc/pdf v0.0.0-2025 v0.0.0-2026"
193            ]
194        );
195    }
196
197    #[test]
198    fn projects_without_a_known_lock_file_are_unmeasured() {
199        let dir = std::env::temp_dir().join(format!("pma-deps-none-{}", std::process::id()));
200        std::fs::create_dir_all(&dir).unwrap();
201        assert_eq!(
202            measure(&dir),
203            DepsFacts {
204                outdated: None,
205                detail: String::new()
206            }
207        );
208        let _ = std::fs::remove_dir_all(dir);
209    }
210}