mur_common/deps/
detect.rs1use crate::deps::{DepStatus, DetectMethod, ProgramDep, VersionCheck};
4use std::path::{Path, PathBuf};
5
6pub fn detect(dep: &ProgramDep, mur_home: &Path) -> DepStatus {
9 match &dep.detect {
10 DetectMethod::File { file } => {
11 if expand_path(file, mur_home).exists() {
12 DepStatus::Present
13 } else {
14 DepStatus::Missing
15 }
16 }
17 DetectMethod::Command { command } => {
18 if command_on_path(command) {
19 DepStatus::Present
20 } else {
21 DepStatus::Missing
22 }
23 }
24 DetectMethod::Version { version } => detect_version(version),
25 }
26}
27
28pub fn expand_path(raw: &str, mur_home: &Path) -> PathBuf {
31 let s = raw.trim();
32 if let Some(rest) = s.strip_prefix("~/")
33 && let Some(home) = std::env::var_os("HOME")
34 {
35 return PathBuf::from(home).join(rest);
36 }
37 let p = PathBuf::from(s);
38 if p.is_absolute() { p } else { mur_home.join(p) }
39}
40
41pub fn command_on_path(cmd: &str) -> bool {
44 let path = match std::env::var_os("PATH") {
45 Some(p) => p,
46 None => return false,
47 };
48 let exts: &[&str] = if cfg!(windows) {
49 &["", ".exe", ".cmd", ".bat"]
50 } else {
51 &[""]
52 };
53 for dir in std::env::split_paths(&path) {
54 for ext in exts {
55 let candidate = dir.join(format!("{cmd}{ext}"));
56 if candidate.is_file() {
57 return true;
58 }
59 }
60 }
61 false
62}
63
64fn detect_version(v: &VersionCheck) -> DepStatus {
65 let mut parts = v.command.split_whitespace();
66 let bin = match parts.next() {
67 Some(b) => b,
68 None => return DepStatus::Missing,
69 };
70 let args: Vec<&str> = parts.collect();
71 let out = std::process::Command::new(bin).args(&args).output();
72 let out = match out {
73 Ok(o) => o,
74 Err(_) => return DepStatus::Missing,
75 };
76 let text = String::from_utf8_lossy(&out.stdout);
77 match first_semver(&text) {
78 Some(found) if semver_ge(&found, &v.min) => DepStatus::Present,
79 Some(found) => DepStatus::PresentWrongVersion { found },
80 None => DepStatus::PresentWrongVersion {
82 found: "unknown".into(),
83 },
84 }
85}
86
87fn first_semver(s: &str) -> Option<String> {
89 let bytes = s.as_bytes();
90 let mut i = 0;
91 while i < bytes.len() {
92 if bytes[i].is_ascii_digit() {
93 let start = i;
94 while i < bytes.len() && (bytes[i].is_ascii_digit() || bytes[i] == b'.') {
95 i += 1;
96 }
97 let cand = &s[start..i];
98 if cand.contains('.') {
99 return Some(cand.trim_end_matches('.').to_string());
100 }
101 } else {
102 i += 1;
103 }
104 }
105 None
106}
107
108fn semver_ge(a: &str, b: &str) -> bool {
109 let pa: Vec<u64> = a.split('.').filter_map(|x| x.parse().ok()).collect();
110 let pb: Vec<u64> = b.split('.').filter_map(|x| x.parse().ok()).collect();
111 for i in 0..pa.len().max(pb.len()) {
112 let x = pa.get(i).copied().unwrap_or(0);
113 let y = pb.get(i).copied().unwrap_or(0);
114 if x != y {
115 return x > y;
116 }
117 }
118 true
119}
120
121#[cfg(test)]
122mod tests {
123 use super::*;
124 use crate::deps::{DepStatus, DetectMethod, ProgramDep};
125 use std::path::Path;
126
127 fn dep(detect: DetectMethod) -> ProgramDep {
128 ProgramDep {
129 name: "x".into(),
130 detect,
131 reason: "r".into(),
132 hint: None,
133 registry: None,
134 recipe: None,
135 }
136 }
137
138 #[test]
139 fn file_detect_present_and_absent() {
140 let tmp = std::env::temp_dir().join(format!("murdep_{}", std::process::id()));
141 std::fs::create_dir_all(tmp.join("aura")).unwrap();
142 std::fs::write(tmp.join("aura/lightpanda"), b"x").unwrap();
143 let present = dep(DetectMethod::File {
145 file: "aura/lightpanda".into(),
146 });
147 assert_eq!(detect(&present, &tmp), DepStatus::Present);
148 let absent = dep(DetectMethod::File {
149 file: "aura/nope".into(),
150 });
151 assert_eq!(detect(&absent, &tmp), DepStatus::Missing);
152 std::fs::remove_dir_all(&tmp).ok();
153 }
154
155 #[test]
156 fn command_detect_missing_for_bogus() {
157 let d = dep(DetectMethod::Command {
158 command: "definitely-not-a-real-binary-xyz".into(),
159 });
160 assert_eq!(detect(&d, Path::new("/")), DepStatus::Missing);
161 }
162}