1use std::collections::HashMap;
10use std::path::{Path, PathBuf};
11use std::sync::atomic::{AtomicU64, Ordering};
12#[cfg(not(test))]
13use std::sync::OnceLock;
14use std::time::{SystemTime, UNIX_EPOCH};
15
16static TMP_COUNTER: AtomicU64 = AtomicU64::new(0);
19
20#[cfg(not(test))]
25static SWEEP_DONE: OnceLock<()> = OnceLock::new();
26
27use serde::{Deserialize, Serialize};
28use sha2::{Digest, Sha256};
29
30const CACHE_TTL_SECS: u64 = 60;
32
33const GH_FETCH_LIMIT: usize = 500;
39
40#[non_exhaustive]
45#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
46#[serde(rename_all = "UPPERCASE")]
47pub enum PrState {
48 Open,
49 Merged,
50 Closed,
51 #[serde(other)]
52 Other,
53}
54
55#[derive(Debug, Clone, Serialize, Deserialize)]
56struct CacheFile {
57 fetched_at: u64,
58 repo: String,
59 prs: HashMap<String, PrState>,
60}
61
62#[derive(Debug, Default, Clone)]
63pub struct PrCache {
64 map: HashMap<String, PrState>,
65}
66
67impl PrCache {
68 pub fn state(&self, branch: &str) -> Option<&PrState> {
76 self.map.get(branch)
77 }
78
79 pub fn from_disk(repo: &Path) -> Option<Self> {
82 load_from_disk(repo).map(|map| PrCache { map })
83 }
84
85 pub fn fetch_and_persist(repo: &Path) -> Self {
91 match fetch_from_gh(repo) {
92 Some(map) => {
93 write_to_disk(repo, &map);
94 PrCache { map }
95 }
96 None => PrCache::default(),
97 }
98 }
99
100 pub fn load_or_fetch(repo: &Path, no_cache: bool) -> Self {
108 if !no_cache {
109 if let Some(c) = Self::from_disk(repo) {
110 return c;
111 }
112 }
113 Self::fetch_and_persist(repo)
114 }
115}
116
117fn repo_hash(repo: &Path) -> String {
125 let canon = repo.canonicalize().unwrap_or_else(|_| repo.to_path_buf());
126 let mut hasher = Sha256::new();
127 hasher.update(canon.to_string_lossy().as_bytes());
128 let digest = hasher.finalize();
129 hex_short(&digest[..8])
130}
131
132fn hex_short(bytes: &[u8]) -> String {
133 use std::fmt::Write;
134 let mut out = String::with_capacity(bytes.len() * 2);
135 for b in bytes {
136 let _ = write!(out, "{:02x}", b);
137 }
138 out
139}
140
141fn cache_path_for(repo: &Path) -> Option<PathBuf> {
144 #[cfg(test)]
145 if let Ok(dir) = std::env::var("GW_TEST_CACHE_DIR") {
146 return Some(
147 PathBuf::from(dir)
148 .join("gw")
149 .join(format!("pr-status-{}.json", repo_hash(repo))),
150 );
151 }
152
153 let base = dirs::cache_dir()?.join("gw");
154 Some(base.join(format!("pr-status-{}.json", repo_hash(repo))))
155}
156
157#[derive(Debug, Deserialize)]
158struct GhPr {
159 #[serde(rename = "headRefName")]
160 head_ref_name: String,
161 state: PrState,
162}
163
164fn fetch_from_gh(repo: &Path) -> Option<HashMap<String, PrState>> {
169 #[cfg(test)]
170 {
171 if std::env::var("GW_TEST_GH_FAIL").ok().as_deref() == Some("1") {
174 return None;
175 }
176 if let Ok(json) = std::env::var("GW_TEST_GH_JSON") {
177 let prs: Vec<GhPr> = serde_json::from_str(json.trim()).ok()?;
178 let mut map = HashMap::with_capacity(prs.len());
179 for pr in prs {
180 map.insert(pr.head_ref_name, pr.state);
181 }
182 return Some(map);
183 }
184 }
185
186 if !crate::git::has_command("gh") {
187 return None;
188 }
189 let limit = GH_FETCH_LIMIT.to_string();
190 let result = crate::git::run_command(
191 &[
192 "gh",
193 "pr",
194 "list",
195 "--state",
196 "all",
197 "--json",
198 "headRefName,state",
199 "--limit",
200 &limit,
201 ],
202 Some(repo),
203 false,
204 true,
205 )
206 .ok()?;
207 if result.returncode != 0 {
208 return None;
209 }
210
211 let prs: Vec<GhPr> = serde_json::from_str(result.stdout.trim()).ok()?;
212 let mut map = HashMap::with_capacity(prs.len());
213 for pr in prs {
214 map.insert(pr.head_ref_name, pr.state);
215 }
216 Some(map)
217}
218
219fn now_secs() -> Option<u64> {
223 SystemTime::now()
224 .duration_since(UNIX_EPOCH)
225 .ok()
226 .map(|d| d.as_secs())
227}
228
229fn load_from_disk(repo: &Path) -> Option<HashMap<String, PrState>> {
232 let path = cache_path_for(repo)?;
233 let data = std::fs::read_to_string(&path).ok()?;
234 let file: CacheFile = serde_json::from_str(&data).ok()?;
235 let now = now_secs()?;
237 if file.fetched_at > now {
239 return None;
240 }
241 let age = now.saturating_sub(file.fetched_at);
242 if age > CACHE_TTL_SECS {
243 return None;
244 }
245 Some(file.prs)
246}
247
248fn sweep_orphans(parent: &Path, cutoff: SystemTime) {
253 let Ok(entries) = std::fs::read_dir(parent) else {
254 return;
255 };
256 for entry in entries.flatten() {
257 let name = entry.file_name();
258 let name_str = name.to_string_lossy();
259 if !(name_str.starts_with("pr-status-") && name_str.contains(".tmp.")) {
260 continue;
261 }
262 let Ok(meta) = entry.metadata() else {
263 continue;
264 };
265 let Ok(modified) = meta.modified() else {
266 continue;
267 };
268 if modified < cutoff {
269 let _ = std::fs::remove_file(entry.path());
270 }
271 }
272}
273
274fn write_to_disk(repo: &Path, prs: &HashMap<String, PrState>) {
284 let Some(path) = cache_path_for(repo) else {
285 return;
286 };
287 if let Some(parent) = path.parent() {
288 let _ = std::fs::create_dir_all(parent);
289 }
290 let now_instant = SystemTime::now();
296 let dur = match now_instant.duration_since(UNIX_EPOCH) {
297 Ok(d) => d,
298 Err(_) => return, };
300 let now = dur.as_secs();
301 let nanos = dur.subsec_nanos();
302
303 let file = CacheFile {
304 fetched_at: now,
305 repo: repo.to_string_lossy().into_owned(),
306 prs: prs.clone(),
307 };
308 let Ok(json) = serde_json::to_string(&file) else {
309 return;
310 };
311
312 #[cfg(not(test))]
318 let do_sweep = SWEEP_DONE.set(()).is_ok();
319 #[cfg(test)]
320 let do_sweep = true;
321 if do_sweep {
322 if let Some(parent) = path.parent() {
323 let cutoff = SystemTime::now()
326 .checked_sub(std::time::Duration::from_secs(60))
327 .unwrap_or_else(SystemTime::now);
328 sweep_orphans(parent, cutoff);
329 }
330 }
331
332 let counter = TMP_COUNTER.fetch_add(1, Ordering::Relaxed);
342 let tmp = path.with_file_name(format!(
343 "{}.tmp.{}.{}.{}",
344 path.file_stem().unwrap_or_default().to_string_lossy(),
345 std::process::id(),
346 nanos,
347 counter,
348 ));
349 if std::fs::write(&tmp, &json).is_err() {
353 let _ = std::fs::remove_file(&tmp); return;
355 }
356 if std::fs::rename(&tmp, &path).is_err() {
357 let _ = std::fs::remove_file(&path);
362 if std::fs::rename(&tmp, &path).is_err() {
363 let _ = std::fs::remove_file(&tmp); }
365 }
366}
367
368#[cfg(test)]
369mod tests {
370 use super::*;
371 use std::path::PathBuf;
372 use std::sync::{Mutex, MutexGuard};
373
374 static ENV_LOCK: Mutex<()> = Mutex::new(());
378
379 fn env_lock() -> MutexGuard<'static, ()> {
382 ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner())
383 }
384
385 #[test]
389 fn now_secs_returns_some_on_normal_system() {
390 assert!(now_secs().is_some());
391 }
392
393 #[test]
394 fn repo_hash_is_stable_and_short() {
395 let p = PathBuf::from("/tmp/some-repo-that-does-not-exist-xyz");
396 let h1 = repo_hash(&p);
397 let h2 = repo_hash(&p);
398 assert_eq!(h1, h2);
399 assert_eq!(h1.len(), 16);
400 }
401
402 #[test]
403 fn repo_hash_differs_per_path() {
404 let a = repo_hash(&PathBuf::from("/tmp/repo-a-xyz"));
405 let b = repo_hash(&PathBuf::from("/tmp/repo-b-xyz"));
406 assert_ne!(a, b);
407 }
408
409 #[test]
410 fn cache_path_contains_repo_hash() {
411 let p = PathBuf::from("/tmp/repo-xyz");
412 let cp = cache_path_for(&p).expect("cache dir available");
413 let s = cp.to_string_lossy();
414 assert!(s.contains("gw"));
415 assert!(s.contains("pr-status-"));
416 assert!(s.ends_with(".json"));
417 }
418
419 #[test]
420 fn fetch_parses_gh_json_from_env() {
421 let _g = env_lock();
422 let _env = EnvGuard::capture(&["GW_TEST_GH_FAIL", "GW_TEST_GH_JSON"]);
423 std::env::set_var(
424 "GW_TEST_GH_JSON",
425 r#"[{"headRefName":"feat/foo","state":"OPEN"},{"headRefName":"fix/bar","state":"MERGED"}]"#,
426 );
427 let prs = fetch_from_gh(std::path::Path::new(".")).expect("parsed");
428 assert_eq!(prs.get("feat/foo"), Some(&PrState::Open));
429 assert_eq!(prs.get("fix/bar"), Some(&PrState::Merged));
430 }
431
432 #[test]
433 fn fetch_returns_none_on_forced_failure() {
434 let _g = env_lock();
435 let _env = EnvGuard::capture(&["GW_TEST_GH_FAIL", "GW_TEST_GH_JSON"]);
436 std::env::set_var("GW_TEST_GH_FAIL", "1");
437 let result = fetch_from_gh(std::path::Path::new("."));
438 assert!(result.is_none());
439 }
440
441 use tempfile::tempdir;
442
443 struct EnvGuard {
447 saved: Vec<(&'static str, Option<std::ffi::OsString>)>,
448 }
449
450 impl EnvGuard {
451 fn capture(keys: &[&'static str]) -> Self {
452 let saved = keys.iter().map(|k| (*k, std::env::var_os(k))).collect();
453 Self { saved }
454 }
455 }
456
457 impl Drop for EnvGuard {
458 fn drop(&mut self) {
459 for (k, v) in self.saved.drain(..) {
460 match v {
461 Some(val) => std::env::set_var(k, val),
462 None => std::env::remove_var(k),
463 }
464 }
465 }
466 }
467
468 fn with_cache_dir<F: FnOnce()>(dir: &std::path::Path, f: F) {
472 let _g = EnvGuard::capture(&["GW_TEST_CACHE_DIR"]);
473 std::env::set_var("GW_TEST_CACHE_DIR", dir);
474 f();
475 }
476
477 #[test]
478 fn load_from_disk_returns_fresh_entry() {
479 let _g = env_lock();
480 let dir = tempdir().unwrap();
481 with_cache_dir(dir.path(), || {
482 let repo = std::path::Path::new("/tmp/repo-xyz");
483 let path = cache_path_for(repo).unwrap();
484 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
485 let now = SystemTime::now()
486 .duration_since(UNIX_EPOCH)
487 .unwrap()
488 .as_secs();
489 let file = CacheFile {
490 fetched_at: now,
491 repo: repo.to_string_lossy().into_owned(),
492 prs: [("feat/a".to_string(), PrState::Open)]
493 .into_iter()
494 .collect(),
495 };
496 std::fs::write(&path, serde_json::to_string(&file).unwrap()).unwrap();
497
498 let loaded = load_from_disk(repo).expect("fresh cache");
499 assert_eq!(loaded.get("feat/a"), Some(&PrState::Open));
500 });
501 }
502
503 #[test]
504 fn load_from_disk_rejects_expired_entry() {
505 let _g = env_lock();
506 let dir = tempdir().unwrap();
507 with_cache_dir(dir.path(), || {
508 let repo = std::path::Path::new("/tmp/repo-expired-xyz");
509 let path = cache_path_for(repo).unwrap();
510 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
511 let file = CacheFile {
512 fetched_at: 0, repo: repo.to_string_lossy().into_owned(),
514 prs: HashMap::new(),
515 };
516 std::fs::write(&path, serde_json::to_string(&file).unwrap()).unwrap();
517
518 assert!(load_from_disk(repo).is_none());
519 });
520 }
521
522 #[test]
523 fn load_from_disk_rejects_future_entry() {
524 let _g = env_lock();
525 let dir = tempdir().unwrap();
526 with_cache_dir(dir.path(), || {
527 let repo = std::path::Path::new("/tmp/repo-future-xyz");
528 let path = cache_path_for(repo).unwrap();
529 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
530 let far_future = now_secs().unwrap() + 9999;
531 let file = CacheFile {
532 fetched_at: far_future,
533 repo: repo.to_string_lossy().into_owned(),
534 prs: HashMap::new(),
535 };
536 std::fs::write(&path, serde_json::to_string(&file).unwrap()).unwrap();
537
538 assert!(load_from_disk(repo).is_none());
539 });
540 }
541
542 #[test]
543 fn load_from_disk_rejects_corrupt_file() {
544 let _g = env_lock();
545 let dir = tempdir().unwrap();
546 with_cache_dir(dir.path(), || {
547 let repo = std::path::Path::new("/tmp/repo-corrupt-xyz");
548 let path = cache_path_for(repo).unwrap();
549 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
550 std::fs::write(&path, "not json").unwrap();
551
552 assert!(load_from_disk(repo).is_none());
553 });
554 }
555
556 #[test]
557 fn load_or_fetch_uses_disk_when_fresh() {
558 let _g = env_lock();
559 let _env = EnvGuard::capture(&["GW_TEST_CACHE_DIR", "GW_TEST_GH_FAIL", "GW_TEST_GH_JSON"]);
560 let dir = tempdir().unwrap();
561 with_cache_dir(dir.path(), || {
562 let repo = std::path::Path::new("/tmp/repo-disk-hit-xyz");
563 let path = cache_path_for(repo).unwrap();
564 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
565 let file = CacheFile {
566 fetched_at: now_secs().unwrap(),
567 repo: repo.to_string_lossy().into_owned(),
568 prs: [("feat/cached".to_string(), PrState::Merged)]
569 .into_iter()
570 .collect(),
571 };
572 std::fs::write(&path, serde_json::to_string(&file).unwrap()).unwrap();
573
574 std::env::set_var("GW_TEST_GH_FAIL", "1");
578 let cache = PrCache::load_or_fetch(repo, false);
579 assert_eq!(cache.state("feat/cached"), Some(&PrState::Merged));
580 });
581 }
582
583 #[test]
584 fn load_or_fetch_bypasses_disk_when_no_cache_true() {
585 let _g = env_lock();
586 let _env = EnvGuard::capture(&["GW_TEST_CACHE_DIR", "GW_TEST_GH_FAIL", "GW_TEST_GH_JSON"]);
587 let dir = tempdir().unwrap();
588 with_cache_dir(dir.path(), || {
589 let repo = std::path::Path::new("/tmp/repo-bypass-xyz");
590 let path = cache_path_for(repo).unwrap();
591 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
592 let file = CacheFile {
593 fetched_at: now_secs().unwrap(),
594 repo: repo.to_string_lossy().into_owned(),
595 prs: [("feat/old".to_string(), PrState::Open)]
596 .into_iter()
597 .collect(),
598 };
599 std::fs::write(&path, serde_json::to_string(&file).unwrap()).unwrap();
600
601 std::env::set_var(
602 "GW_TEST_GH_JSON",
603 r#"[{"headRefName":"feat/new","state":"OPEN"}]"#,
604 );
605 let cache = PrCache::load_or_fetch(repo, true);
606 assert_eq!(cache.state("feat/new"), Some(&PrState::Open));
607 assert_eq!(cache.state("feat/old"), None);
608 });
609 }
610
611 #[test]
612 fn load_or_fetch_empty_when_gh_fails_and_no_cache_file() {
613 let _g = env_lock();
614 let _env = EnvGuard::capture(&["GW_TEST_CACHE_DIR", "GW_TEST_GH_FAIL", "GW_TEST_GH_JSON"]);
615 let dir = tempdir().unwrap();
616 with_cache_dir(dir.path(), || {
617 let repo = std::path::Path::new("/tmp/repo-empty-xyz");
618 std::env::set_var("GW_TEST_GH_FAIL", "1");
619 let cache = PrCache::load_or_fetch(repo, false);
620 assert!(cache.state("anything").is_none());
621 });
622 }
623
624 #[test]
625 fn write_to_disk_cleans_up_tmp_file() {
626 let _g = env_lock();
627 let dir = tempdir().unwrap();
628 with_cache_dir(dir.path(), || {
629 let repo = std::path::Path::new("/tmp/repo-atomic-xyz");
630 let mut prs = HashMap::new();
631 prs.insert("feat/x".to_string(), PrState::Open);
632 write_to_disk(repo, &prs);
633
634 let final_path = cache_path_for(repo).unwrap();
635 assert!(final_path.exists(), "final cache file exists");
636
637 let parent = final_path.parent().unwrap();
639 let entries: Vec<_> = std::fs::read_dir(parent).unwrap().flatten().collect();
640 for entry in &entries {
641 let name = entry.file_name();
642 let name_str = name.to_string_lossy();
643 assert!(
644 !name_str.contains(".tmp."),
645 "no tmp file should remain: {}",
646 name_str
647 );
648 }
649 });
650 }
651
652 #[test]
653 fn from_disk_and_fetch_and_persist_split() {
654 let _g = env_lock();
655 let _env = EnvGuard::capture(&["GW_TEST_CACHE_DIR", "GW_TEST_GH_FAIL", "GW_TEST_GH_JSON"]);
656 let dir = tempdir().unwrap();
657 with_cache_dir(dir.path(), || {
658 let repo = std::path::Path::new("/tmp/repo-split-xyz");
659 assert!(PrCache::from_disk(repo).is_none());
661
662 std::env::set_var("GW_TEST_GH_FAIL", "1");
664 let empty = PrCache::fetch_and_persist(repo);
665 assert!(empty.state("anything").is_none());
666 std::env::remove_var("GW_TEST_GH_FAIL");
668
669 std::env::set_var(
671 "GW_TEST_GH_JSON",
672 r#"[{"headRefName":"main","state":"OPEN"}]"#,
673 );
674 let _ = PrCache::fetch_and_persist(repo);
675 let loaded = PrCache::from_disk(repo).expect("written to disk");
677 assert_eq!(loaded.state("main"), Some(&PrState::Open));
678 });
679 }
680
681 #[cfg(unix)]
683 #[test]
684 fn write_to_disk_sweeps_old_orphan_tmp_files() {
685 let _g = env_lock();
686 let _env = EnvGuard::capture(&["GW_TEST_CACHE_DIR"]);
687 let dir = tempdir().unwrap();
688 with_cache_dir(dir.path(), || {
689 let repo = std::path::Path::new("/tmp/repo-sweep-xyz");
690 let final_path = cache_path_for(repo).unwrap();
691 let parent = final_path.parent().unwrap();
692 std::fs::create_dir_all(parent).unwrap();
693
694 let orphan = parent.join("pr-status-orphan.tmp.99999.123456789.0");
697 std::fs::write(&orphan, "stale").unwrap();
698 {
700 use std::ffi::CString;
701 let c_path = CString::new(orphan.to_string_lossy().as_bytes()).unwrap();
702 let times = [libc::timeval {
703 tv_sec: 0,
704 tv_usec: 0,
705 }; 2];
706 unsafe { libc::utimes(c_path.as_ptr(), times.as_ptr()) };
708 }
709
710 let fresh_tmp = parent.join("pr-status-fresh.tmp.123.456.0");
712 std::fs::write(&fresh_tmp, "fresh").unwrap();
713
714 let mut prs = HashMap::new();
716 prs.insert("feat/sweep".to_string(), PrState::Open);
717 write_to_disk(repo, &prs);
718
719 assert!(
720 !orphan.exists(),
721 "old orphan tmp file should have been swept"
722 );
723 assert!(fresh_tmp.exists(), "fresh tmp file should not be swept");
724 assert!(final_path.exists(), "final cache file should exist");
725 });
726 }
727
728 #[test]
731 fn pr_state_variants_are_handled() {
732 fn _must_handle(s: &PrState) {
734 match s {
735 PrState::Open => {}
736 PrState::Merged => {}
737 PrState::Closed => {}
738 PrState::Other => {}
739 }
740 }
741 }
742}