Skip to main content

update_kit/detection/
mod.rs

1pub mod brew;
2pub mod heuristics;
3pub mod npm;
4pub mod receipt;
5
6use std::path::Path;
7
8use crate::config::CustomDetector;
9use crate::types::{Channel, Confidence, Evidence, InstallDetection};
10use crate::utils::process::CommandRunner;
11
12pub use brew::detect_from_brew;
13pub use heuristics::collect_path_heuristics;
14pub use npm::detect_from_npm;
15pub use receipt::detect_from_receipt;
16
17/// Configuration for the detection pipeline.
18pub struct DetectionConfig<'a> {
19    /// The application name (used for receipt lookup).
20    pub app_name: &'a str,
21    /// Optional Homebrew cask name for brew verification.
22    pub brew_cask_name: Option<&'a str>,
23    /// Custom detectors to run before built-in detection.
24    pub custom_detectors: &'a [CustomDetector],
25    /// Optional override for the receipt directory (defaults to platform config dir).
26    pub receipt_dir: Option<&'a Path>,
27}
28
29/// Main orchestrator for install detection.
30///
31/// Detection priority:
32/// 1. Custom detectors (iterate, first non-None result wins)
33/// 2. Install receipt (check for config_dir/{app_name}/install-receipt.json)
34/// 3. Homebrew (check path patterns + optional `brew list --cask` verification)
35/// 4. npm global (check path patterns + optional `npm prefix -g` verification)
36/// 5. Fallback: unmanaged with heuristic evidence
37pub async fn detect_install(
38    exec_path: &str,
39    config: &DetectionConfig<'_>,
40    cmd: &dyn CommandRunner,
41) -> InstallDetection {
42    // 1. Custom detectors
43    for detector in config.custom_detectors {
44        match (detector.detect)().await {
45            Ok(Some(detection)) => return detection,
46            Ok(None) => continue,
47            Err(_) => continue,
48        }
49    }
50
51    // 2. Install receipt
52    if let Some(detection) = detect_from_receipt(config.app_name, config.receipt_dir).await {
53        return detection;
54    }
55
56    // 3. Homebrew
57    if let Some(detection) = detect_from_brew(exec_path, config.brew_cask_name, cmd).await {
58        return detection;
59    }
60
61    // 4. npm global
62    if let Some(detection) = detect_from_npm(exec_path, cmd).await {
63        return detection;
64    }
65
66    // 5. Fallback: unmanaged with heuristic evidence
67    let evidence = collect_path_heuristics(exec_path);
68    let mut all_evidence = vec![Evidence {
69        source: "fallback".into(),
70        detail: "no known install channel detected".into(),
71    }];
72    all_evidence.extend(evidence);
73
74    InstallDetection {
75        channel: Channel::Unmanaged,
76        confidence: Confidence::Low,
77        evidence: all_evidence,
78    }
79}
80
81#[cfg(test)]
82mod tests {
83    use super::*;
84    use crate::test_utils::MockCommandRunner;
85    use crate::utils::process::TokioCommandRunner;
86
87    #[tokio::test]
88    async fn fallback_to_unmanaged() {
89        let cmd = TokioCommandRunner;
90        let config = DetectionConfig {
91            app_name: "nonexistent-test-app",
92            brew_cask_name: None,
93            custom_detectors: &[],
94            receipt_dir: None,
95        };
96
97        let detection = detect_install("/tmp/random/path/my-app", &config, &cmd).await;
98        assert_eq!(detection.channel, Channel::Unmanaged);
99        assert_eq!(detection.confidence, Confidence::Low);
100        assert!(!detection.evidence.is_empty());
101    }
102
103    #[tokio::test]
104    async fn custom_detector_takes_priority() {
105        let detector = CustomDetector {
106            name: "test-detector".into(),
107            detect: Box::new(|| {
108                Box::pin(async {
109                    Ok(Some(InstallDetection {
110                        channel: Channel::Custom("test-channel".into()),
111                        confidence: Confidence::High,
112                        evidence: vec![Evidence {
113                            source: "test".into(),
114                            detail: "custom detection".into(),
115                        }],
116                    }))
117                })
118            }),
119        };
120
121        let detectors = vec![detector];
122        let config = DetectionConfig {
123            app_name: "test-app",
124            brew_cask_name: None,
125            custom_detectors: &detectors,
126            receipt_dir: None,
127        };
128
129        let cmd = TokioCommandRunner;
130        let detection = detect_install("/tmp/random/path", &config, &cmd).await;
131        assert_eq!(detection.channel, Channel::Custom("test-channel".into()));
132        assert_eq!(detection.confidence, Confidence::High);
133    }
134
135    #[tokio::test]
136    async fn receipt_detection_before_path_heuristics() {
137        let tmp = tempfile::TempDir::new().unwrap();
138        let app_dir = tmp.path().join("my-app");
139        std::fs::create_dir_all(&app_dir).unwrap();
140        std::fs::write(
141            app_dir.join("install-receipt.json"),
142            r#"{"channel": "native"}"#,
143        )
144        .unwrap();
145
146        // Use a brew-like path, but receipt should win
147        let config = DetectionConfig {
148            app_name: "my-app",
149            brew_cask_name: None,
150            custom_detectors: &[],
151            receipt_dir: Some(tmp.path()),
152        };
153
154        let cmd = TokioCommandRunner;
155        let detection = detect_install("/opt/homebrew/bin/my-app", &config, &cmd).await;
156        assert_eq!(detection.channel, Channel::Native);
157        assert_eq!(detection.confidence, Confidence::High);
158    }
159
160    #[tokio::test]
161    async fn brew_path_detected_without_receipt() {
162        let cmd = TokioCommandRunner;
163        let tmp = tempfile::TempDir::new().unwrap();
164
165        let config = DetectionConfig {
166            app_name: "nonexistent-app",
167            brew_cask_name: None,
168            custom_detectors: &[],
169            receipt_dir: Some(tmp.path()),
170        };
171
172        let detection = detect_install("/opt/homebrew/bin/my-app", &config, &cmd).await;
173        assert_eq!(detection.channel, Channel::BrewCask);
174    }
175
176    #[tokio::test]
177    async fn npm_path_detected_without_receipt() {
178        let cmd = TokioCommandRunner;
179        let tmp = tempfile::TempDir::new().unwrap();
180
181        let config = DetectionConfig {
182            app_name: "nonexistent-app",
183            brew_cask_name: None,
184            custom_detectors: &[],
185            receipt_dir: Some(tmp.path()),
186        };
187
188        let detection =
189            detect_install("/usr/local/lib/node_modules/.bin/my-app", &config, &cmd).await;
190        assert_eq!(detection.channel, Channel::NpmGlobal);
191    }
192
193    #[tokio::test]
194    async fn failing_custom_detector_skipped() {
195        let detector = CustomDetector {
196            name: "failing-detector".into(),
197            detect: Box::new(|| {
198                Box::pin(async {
199                    Err(crate::errors::UpdateKitError::DetectionFailed(
200                        "intentional failure".into(),
201                    ))
202                })
203            }),
204        };
205
206        let detectors = vec![detector];
207        let config = DetectionConfig {
208            app_name: "nonexistent-app",
209            brew_cask_name: None,
210            custom_detectors: &detectors,
211            receipt_dir: None,
212        };
213
214        let cmd = TokioCommandRunner;
215        let detection = detect_install("/tmp/random/path", &config, &cmd).await;
216        // Should fall through to unmanaged since custom detector errored
217        assert_eq!(detection.channel, Channel::Unmanaged);
218    }
219
220    // ── Orchestration tests using MockCommandRunner ──
221
222    #[tokio::test]
223    async fn custom_detector_returning_none_continues_pipeline() {
224        let skip_detector = CustomDetector {
225            name: "skip".into(),
226            detect: Box::new(|| Box::pin(async { Ok(None) })),
227        };
228        let detectors = vec![skip_detector];
229        let tmp = tempfile::TempDir::new().unwrap();
230        let cmd = MockCommandRunner::new();
231        let config = DetectionConfig {
232            app_name: "test-app",
233            brew_cask_name: None,
234            custom_detectors: &detectors,
235            receipt_dir: Some(tmp.path()),
236        };
237        // Should fall through to unmanaged since skip detector returns None and no brew/npm path
238        let detection = detect_install("/tmp/random/path", &config, &cmd).await;
239        assert_eq!(detection.channel, Channel::Unmanaged);
240    }
241
242    #[tokio::test]
243    async fn multiple_custom_detectors_first_match_wins() {
244        let first = CustomDetector {
245            name: "first".into(),
246            detect: Box::new(|| {
247                Box::pin(async {
248                    Ok(Some(InstallDetection {
249                        channel: Channel::Custom("first-channel".into()),
250                        confidence: Confidence::High,
251                        evidence: vec![Evidence {
252                            source: "first".into(),
253                            detail: "first wins".into(),
254                        }],
255                    }))
256                })
257            }),
258        };
259        let second = CustomDetector {
260            name: "second".into(),
261            detect: Box::new(|| {
262                Box::pin(async {
263                    Ok(Some(InstallDetection {
264                        channel: Channel::Custom("second-channel".into()),
265                        confidence: Confidence::High,
266                        evidence: vec![],
267                    }))
268                })
269            }),
270        };
271        let detectors = vec![first, second];
272        let cmd = MockCommandRunner::new();
273        let config = DetectionConfig {
274            app_name: "test-app",
275            brew_cask_name: None,
276            custom_detectors: &detectors,
277            receipt_dir: None,
278        };
279        let detection = detect_install("/tmp/path", &config, &cmd).await;
280        assert_eq!(detection.channel, Channel::Custom("first-channel".into()));
281    }
282
283    #[tokio::test]
284    async fn brew_verified_before_npm() {
285        let cmd = MockCommandRunner::new();
286        cmd.on(
287            "brew list --cask my-cask",
288            Ok(MockCommandRunner::success_output("")),
289        );
290        let tmp = tempfile::TempDir::new().unwrap();
291        let config = DetectionConfig {
292            app_name: "test-app",
293            brew_cask_name: Some("my-cask"),
294            custom_detectors: &[],
295            receipt_dir: Some(tmp.path()),
296        };
297        let detection = detect_install("/opt/homebrew/bin/my-app", &config, &cmd).await;
298        assert_eq!(detection.channel, Channel::BrewCask);
299    }
300
301    #[tokio::test]
302    async fn fallback_unmanaged_has_fallback_evidence() {
303        let cmd = MockCommandRunner::new();
304        let tmp = tempfile::TempDir::new().unwrap();
305        let config = DetectionConfig {
306            app_name: "test-app",
307            brew_cask_name: None,
308            custom_detectors: &[],
309            receipt_dir: Some(tmp.path()),
310        };
311        let detection = detect_install("/usr/local/bin/random-app", &config, &cmd).await;
312        assert_eq!(detection.channel, Channel::Unmanaged);
313        assert_eq!(detection.confidence, Confidence::Low);
314        assert!(detection.evidence.iter().any(|e| e.source == "fallback"));
315    }
316
317    #[tokio::test]
318    async fn receipt_with_custom_dir_wins_over_brew_path() {
319        let tmp = tempfile::TempDir::new().unwrap();
320        let app_dir = tmp.path().join("my-app");
321        std::fs::create_dir_all(&app_dir).unwrap();
322        std::fs::write(
323            app_dir.join("install-receipt.json"),
324            r#"{"channel":"native"}"#,
325        )
326        .unwrap();
327
328        let cmd = MockCommandRunner::new();
329        let config = DetectionConfig {
330            app_name: "my-app",
331            brew_cask_name: None,
332            custom_detectors: &[],
333            receipt_dir: Some(tmp.path()),
334        };
335        // Brew path but receipt exists — receipt wins
336        let detection = detect_install("/opt/homebrew/bin/my-app", &config, &cmd).await;
337        assert_eq!(detection.channel, Channel::Native);
338        assert_eq!(detection.confidence, Confidence::High);
339    }
340
341    #[tokio::test]
342    async fn npm_path_detected_when_no_receipt_or_brew() {
343        let tmp = tempfile::TempDir::new().unwrap();
344        let cmd = MockCommandRunner::new();
345        let config = DetectionConfig {
346            app_name: "test-app",
347            brew_cask_name: None,
348            custom_detectors: &[],
349            receipt_dir: Some(tmp.path()),
350        };
351        let detection =
352            detect_install("/usr/local/lib/node_modules/.bin/my-app", &config, &cmd).await;
353        assert_eq!(detection.channel, Channel::NpmGlobal);
354    }
355}