Skip to main content

mur_common/deps/
detect.rs

1//! Cross-platform, side-effect-free detection of a `ProgramDep`.
2
3use crate::deps::{DepStatus, DetectMethod, ProgramDep, VersionCheck};
4use std::path::{Path, PathBuf};
5
6/// Detect whether `dep` is present. Never installs; the `version` arm runs a
7/// bounded subprocess only to read a version string.
8pub 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
28/// Expand `~`/`$MUR_HOME` and resolve a bare relative path against `mur_home`
29/// (so `aura/lightpanda` and `~/.mur/aura/lightpanda` both work).
30pub 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
41/// True if `cmd` resolves to an executable on `PATH` (Windows: also tries
42/// PATHEXT-style `.exe`/`.cmd`).
43pub 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        // present but unparseable → report as present-unknown (never auto-action)
81        None => DepStatus::PresentWrongVersion {
82            found: "unknown".into(),
83        },
84    }
85}
86
87/// First `MAJOR.MINOR.PATCH` (or `MAJOR.MINOR`) run in `s`.
88fn 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        // mur_home-relative via the literal "aura/lightpanda" is expanded against mur_home
144        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}