Skip to main content

update_kit/detection/
brew.rs

1use crate::types::{Channel, Confidence, Evidence, InstallDetection};
2use crate::utils::process::CommandRunner;
3
4/// Path patterns that indicate a Homebrew installation.
5const BREW_PATH_PATTERNS: &[&str] = &[
6    "/opt/homebrew/",
7    "/usr/local/Caskroom/",
8    "/usr/local/Cellar/",
9    "/home/linuxbrew/",
10];
11
12/// Detects installation via Homebrew by checking path patterns and optionally
13/// verifying with `brew list --cask`.
14///
15/// If the executable path contains a brew pattern, detection is triggered.
16/// If `brew_cask_name` is provided, `brew list --cask {name}` is run for
17/// verification, yielding High confidence on success. Otherwise, Medium
18/// confidence is returned.
19pub async fn detect_from_brew(
20    exec_path: &str,
21    brew_cask_name: Option<&str>,
22    cmd: &dyn CommandRunner,
23) -> Option<InstallDetection> {
24    let matching_pattern = BREW_PATH_PATTERNS
25        .iter()
26        .find(|pattern| exec_path.contains(*pattern));
27
28    let pattern = matching_pattern?;
29    let mut evidence = vec![Evidence {
30        source: "brew-path".into(),
31        detail: format!("path contains brew pattern '{}'", pattern),
32    }];
33
34    // If a cask name is provided, try to verify with brew
35    if let Some(cask_name) = brew_cask_name {
36        match cmd.run("brew", &["list", "--cask", cask_name]).await {
37            Ok(output) if output.success() => {
38                evidence.push(Evidence {
39                    source: "brew-verify".into(),
40                    detail: format!("brew list --cask {} succeeded", cask_name),
41                });
42                return Some(InstallDetection {
43                    channel: Channel::BrewCask,
44                    confidence: Confidence::High,
45                    evidence,
46                });
47            }
48            _ => {
49                // Verification failed but path still matched, use Medium
50            }
51        }
52    }
53
54    Some(InstallDetection {
55        channel: Channel::BrewCask,
56        confidence: Confidence::Medium,
57        evidence,
58    })
59}
60
61#[cfg(test)]
62mod tests {
63    use super::*;
64    use crate::utils::process::TokioCommandRunner;
65
66    #[tokio::test]
67    async fn brew_path_detected_without_cask_name() {
68        let cmd = TokioCommandRunner;
69        let result = detect_from_brew("/opt/homebrew/bin/my-app", None, &cmd).await;
70        assert!(result.is_some());
71        let detection = result.unwrap();
72        assert_eq!(detection.channel, Channel::BrewCask);
73        assert_eq!(detection.confidence, Confidence::Medium);
74    }
75
76    #[tokio::test]
77    async fn non_brew_path_returns_none() {
78        let cmd = TokioCommandRunner;
79        let result = detect_from_brew("/usr/bin/my-app", None, &cmd).await;
80        assert!(result.is_none());
81    }
82
83    #[tokio::test]
84    async fn cellar_path_detected() {
85        let cmd = TokioCommandRunner;
86        let result =
87            detect_from_brew("/usr/local/Cellar/my-app/1.0/bin/my-app", None, &cmd).await;
88        assert!(result.is_some());
89        let detection = result.unwrap();
90        assert_eq!(detection.channel, Channel::BrewCask);
91    }
92
93    #[tokio::test]
94    async fn caskroom_path_detected() {
95        let cmd = TokioCommandRunner;
96        let result = detect_from_brew(
97            "/usr/local/Caskroom/my-app/1.0/my-app.app/bin/my-app",
98            None,
99            &cmd,
100        )
101        .await;
102        assert!(result.is_some());
103    }
104
105    #[tokio::test]
106    async fn linuxbrew_path_detected() {
107        let cmd = TokioCommandRunner;
108        let result = detect_from_brew("/home/linuxbrew/.linuxbrew/bin/my-app", None, &cmd).await;
109        assert!(result.is_some());
110    }
111
112    // ── MockCommandRunner tests ──
113
114    use crate::test_utils::MockCommandRunner;
115
116    #[tokio::test]
117    async fn verified_cask_high_confidence() {
118        let cmd = MockCommandRunner::new();
119        cmd.on(
120            "brew list --cask my-cask",
121            Ok(MockCommandRunner::success_output("my-cask")),
122        );
123
124        let result = detect_from_brew("/opt/homebrew/bin/my-app", Some("my-cask"), &cmd).await;
125        let detection = result.unwrap();
126        assert_eq!(detection.channel, Channel::BrewCask);
127        assert_eq!(detection.confidence, Confidence::High);
128        assert!(detection.evidence.len() >= 2); // path + verify
129    }
130
131    #[tokio::test]
132    async fn failed_verification_medium_confidence() {
133        let cmd = MockCommandRunner::new();
134        cmd.on(
135            "brew list --cask my-cask",
136            Ok(MockCommandRunner::failure_output("Error")),
137        );
138
139        let result = detect_from_brew("/opt/homebrew/bin/my-app", Some("my-cask"), &cmd).await;
140        let detection = result.unwrap();
141        assert_eq!(detection.channel, Channel::BrewCask);
142        assert_eq!(detection.confidence, Confidence::Medium);
143        assert_eq!(detection.evidence.len(), 1); // path only
144    }
145
146    #[tokio::test]
147    async fn brew_command_not_found_medium_confidence() {
148        let cmd = MockCommandRunner::new();
149        // No response registered — will return error
150
151        let result = detect_from_brew("/opt/homebrew/bin/my-app", Some("my-cask"), &cmd).await;
152        let detection = result.unwrap();
153        assert_eq!(detection.confidence, Confidence::Medium);
154    }
155
156    #[tokio::test]
157    async fn evidence_source_is_brew_path() {
158        let cmd = MockCommandRunner::new();
159        let result = detect_from_brew("/opt/homebrew/bin/my-app", None, &cmd).await;
160        let detection = result.unwrap();
161        assert!(detection.evidence.iter().any(|e| e.source == "brew-path"));
162    }
163
164    #[tokio::test]
165    async fn evidence_has_verify_on_success() {
166        let cmd = MockCommandRunner::new();
167        cmd.on(
168            "brew list --cask my-cask",
169            Ok(MockCommandRunner::success_output("")),
170        );
171
172        let result = detect_from_brew("/opt/homebrew/bin/my-app", Some("my-cask"), &cmd).await;
173        let detection = result.unwrap();
174        assert!(detection
175            .evidence
176            .iter()
177            .any(|e| e.source == "brew-verify"));
178    }
179
180    #[tokio::test]
181    async fn usr_local_caskroom_detected() {
182        let cmd = MockCommandRunner::new();
183        let result =
184            detect_from_brew("/usr/local/Caskroom/my-app/1.0/bin/my-app", None, &cmd).await;
185        assert!(result.is_some());
186        assert_eq!(result.unwrap().channel, Channel::BrewCask);
187    }
188
189    #[tokio::test]
190    async fn non_brew_path_with_cask_name_returns_none() {
191        // Even with cask_name provided, non-brew path should return None
192        let cmd = MockCommandRunner::new();
193        let result = detect_from_brew("/usr/bin/my-app", Some("my-cask"), &cmd).await;
194        assert!(result.is_none());
195    }
196}