1use anyhow::{Context, Result};
2use regex::Regex;
3use serde_json::Value;
4use std::fs;
5use std::io::Read;
6use std::path::{Path, PathBuf};
7use std::process::{Command, Stdio};
8use std::thread;
9use std::time::{Duration, Instant};
10
11const MAX_METADATA_BYTES: u64 = 512 * 1024;
12const MAX_OUTPUT_BYTES: u64 = 128 * 1024;
13const PROBE_TIMEOUT: Duration = Duration::from_secs(3);
14
15#[derive(Debug, Clone, PartialEq, Eq)]
16pub struct VersionProbeResult {
17 pub version: String,
18 pub source: String,
19}
20
21pub fn detect_installed_version(
30 app_dir: &Path,
31 exec_path_inside_app: &Path,
32 command_name: &str,
33 app_id: &str,
34 app_name: &str,
35) -> Result<Option<VersionProbeResult>> {
36 if let Some(found) = detect_from_metadata(app_dir)? {
37 return Ok(Some(found));
38 }
39
40 let exec_abs = app_dir.join(exec_path_inside_app);
41 detect_from_command(&exec_abs, app_dir, command_name, app_id, app_name)
42}
43
44fn detect_from_metadata(app_dir: &Path) -> Result<Option<VersionProbeResult>> {
45 let json_candidates = [
46 PathBuf::from("product-info.json"), PathBuf::from("package.json"), PathBuf::from("resources/app/package.json"), PathBuf::from("app/package.json"),
50 ];
51
52 for rel in json_candidates {
53 let path = app_dir.join(&rel);
54 if !path.is_file() {
55 continue;
56 }
57
58 let text = read_small_text(&path)?;
59 if text.trim().is_empty() {
60 continue;
61 }
62
63 if let Ok(json) = serde_json::from_str::<Value>(&text) {
64 if let Some(version) = json
65 .get("version")
66 .and_then(Value::as_str)
67 .and_then(clean_version)
68 {
69 return Ok(Some(VersionProbeResult {
70 version,
71 source: rel.display().to_string(),
72 }));
73 }
74
75 if let Some(version) = json
77 .get("buildNumber")
78 .and_then(Value::as_str)
79 .and_then(clean_version)
80 {
81 return Ok(Some(VersionProbeResult {
82 version,
83 source: format!("{}:buildNumber", rel.display()),
84 }));
85 }
86 }
87 }
88
89 let text_candidates = [
90 PathBuf::from("VERSION"),
91 PathBuf::from("VERSION.txt"),
92 PathBuf::from("version.txt"),
93 PathBuf::from("build.txt"),
94 PathBuf::from("RELEASE"),
95 ];
96
97 for rel in text_candidates {
98 let path = app_dir.join(&rel);
99 if !path.is_file() {
100 continue;
101 }
102
103 let text = read_small_text(&path)?;
104 if let Some(version) = parse_version_from_text(&text, "", "", "") {
105 return Ok(Some(VersionProbeResult {
106 version,
107 source: rel.display().to_string(),
108 }));
109 }
110 }
111
112 Ok(None)
113}
114
115fn detect_from_command(
116 exec_abs: &Path,
117 app_dir: &Path,
118 command_name: &str,
119 app_id: &str,
120 app_name: &str,
121) -> Result<Option<VersionProbeResult>> {
122 if !exec_abs.is_file() {
123 return Ok(None);
124 }
125
126 let probes: &[&[&str]] = &[
127 &["--version"],
128 &["-version"],
129 &["-v"],
130 &["version"],
131 &["-V"],
132 &[],
133 ];
134
135 for args in probes {
136 let output = match run_probe(exec_abs, app_dir, args, PROBE_TIMEOUT) {
137 Ok(output) => output,
138 Err(_) => continue,
139 };
140
141 if let Some(version) = parse_version_from_text(&output, command_name, app_id, app_name) {
142 let rendered_args = if args.is_empty() {
143 "<no args>".to_string()
144 } else {
145 args.join(" ")
146 };
147
148 return Ok(Some(VersionProbeResult {
149 version,
150 source: format!("{} {}", exec_abs.display(), rendered_args),
151 }));
152 }
153 }
154
155 Ok(None)
156}
157
158fn run_probe(exec_abs: &Path, app_dir: &Path, args: &[&str], timeout: Duration) -> Result<String> {
159 let mut child = Command::new(exec_abs)
160 .args(args)
161 .current_dir(app_dir)
162 .env("LC_ALL", "C")
163 .env("LANG", "C")
164 .env("NO_COLOR", "1")
165 .stdout(Stdio::piped())
166 .stderr(Stdio::piped())
167 .spawn()
168 .with_context(|| format!("failed to run version probe: {}", exec_abs.display()))?;
169
170 let stdout = child.stdout.take();
171 let stderr = child.stderr.take();
172
173 let out_handle = stdout.map(|stream| thread::spawn(move || read_limited(stream, MAX_OUTPUT_BYTES)));
174 let err_handle = stderr.map(|stream| thread::spawn(move || read_limited(stream, MAX_OUTPUT_BYTES)));
175
176 let started = Instant::now();
177 loop {
178 if child.try_wait()?.is_some() {
179 break;
180 }
181
182 if started.elapsed() >= timeout {
183 let _ = child.kill();
184 let _ = child.wait();
185 break;
186 }
187
188 thread::sleep(Duration::from_millis(40));
189 }
190
191 let mut output = String::new();
192
193 if let Some(handle) = out_handle {
194 output.push_str(&handle.join().unwrap_or_default());
195 }
196
197 if let Some(handle) = err_handle {
198 if !output.ends_with('\n') && !output.is_empty() {
199 output.push('\n');
200 }
201 output.push_str(&handle.join().unwrap_or_default());
202 }
203
204 Ok(output)
205}
206
207fn read_limited<R: Read>(stream: R, limit: u64) -> String {
208 let mut text = String::new();
209 let mut limited = stream.take(limit);
210 let _ = limited.read_to_string(&mut text);
211 text
212}
213
214fn read_small_text(path: &Path) -> Result<String> {
215 let meta = fs::metadata(path)?;
216 if meta.len() > MAX_METADATA_BYTES {
217 return Ok(String::new());
218 }
219
220 fs::read_to_string(path).with_context(|| format!("failed to read metadata file: {}", path.display()))
221}
222
223fn clean_version(value: &str) -> Option<String> {
224 let trimmed = value.trim().trim_matches(['\'', '"']);
225 if trimmed.is_empty() {
226 return None;
227 }
228
229 parse_version_from_text(trimmed, "", "", "")
230 .or_else(|| Some(trimmed.trim_start_matches(['v', 'V']).to_string()))
231}
232
233pub fn parse_version_from_text(
234 text: &str,
235 command_name: &str,
236 app_id: &str,
237 app_name: &str,
238) -> Option<String> {
239 let version_re = Regex::new(
240 r#"(?ix)
241 \b
242 (?:version\s*)?
243 ["']?
244 v?
245 (\d{1,4}(?:\.\d{1,8}){1,5}(?:[-+~._][0-9A-Za-z][0-9A-Za-z._+-]*)?)
246 ["']?
247 "#,
248 )
249 .unwrap();
250
251 let needles = [command_name, app_id, app_name]
252 .into_iter()
253 .filter(|s| !s.trim().is_empty())
254 .map(|s| s.to_ascii_lowercase())
255 .collect::<Vec<_>>();
256
257 let mut best: Option<(i32, String)> = None;
258
259 for (line_index, line) in text.lines().take(80).enumerate() {
260 let lower = line.to_ascii_lowercase();
261
262 for caps in version_re.captures_iter(line) {
263 let Some(raw) = caps.get(1).map(|m| m.as_str()) else {
264 continue;
265 };
266
267 if looks_like_false_positive(raw, &lower) {
268 continue;
269 }
270
271 let mut score = 1000 - line_index as i32;
272
273 if line_index == 0 {
274 score += 120;
275 }
276 if lower.contains("version") {
277 score += 80;
278 }
279 if needles.iter().any(|needle| lower.contains(needle)) {
280 score += 80;
281 }
282 if lower.contains("build date")
283 || lower.contains("build time")
284 || lower.contains("commit")
285 || lower.contains("runtime")
286 || lower.contains("luajit")
287 {
288 score -= 160;
289 }
290
291 let version = raw.trim_start_matches(['v', 'V']).to_string();
292
293 match &best {
294 Some((best_score, _)) if *best_score >= score => {}
295 _ => best = Some((score, version)),
296 }
297 }
298 }
299
300 best.map(|(_, version)| version)
301}
302
303fn looks_like_false_positive(version: &str, line_lower: &str) -> bool {
304 let lower = version.to_ascii_lowercase();
305
306 if line_lower.contains("copyright") || line_lower.contains("license") {
307 return true;
308 }
309
310 if !lower.contains('-') && !lower.contains('+') {
312 if lower.split('.').any(|part| part.len() > 8) {
313 return true;
314 }
315 }
316
317 false
318}
319
320#[cfg(test)]
321mod tests {
322 use super::*;
323
324 #[test]
325 fn parses_nvim_dev_version() {
326 let text = "NVIM v0.13.0-dev-915+g3b8c19ea46\nLuaJIT 2.1.1782726002";
327 assert_eq!(
328 parse_version_from_text(text, "nvim", "nvim", "Neovim").as_deref(),
329 Some("0.13.0-dev-915+g3b8c19ea46")
330 );
331 }
332
333 #[test]
334 fn parses_blender_version() {
335 let text = "Blender 5.1.2\n\tbuild date: 2026-05-19";
336 assert_eq!(
337 parse_version_from_text(text, "blender", "blender", "Blender").as_deref(),
338 Some("5.1.2")
339 );
340 }
341
342 #[test]
343 fn parses_java_version() {
344 let text = "openjdk version \"21.0.11\" 2026-04-21 LTS";
345 assert_eq!(
346 parse_version_from_text(text, "java", "java", "Java").as_deref(),
347 Some("21.0.11")
348 );
349 }
350
351 #[test]
352 fn parses_jetbrains_style_version() {
353 let text = "2026.1.4";
354 assert_eq!(
355 parse_version_from_text(text, "idea", "idea", "IntelliJ IDEA").as_deref(),
356 Some("2026.1.4")
357 );
358 }
359}