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
17pub struct DetectionConfig<'a> {
19 pub app_name: &'a str,
21 pub brew_cask_name: Option<&'a str>,
23 pub custom_detectors: &'a [CustomDetector],
25 pub receipt_dir: Option<&'a Path>,
27}
28
29pub async fn detect_install(
38 exec_path: &str,
39 config: &DetectionConfig<'_>,
40 cmd: &dyn CommandRunner,
41) -> InstallDetection {
42 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 if let Some(detection) = detect_from_receipt(config.app_name, config.receipt_dir).await {
53 return detection;
54 }
55
56 if let Some(detection) = detect_from_brew(exec_path, config.brew_cask_name, cmd).await {
58 return detection;
59 }
60
61 if let Some(detection) = detect_from_npm(exec_path, cmd).await {
63 return detection;
64 }
65
66 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 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 assert_eq!(detection.channel, Channel::Unmanaged);
218 }
219
220 #[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 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 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}