1use std::collections::HashMap;
36use std::path::{Path, PathBuf};
37use std::time::{SystemTime, UNIX_EPOCH};
38
39use serde::{Deserialize, Serialize};
40
41use crate::datastore::CommandRunner;
42use crate::fs::Fs;
43use crate::Result;
44
45pub const CACHE_TTL_SECS: u64 = 24 * 60 * 60;
49
50#[derive(Debug, Clone, Serialize, Deserialize, Default)]
56pub struct CaskInfo {
57 #[serde(default)]
59 pub token: String,
60 #[serde(default)]
62 pub name: Vec<String>,
63 #[serde(default)]
66 pub artifacts: Vec<serde_json::Value>,
67 #[serde(default)]
70 pub installed: Option<String>,
71}
72
73impl CaskInfo {
74 pub fn app_support_candidates(&self) -> Vec<String> {
79 zap_paths(&self.artifacts)
80 .filter_map(|p| {
81 let needle = "Library/Application Support/";
82 let idx = p.find(needle)?;
83 let rest = &p[idx + needle.len()..];
84 let leaf = rest.split('/').next()?.trim();
85 if leaf.is_empty() {
86 None
87 } else {
88 Some(leaf.to_string())
89 }
90 })
91 .collect()
92 }
93
94 pub fn preferences_plists(&self) -> Vec<String> {
98 zap_paths(&self.artifacts)
99 .filter(|p| p.contains("Library/Preferences/"))
100 .collect()
101 }
102
103 pub fn app_bundle_name(&self) -> Option<String> {
106 for artifact in &self.artifacts {
107 if let Some(arr) = artifact.get("app").and_then(|v| v.as_array()) {
108 if let Some(first) = arr.first().and_then(|v| v.as_str()) {
109 return Some(first.to_string());
110 }
111 }
112 }
113 None
114 }
115}
116
117fn zap_paths(artifacts: &[serde_json::Value]) -> impl Iterator<Item = String> + '_ {
122 artifacts.iter().flat_map(|art| {
123 let mut out: Vec<String> = Vec::new();
124 if let Some(zap) = art.get("zap") {
125 walk_strings(zap, &mut out);
126 }
127 out.into_iter()
128 })
129}
130
131fn walk_strings(v: &serde_json::Value, out: &mut Vec<String>) {
132 match v {
133 serde_json::Value::String(s) => out.push(s.clone()),
134 serde_json::Value::Array(a) => {
135 for child in a {
136 walk_strings(child, out);
137 }
138 }
139 serde_json::Value::Object(map) => {
140 for child in map.values() {
141 walk_strings(child, out);
142 }
143 }
144 _ => {}
145 }
146}
147
148#[derive(Debug, Clone, Serialize, Deserialize)]
150struct CacheEntry {
151 fetched_at: u64,
152 info: CaskInfo,
153}
154
155pub fn list_installed_casks(runner: &dyn CommandRunner) -> Vec<String> {
162 if !cfg!(target_os = "macos") {
163 return Vec::new();
164 }
165 let output = match runner.run(
166 "brew",
167 &["list".into(), "--cask".into(), "--versions".into()],
168 ) {
169 Ok(o) if o.exit_code == 0 => o,
170 _ => return Vec::new(),
171 };
172 output
173 .stdout
174 .lines()
175 .filter_map(|line| line.split_whitespace().next().map(str::to_string))
176 .collect()
177}
178
179pub fn info_cask(
194 token: &str,
195 cache_dir: &Path,
196 now_secs: u64,
197 fs: &dyn Fs,
198 runner: &dyn CommandRunner,
199) -> Result<Option<CaskInfo>> {
200 if !cfg!(target_os = "macos") {
201 return Ok(None);
202 }
203 let cache_path = cache_path_for(cache_dir, token);
204 if let Some(entry) = read_cache(&cache_path, fs) {
205 if now_secs.saturating_sub(entry.fetched_at) < CACHE_TTL_SECS {
206 return Ok(Some(entry.info));
207 }
208 }
209
210 let info = match fetch_from_brew(token, runner) {
211 Some(i) => i,
212 None => return Ok(None),
213 };
214
215 let entry = CacheEntry {
216 fetched_at: now_secs,
217 info: info.clone(),
218 };
219 let _ = write_cache(&cache_path, &entry, fs);
220 Ok(Some(info))
221}
222
223pub fn invalidate_cache(token: &str, cache_dir: &Path, fs: &dyn Fs) {
226 let path = cache_path_for(cache_dir, token);
227 if fs.exists(&path) {
228 let _ = fs.remove_file(&path);
229 }
230}
231
232fn cache_path_for(cache_dir: &Path, token: &str) -> PathBuf {
233 let safe = token.replace(['/', '\\', ':', ' '], "_");
237 cache_dir.join(format!("{safe}.json"))
238}
239
240fn read_cache(path: &Path, fs: &dyn Fs) -> Option<CacheEntry> {
241 if !fs.exists(path) {
242 return None;
243 }
244 let bytes = fs.read_to_string(path).ok()?;
245 serde_json::from_str(&bytes).ok()
246}
247
248fn write_cache(path: &Path, entry: &CacheEntry, fs: &dyn Fs) -> Result<()> {
249 if let Some(parent) = path.parent() {
250 if !fs.exists(parent) {
251 fs.mkdir_all(parent)?;
252 }
253 }
254 let json = serde_json::to_string(entry)
255 .map_err(|e| crate::DodotError::Other(format!("brew cache encode failed: {e}")))?;
256 fs.write_file(path, json.as_bytes())?;
257 Ok(())
258}
259
260fn fetch_from_brew(token: &str, runner: &dyn CommandRunner) -> Option<CaskInfo> {
261 let output = runner
262 .run(
263 "brew",
264 &[
265 "info".into(),
266 "--json=v2".into(),
267 "--cask".into(),
268 token.to_string(),
269 ],
270 )
271 .ok()?;
272 if output.exit_code != 0 {
273 return None;
274 }
275 parse_info_json(&output.stdout)
276}
277
278fn parse_info_json(stdout: &str) -> Option<CaskInfo> {
281 #[derive(Deserialize)]
282 struct Wrapper {
283 #[serde(default)]
284 casks: Vec<CaskInfo>,
285 }
286 let w: Wrapper = serde_json::from_str(stdout).ok()?;
287 w.casks.into_iter().next()
288}
289
290pub fn now_secs_unix() -> u64 {
294 SystemTime::now()
295 .duration_since(UNIX_EPOCH)
296 .map(|d| d.as_secs())
297 .unwrap_or(0)
298}
299
300#[derive(Debug, Clone, Default)]
308pub struct InstalledCaskMatches {
309 pub installed_tokens: Vec<String>,
310 pub folder_to_token: HashMap<String, String>,
311}
312
313pub fn match_folders_to_installed_casks(
330 folders: &[String],
331 runner: &dyn CommandRunner,
332 cache_dir: &Path,
333 now_secs: u64,
334 fs: &dyn Fs,
335 cache_only: bool,
336) -> InstalledCaskMatches {
337 let mut out = InstalledCaskMatches::default();
338 if !cfg!(target_os = "macos") {
339 return out;
340 }
341 out.installed_tokens = list_installed_casks(runner);
342 for token in &out.installed_tokens {
343 let info = if cache_only {
344 read_cache(&cache_path_for(cache_dir, token), fs)
347 .filter(|e| now_secs.saturating_sub(e.fetched_at) < CACHE_TTL_SECS)
348 .map(|e| e.info)
349 } else {
350 info_cask(token, cache_dir, now_secs, fs, runner)
351 .ok()
352 .flatten()
353 };
354 if let Some(info) = info {
355 for cand in info.app_support_candidates() {
356 if folders.iter().any(|f| f == &cand) {
357 out.folder_to_token.insert(cand, token.clone());
358 }
359 }
360 }
361 }
362 out
363}
364
365pub fn invalidate_all_cache(cache_dir: &Path, fs: &dyn Fs) {
372 if !fs.exists(cache_dir) {
373 return;
374 }
375 if let Ok(entries) = fs.read_dir(cache_dir) {
376 for entry in entries {
377 if entry.name.ends_with(".json") {
378 let _ = fs.remove_file(&entry.path);
379 }
380 }
381 }
382}
383
384#[cfg(test)]
385mod tests {
386 use super::*;
387 use crate::datastore::CommandOutput;
388 use std::sync::Mutex;
389
390 struct MockRunner {
392 responses: Mutex<HashMap<Vec<String>, CommandOutput>>,
393 calls: Mutex<Vec<Vec<String>>>,
394 }
395
396 impl MockRunner {
397 fn new() -> Self {
398 Self {
399 responses: Mutex::new(HashMap::new()),
400 calls: Mutex::new(Vec::new()),
401 }
402 }
403 fn respond(&self, args: &[&str], stdout: &str, exit_code: i32) {
404 let key: Vec<String> = args.iter().map(|s| s.to_string()).collect();
405 self.responses.lock().unwrap().insert(
406 key,
407 CommandOutput {
408 exit_code,
409 stdout: stdout.into(),
410 stderr: String::new(),
411 },
412 );
413 }
414 fn call_count(&self, args: &[&str]) -> usize {
415 let key: Vec<String> = args.iter().map(|s| s.to_string()).collect();
416 self.calls
417 .lock()
418 .unwrap()
419 .iter()
420 .filter(|c| **c == key)
421 .count()
422 }
423 }
424
425 impl CommandRunner for MockRunner {
426 fn run(&self, exe: &str, args: &[String]) -> Result<CommandOutput> {
427 let mut full = vec![exe.to_string()];
428 full.extend(args.iter().cloned());
429 self.calls.lock().unwrap().push(full.clone());
430 let key: Vec<String> = full.iter().skip(1).cloned().collect();
433 self.responses
434 .lock()
435 .unwrap()
436 .get(&key)
437 .cloned()
438 .ok_or_else(|| crate::DodotError::Other(format!("no mock response for {full:?}")))
439 }
440 }
441
442 fn make_env() -> (crate::testing::TempEnvironment, std::path::PathBuf) {
443 let env = crate::testing::TempEnvironment::builder().build();
444 let cache = env.home.join("brew-probe-cache");
445 env.fs.mkdir_all(&cache).unwrap();
446 (env, cache)
447 }
448
449 #[test]
450 fn parse_info_json_extracts_first_cask() {
451 let payload = r#"{
452 "casks": [
453 {
454 "token": "visual-studio-code",
455 "name": ["Visual Studio Code"],
456 "installed": "1.95.0",
457 "artifacts": [
458 {"app": ["Visual Studio Code.app"]},
459 {"zap": [
460 {"trash": [
461 "~/Library/Application Support/Code",
462 "~/Library/Preferences/com.microsoft.VSCode.plist"
463 ]}
464 ]}
465 ]
466 }
467 ]
468 }"#;
469 let info = parse_info_json(payload).expect("parse");
470 assert_eq!(info.token, "visual-studio-code");
471 assert_eq!(info.installed.as_deref(), Some("1.95.0"));
472 assert_eq!(
473 info.app_bundle_name().as_deref(),
474 Some("Visual Studio Code.app")
475 );
476 let candidates = info.app_support_candidates();
477 assert!(candidates.iter().any(|c| c == "Code"), "got {candidates:?}");
478 let plists = info.preferences_plists();
479 assert!(
480 plists
481 .iter()
482 .any(|p| p.contains("com.microsoft.VSCode.plist")),
483 "got {plists:?}"
484 );
485 }
486
487 #[test]
488 fn parse_info_json_missing_casks_array_returns_none() {
489 assert!(parse_info_json("{}").is_none());
490 assert!(parse_info_json("not json").is_none());
491 }
492
493 #[test]
494 #[cfg_attr(not(target_os = "macos"), ignore = "macOS-only behavior")]
495 fn info_cask_caches_first_result_then_serves_from_cache() {
496 let (env, cache) = make_env();
497 let runner = MockRunner::new();
498 runner.respond(
499 &["info", "--json=v2", "--cask", "visual-studio-code"],
500 r#"{"casks": [{"token": "visual-studio-code", "installed": "1.95.0"}]}"#,
501 0,
502 );
503
504 let now = 1_000_000;
505 let first = info_cask("visual-studio-code", &cache, now, env.fs.as_ref(), &runner)
506 .unwrap()
507 .expect("first call returns Some");
508 assert_eq!(first.token, "visual-studio-code");
509 assert_eq!(
510 runner.call_count(&["brew", "info", "--json=v2", "--cask", "visual-studio-code"]),
511 1
512 );
513
514 let _ = info_cask(
516 "visual-studio-code",
517 &cache,
518 now + 100,
519 env.fs.as_ref(),
520 &runner,
521 )
522 .unwrap();
523 assert_eq!(
524 runner.call_count(&["brew", "info", "--json=v2", "--cask", "visual-studio-code"]),
525 1,
526 "fresh cache must not re-fetch"
527 );
528 }
529
530 #[test]
531 #[cfg_attr(not(target_os = "macos"), ignore = "macOS-only behavior")]
532 fn info_cask_refetches_when_ttl_expires() {
533 let (env, cache) = make_env();
534 let runner = MockRunner::new();
535 runner.respond(
536 &["info", "--json=v2", "--cask", "cursor"],
537 r#"{"casks": [{"token": "cursor"}]}"#,
538 0,
539 );
540
541 let now = 1_000_000;
542 let _ = info_cask("cursor", &cache, now, env.fs.as_ref(), &runner).unwrap();
543 let _ = info_cask(
545 "cursor",
546 &cache,
547 now + CACHE_TTL_SECS + 1,
548 env.fs.as_ref(),
549 &runner,
550 )
551 .unwrap();
552 assert_eq!(
553 runner.call_count(&["brew", "info", "--json=v2", "--cask", "cursor"]),
554 2,
555 "stale cache should re-fetch"
556 );
557 }
558
559 #[test]
560 #[cfg_attr(not(target_os = "macos"), ignore = "macOS-only behavior")]
561 fn invalidate_cache_forces_refetch() {
562 let (env, cache) = make_env();
563 let runner = MockRunner::new();
564 runner.respond(
565 &["info", "--json=v2", "--cask", "zed"],
566 r#"{"casks": [{"token": "zed"}]}"#,
567 0,
568 );
569
570 let now = 1_000_000;
571 let _ = info_cask("zed", &cache, now, env.fs.as_ref(), &runner).unwrap();
572 invalidate_cache("zed", &cache, env.fs.as_ref());
573 let _ = info_cask("zed", &cache, now + 10, env.fs.as_ref(), &runner).unwrap();
574 assert_eq!(
575 runner.call_count(&["brew", "info", "--json=v2", "--cask", "zed"]),
576 2
577 );
578 }
579
580 #[test]
581 #[cfg_attr(not(target_os = "macos"), ignore = "macOS-only behavior")]
582 fn info_cask_returns_none_on_brew_failure() {
583 let (env, cache) = make_env();
584 let runner = MockRunner::new();
585 runner.respond(
586 &["info", "--json=v2", "--cask", "nonexistent"],
587 "",
588 1, );
590 let got = info_cask("nonexistent", &cache, 100, env.fs.as_ref(), &runner).unwrap();
591 assert!(got.is_none());
592 }
593
594 #[test]
595 #[cfg_attr(not(target_os = "macos"), ignore = "macOS-only behavior")]
596 fn list_installed_casks_parses_first_column() {
597 let runner = MockRunner::new();
598 runner.respond(
599 &["list", "--cask", "--versions"],
600 "visual-studio-code 1.95.0\ncursor 0.42.0\nzed 0.150.0\n",
601 0,
602 );
603 let got = list_installed_casks(&runner);
604 assert_eq!(got, vec!["visual-studio-code", "cursor", "zed"]);
605 }
606
607 #[test]
608 fn list_installed_casks_silent_on_non_macos() {
609 let runner = MockRunner::new();
612 let got = list_installed_casks(&runner);
613 if !cfg!(target_os = "macos") {
614 assert!(got.is_empty());
615 }
616 }
620
621 #[test]
622 #[cfg_attr(not(target_os = "macos"), ignore = "macOS-only behavior")]
623 fn match_folders_cache_only_skips_brew_info_on_miss() {
624 let (env, cache) = make_env();
628 let runner = MockRunner::new();
629 runner.respond(
630 &["list", "--cask", "--versions"],
631 "visual-studio-code 1.95.0\n",
632 0,
633 );
634 let now = 1_000_000;
639 let result = match_folders_to_installed_casks(
640 &["Code".into()],
641 &runner,
642 &cache,
643 now,
644 env.fs.as_ref(),
645 true,
646 );
647 assert!(result
649 .installed_tokens
650 .contains(&"visual-studio-code".into()));
651 assert!(result.folder_to_token.is_empty());
653 assert_eq!(
655 runner.call_count(&["brew", "info", "--json=v2", "--cask", "visual-studio-code"]),
656 0,
657 "cache_only=true must not spawn brew info"
658 );
659 }
660
661 #[test]
662 #[cfg_attr(not(target_os = "macos"), ignore = "macOS-only behavior")]
663 fn invalidate_all_cache_clears_every_token() {
664 let (env, cache) = make_env();
665 let runner = MockRunner::new();
666 runner.respond(
667 &["info", "--json=v2", "--cask", "alpha"],
668 r#"{"casks": [{"token": "alpha"}]}"#,
669 0,
670 );
671 runner.respond(
672 &["info", "--json=v2", "--cask", "beta"],
673 r#"{"casks": [{"token": "beta"}]}"#,
674 0,
675 );
676 let now = 1_000_000;
677 let _ = info_cask("alpha", &cache, now, env.fs.as_ref(), &runner).unwrap();
678 let _ = info_cask("beta", &cache, now, env.fs.as_ref(), &runner).unwrap();
679 assert!(env.fs.exists(&cache.join("alpha.json")));
680 assert!(env.fs.exists(&cache.join("beta.json")));
681
682 invalidate_all_cache(&cache, env.fs.as_ref());
683 assert!(!env.fs.exists(&cache.join("alpha.json")));
684 assert!(!env.fs.exists(&cache.join("beta.json")));
685 }
686
687 #[test]
688 fn cache_path_sanitizes_token() {
689 use std::path::Component;
694 let cache = Path::new("/tmp/brew-cache");
695 let p = cache_path_for(cache, "evil/../token");
696 assert!(p.starts_with(cache));
697 let escapes = p.components().any(|c| matches!(c, Component::ParentDir));
698 assert!(
699 !escapes,
700 "sanitized path must not contain a ParentDir component: {}",
701 p.display()
702 );
703 }
704}