1use 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
19const 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
29pub 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
104fn 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
122fn 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
140fn 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}