1use std::collections::HashSet;
39use std::fmt::Write as _;
40use std::path::{Path, PathBuf};
41use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
42use std::sync::Mutex;
43use std::time::{Duration, SystemTime, UNIX_EPOCH};
44
45use anyhow::{Context, Result};
46use serde::{Deserialize, Serialize};
47use sha2::{Digest, Sha256};
48use tracing::{debug, warn};
49
50use crate::provider::{IssueDoc, ItemRef};
51use crate::utils::env::{non_empty_var, EnvSource};
52
53pub const GITHUB_CACHE_TTL_ENV: &str = "OMNI_DEV_GITHUB_CACHE_TTL_SECS";
56
57pub const DEFAULT_TTL: Duration = Duration::from_secs(300);
63
64const SCHEMA: u32 = 1;
69
70const CACHE_SUBDIR: [&str; 2] = ["omni-dev", "github-issues"];
72
73const MAX_SWEEP_DEPTH: usize = 4;
76
77const TEMP_EXT: &str = "partial";
79
80const TEMP_GRACE: Duration = Duration::from_secs(3600);
83
84#[derive(Serialize, Deserialize)]
86struct Entry {
87 schema: u32,
88 fetched_at: u64,
90 doc: IssueDoc,
91}
92
93#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
99pub struct CacheUsage {
100 pub items_reused: usize,
102 pub oldest_age_secs: u64,
104}
105
106#[derive(Debug)]
112pub struct IssueCache {
113 root: Option<PathBuf>,
116 dir: Option<PathBuf>,
119 ttl: Duration,
120 refresh: bool,
122 hits: AtomicUsize,
123 oldest_hit_secs: AtomicU64,
124 stored: Mutex<HashSet<(String, u64)>>,
128 verified: Mutex<HashSet<(String, u64)>>,
132}
133
134impl IssueCache {
135 #[must_use]
139 pub fn new(dir: PathBuf, ttl: Duration, refresh: bool) -> Self {
140 let active = (!ttl.is_zero()).then(|| dir.clone());
141 Self::build(Some(dir), active, ttl, refresh)
142 }
143
144 fn build(root: Option<PathBuf>, dir: Option<PathBuf>, ttl: Duration, refresh: bool) -> Self {
145 Self {
146 root,
147 dir,
148 ttl,
149 refresh,
150 hits: AtomicUsize::new(0),
151 oldest_hit_secs: AtomicU64::new(0),
152 stored: Mutex::new(HashSet::new()),
153 verified: Mutex::new(HashSet::new()),
154 }
155 }
156
157 #[must_use]
159 pub fn disabled() -> Self {
160 Self::build(None, None, Duration::ZERO, false)
161 }
162
163 #[must_use]
173 pub fn from_env_with(
174 env: &impl EnvSource,
175 base: Option<PathBuf>,
176 account: Option<&str>,
177 refresh: bool,
178 ) -> Self {
179 let Some(base) = base else {
180 debug!("No user cache directory; the GitHub fetch cache is disabled");
181 return Self::disabled();
182 };
183 let root = CACHE_SUBDIR.iter().fold(base, |dir, part| dir.join(part));
184 let ttl = ttl_from_env(env);
185 let dir = account
186 .filter(|account| is_safe_segment(account))
187 .filter(|_| !ttl.is_zero())
188 .map(|account| root.join(account));
189 if dir.is_none() && !ttl.is_zero() {
190 debug!("No usable gh login; the GitHub fetch cache is disabled");
191 }
192 Self::build(Some(root), dir, ttl, refresh)
193 }
194
195 fn read_fresh(&self, item_ref: &ItemRef) -> Option<(IssueDoc, u64)> {
202 if self.refresh {
203 return None;
204 }
205 let path = self.entry_path(&item_ref.project, item_ref.number)?;
206 let bytes = match std::fs::read(&path) {
207 Ok(bytes) => bytes,
208 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return None,
209 Err(e) => {
210 let shown = path.display();
211 debug!("Ignoring GitHub cache entry {shown}: {e}");
212 return None;
213 }
214 };
215 let entry = match serde_json::from_slice::<Entry>(&bytes) {
216 Ok(entry) if entry.schema == SCHEMA => entry,
217 Ok(_) => return None,
218 Err(e) => {
219 let shown = path.display();
220 debug!("Ignoring unreadable GitHub cache entry {shown}: {e}");
221 return None;
222 }
223 };
224 let age = self.fresh_age(entry.fetched_at)?;
225 if !entry.doc.project.eq_ignore_ascii_case(&item_ref.project)
228 || entry.doc.number != item_ref.number
229 {
230 return None;
231 }
232 Some((entry.doc, age))
233 }
234
235 pub(super) fn lookup(
238 &self,
239 item_ref: &ItemRef,
240 accept: impl Fn(&IssueDoc) -> bool,
241 ) -> Option<IssueDoc> {
242 let (mut doc, age) = self.read_fresh(item_ref).filter(|(doc, _)| accept(doc))?;
245 if !self.stored_this_run(&item_ref.project, item_ref.number) {
246 self.hits.fetch_add(1, Ordering::Relaxed);
247 self.oldest_hit_secs.fetch_max(age, Ordering::Relaxed);
248 }
249 doc.project.clone_from(&item_ref.project);
252 Some(doc)
253 }
254
255 pub(super) fn unverified_refs<'a>(
259 &self,
260 refs: &'a [ItemRef],
261 accept: impl Fn(&IssueDoc) -> bool,
262 ) -> Vec<&'a ItemRef> {
263 refs.iter()
264 .filter(|item_ref| !self.state_verified(&item_ref.project, item_ref.number))
265 .filter(|item_ref| {
266 self.read_fresh(item_ref)
267 .is_some_and(|(doc, _)| accept(&doc))
268 })
269 .collect()
270 }
271
272 pub(super) fn mark_state_verified(&self, refs: &[ItemRef]) {
275 if let Ok(mut verified) = self.verified.lock() {
276 verified.extend(
277 refs.iter()
278 .map(|r| (r.project.to_ascii_lowercase(), r.number)),
279 );
280 }
281 }
282
283 pub(super) fn state_verified(&self, project: &str, number: u64) -> bool {
286 self.stored_this_run(project, number)
287 || self
288 .verified
289 .lock()
290 .is_ok_and(|v| v.contains(&(project.to_ascii_lowercase(), number)))
291 }
292
293 pub fn prune_expired(&self) {
301 let Some(root) = &self.root else {
302 return;
303 };
304 for (path, is_temp) in sweep_files(root) {
305 let age = file_age(&path);
309 let expired = if is_temp {
310 age.is_some_and(|age| age >= TEMP_GRACE)
311 } else {
312 age.is_none_or(|age| age.as_secs() >= self.ttl.as_secs())
313 };
314 if expired {
315 remove_entry(&path);
316 }
317 }
318 remove_empty_dirs(root);
319 }
320
321 fn fresh_age(&self, fetched_at: u64) -> Option<u64> {
325 now_secs()
326 .checked_sub(fetched_at)
327 .filter(|age| *age < self.ttl.as_secs())
328 }
329
330 fn stored_this_run(&self, project: &str, number: u64) -> bool {
331 self.stored
332 .lock()
333 .is_ok_and(|stored| stored.contains(&(project.to_ascii_lowercase(), number)))
334 }
335
336 pub(super) fn store(&self, doc: &IssueDoc) {
339 let Some(path) = self.entry_path(&doc.project, doc.number) else {
340 return;
341 };
342 match write_entry(&path, doc) {
343 Ok(()) => {
344 if let Ok(mut stored) = self.stored.lock() {
345 stored.insert((doc.project.to_ascii_lowercase(), doc.number));
346 }
347 }
348 Err(e) => {
349 let (project, number) = (&doc.project, doc.number);
350 debug!("Failed to cache {project}#{number}: {e:#}");
351 }
352 }
353 }
354
355 #[must_use]
359 pub fn usage(&self) -> Option<CacheUsage> {
360 let items_reused = self.hits.load(Ordering::Relaxed);
361 (items_reused > 0).then(|| CacheUsage {
362 items_reused,
363 oldest_age_secs: self.oldest_hit_secs.load(Ordering::Relaxed),
364 })
365 }
366
367 #[must_use]
370 pub fn reuse_note(&self) -> Option<String> {
371 let hits = self.hits.load(Ordering::Relaxed);
372 if hits == 0 {
373 return None;
374 }
375 let age = format_age(self.oldest_hit_secs.load(Ordering::Relaxed));
376 let noun = if hits == 1 { "item" } else { "items" };
377 Some(format!(
378 "note: reused {hits} cached GitHub {noun}, up to {age} old; pass --refresh to re-fetch"
379 ))
380 }
381
382 fn entry_path(&self, project: &str, number: u64) -> Option<PathBuf> {
386 let dir = self.dir.as_ref()?;
387 let project = project.to_ascii_lowercase();
388 let (owner, repo) = project.split_once('/')?;
389 if !is_safe_segment(owner) || !is_safe_segment(repo) {
390 return None;
391 }
392 Some(dir.join(owner).join(repo).join(format!("{number}.json")))
393 }
394}
395
396fn ttl_from_env(env: &impl EnvSource) -> Duration {
399 let Some(raw) = non_empty_var(env, GITHUB_CACHE_TTL_ENV) else {
400 return DEFAULT_TTL;
401 };
402 match raw.trim().parse::<u64>() {
403 Ok(secs) => Duration::from_secs(secs),
404 Err(e) => {
405 let default = DEFAULT_TTL.as_secs();
406 warn!("Ignoring {GITHUB_CACHE_TTL_ENV}={raw:?} ({e}); using {default}s");
407 DEFAULT_TTL
408 }
409 }
410}
411
412fn is_safe_segment(segment: &str) -> bool {
416 !segment.is_empty()
417 && segment != "."
418 && segment != ".."
419 && segment
420 .chars()
421 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.'))
422}
423
424const WRITE_ATTEMPTS: usize = 3;
428
429fn write_entry(path: &Path, doc: &IssueDoc) -> Result<()> {
439 retry_on_not_found(|| write_entry_once(path, doc))
440}
441
442fn retry_on_not_found(mut write: impl FnMut() -> Result<()>) -> Result<()> {
444 let mut attempt = 1;
445 loop {
446 match write() {
447 Err(e) if attempt < WRITE_ATTEMPTS && is_not_found(&e) => attempt += 1,
448 result => return result,
449 }
450 }
451}
452
453fn is_not_found(err: &anyhow::Error) -> bool {
454 err.chain().any(|cause| {
455 cause
456 .downcast_ref::<std::io::Error>()
457 .is_some_and(|io_err| io_err.kind() == std::io::ErrorKind::NotFound)
458 })
459}
460
461fn write_entry_once(path: &Path, doc: &IssueDoc) -> Result<()> {
462 crate::daemon::paths::ensure_parent_dir_0700(path)?;
463 let dir = path.parent().context("cache entry path has no parent")?;
464 let entry = Entry {
465 schema: SCHEMA,
466 fetched_at: now_secs(),
467 doc: doc.clone(),
468 };
469 let mut tmp = tempfile::Builder::new()
473 .prefix(".entry-")
474 .suffix(&format!(".{TEMP_EXT}"))
475 .tempfile_in(dir)
476 .with_context(|| format!("Failed to create a temp file in {}", dir.display()))?;
477 serde_json::to_writer(&mut tmp, &entry).context("Failed to serialise cache entry")?;
478 tmp.persist(path)
479 .with_context(|| format!("Failed to replace {}", path.display()))?;
480 Ok(())
481}
482
483fn remove_entry(path: &Path) {
486 match std::fs::remove_file(path) {
487 Err(e) if e.kind() != std::io::ErrorKind::NotFound => {
488 let shown = path.display();
489 debug!("Failed to remove GitHub cache file {shown}: {e}");
490 }
491 _ => {}
492 }
493}
494
495fn remove_empty_dirs(root: &Path) {
506 fn walk(dir: &Path, depth: usize) {
507 let Ok(children) = std::fs::read_dir(dir) else {
508 return;
509 };
510 for child in children.flatten() {
511 if depth < MAX_SWEEP_DEPTH && child.file_type().is_ok_and(|kind| kind.is_dir()) {
512 let path = child.path();
513 walk(&path, depth + 1);
514 log_unexpected_remove_dir_failure(&path, std::fs::remove_dir(&path));
515 }
516 }
517 }
518 walk(root, 1);
519}
520
521fn log_unexpected_remove_dir_failure(path: &Path, result: std::io::Result<()>) {
525 match result {
526 Err(e) if !is_not_empty(&e) && e.kind() != std::io::ErrorKind::NotFound => {
527 let shown = path.display();
528 debug!("Failed to remove GitHub cache directory {shown}: {e}");
529 }
530 _ => {}
531 }
532}
533
534fn is_not_empty(err: &std::io::Error) -> bool {
537 matches!(
538 err.kind(),
539 std::io::ErrorKind::DirectoryNotEmpty | std::io::ErrorKind::AlreadyExists
540 )
541}
542
543fn file_age(path: &Path) -> Option<Duration> {
546 let modified = std::fs::metadata(path).ok()?.modified().ok()?;
547 SystemTime::now().duration_since(modified).ok()
548}
549
550fn sweep_files(root: &Path) -> Vec<(PathBuf, bool)> {
555 fn walk(dir: &Path, depth: usize, out: &mut Vec<(PathBuf, bool)>) {
556 let Ok(children) = std::fs::read_dir(dir) else {
557 return;
558 };
559 for child in children.flatten() {
560 let path = child.path();
561 let Ok(kind) = child.file_type() else {
562 continue;
563 };
564 if kind.is_dir() {
565 if depth < MAX_SWEEP_DEPTH {
566 walk(&path, depth + 1, out);
567 }
568 } else if kind.is_file() {
569 match path.extension().and_then(|ext| ext.to_str()) {
570 Some("json") => out.push((path, false)),
571 Some(TEMP_EXT) => out.push((path, true)),
572 _ => {}
573 }
574 }
575 }
576 }
577 let mut out = Vec::new();
578 walk(root, 1, &mut out);
579 out
580}
581
582#[must_use]
587pub fn account_scope(token: &str) -> String {
588 let digest = Sha256::digest(token.as_bytes());
589 digest[..8].iter().fold(String::new(), |mut hex, byte| {
590 let _ = write!(hex, "{byte:02x}");
591 hex
592 })
593}
594
595fn now_secs() -> u64 {
596 SystemTime::now()
597 .duration_since(UNIX_EPOCH)
598 .map_or(0, |d| d.as_secs())
599}
600
601fn format_age(secs: u64) -> String {
603 if secs < 120 {
604 format!("{secs}s")
605 } else {
606 format!("{}m", secs / 60)
607 }
608}
609
610#[cfg(test)]
611#[allow(clippy::unwrap_used, clippy::expect_used)]
612mod tests {
613 use super::*;
614 use crate::provider::{GitProvider, ItemKind, ItemState};
615 use crate::test_support::env::MapEnv;
616
617 fn doc(project: &str, number: u64, kind: ItemKind) -> IssueDoc {
618 IssueDoc {
619 provider: GitProvider::GitHub,
620 project: project.to_string(),
621 number,
622 kind,
623 title: format!("title {number}"),
624 state: ItemState::Open,
625 body: "body".to_string(),
626 comments: Vec::new(),
627 closed_by: Vec::new(),
628 url: format!("https://github.com/{project}/issues/{number}"),
629 }
630 }
631
632 fn item_ref(project: &str, number: u64) -> ItemRef {
633 ItemRef {
634 provider: GitProvider::GitHub,
635 project: project.to_string(),
636 kind: ItemKind::Issue,
637 number,
638 }
639 }
640
641 fn cache(dir: &Path) -> IssueCache {
642 IssueCache::new(dir.to_path_buf(), DEFAULT_TTL, false)
643 }
644
645 fn backdate(dir: &Path, project: &str, number: u64, secs_ago: i64) {
647 let path = cache(dir).entry_path(project, number).unwrap();
648 let mut value: serde_json::Value =
649 serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap();
650 let at = i64::try_from(now_secs()).unwrap() - secs_ago;
651 value["fetched_at"] = serde_json::json!(at);
652 std::fs::write(&path, value.to_string()).unwrap();
653 }
654
655 #[test]
656 fn a_stored_doc_is_served_back_and_counted() {
657 let dir = tempfile::tempdir().unwrap();
658 let stored = doc("o/r", 7, ItemKind::Issue);
659 cache(dir.path()).store(&stored);
660 let cache = cache(dir.path());
661 assert_eq!(cache.lookup(&item_ref("o/r", 7), |_| true), Some(stored));
662 assert!(cache.lookup(&item_ref("o/r", 8), |_| true).is_none());
663 let note = cache.reuse_note().unwrap();
664 assert!(
665 note.starts_with("note: reused 1 cached GitHub item,"),
666 "{note}"
667 );
668 assert!(note.contains("--refresh"), "{note}");
669 }
670
671 #[test]
672 fn nothing_reused_means_no_note() {
673 let dir = tempfile::tempdir().unwrap();
674 assert!(cache(dir.path()).reuse_note().is_none());
675 }
676
677 #[test]
678 fn an_item_stored_this_run_is_served_but_not_reported_as_reused() {
679 let dir = tempfile::tempdir().unwrap();
680 let cache = cache(dir.path());
681 cache.store(&doc("o/r", 7, ItemKind::Issue));
682 assert!(cache.lookup(&item_ref("o/r", 7), |_| true).is_some());
683 assert!(cache.reuse_note().is_none());
684 }
685
686 #[test]
687 fn the_note_reports_the_count_and_oldest_age() {
688 let dir = tempfile::tempdir().unwrap();
689 cache(dir.path()).store(&doc("o/r", 1, ItemKind::Issue));
690 cache(dir.path()).store(&doc("o/r", 2, ItemKind::Issue));
691 backdate(dir.path(), "o/r", 2, 150);
692 let cache = cache(dir.path());
693 assert!(cache.lookup(&item_ref("o/r", 1), |_| true).is_some());
694 assert!(cache.lookup(&item_ref("o/r", 2), |_| true).is_some());
695 assert_eq!(
696 cache.reuse_note().unwrap(),
697 "note: reused 2 cached GitHub items, up to 2m old; pass --refresh to re-fetch"
698 );
699 }
700
701 #[test]
702 fn an_expired_entry_is_a_miss_and_is_left_for_the_sweep() {
703 let dir = tempfile::tempdir().unwrap();
704 let cache = cache(dir.path());
705 cache.store(&doc("o/r", 7, ItemKind::Issue));
706 backdate(dir.path(), "o/r", 7, 300);
707 assert!(cache.lookup(&item_ref("o/r", 7), |_| true).is_none());
708 assert!(cache.reuse_note().is_none());
709 assert!(cache.entry_path("o/r", 7).unwrap().exists());
711 }
712
713 fn set_age(path: &Path, secs_ago: i64) {
716 let now = SystemTime::now();
717 let by = Duration::from_secs(secs_ago.unsigned_abs());
718 let at = if secs_ago >= 0 { now - by } else { now + by };
719 std::fs::File::options()
720 .write(true)
721 .open(path)
722 .unwrap()
723 .set_modified(at)
724 .unwrap();
725 }
726
727 #[test]
728 fn prune_removes_entries_older_than_the_ttl_only() {
729 let dir = tempfile::tempdir().unwrap();
730 let cache = cache(dir.path());
731 for number in 1..=3 {
732 cache.store(&doc("o/r", number, ItemKind::Issue));
733 }
734 let path = |n| cache.entry_path("o/r", n).unwrap();
735 set_age(&path(2), 3600);
736 set_age(&path(3), -3600);
737 let bystander = dir.path().join("o/r/notes.txt");
738 std::fs::write(&bystander, "not ours").unwrap();
739 set_age(&bystander, 86_400);
740
741 cache.prune_expired();
742 assert!(path(1).exists());
743 assert!(!path(2).exists(), "expired");
744 assert!(!path(3).exists(), "an mtime in the future is not trusted");
745 assert!(bystander.exists(), "only the cache's own files are swept");
746 }
747
748 #[test]
749 fn prune_removes_a_crashed_writes_temp_file_but_not_a_live_one() {
750 let dir = tempfile::tempdir().unwrap();
751 let cache = cache(dir.path());
752 cache.store(&doc("o/r", 1, ItemKind::Issue));
753 let repo_dir = dir.path().join("o/r");
754 let orphan = repo_dir.join(format!(".entry-abc.{TEMP_EXT}"));
755 let live = repo_dir.join(format!(".entry-def.{TEMP_EXT}"));
756 std::fs::write(&orphan, "issue text").unwrap();
757 std::fs::write(&live, "issue text").unwrap();
758 set_age(&orphan, i64::try_from(TEMP_GRACE.as_secs()).unwrap() + 60);
759
760 cache.prune_expired();
761 assert!(!orphan.exists(), "an orphaned temp file holds issue text");
762 assert!(live.exists(), "an in-flight write is never pruned");
763 }
764
765 #[test]
766 fn a_write_leaves_no_temp_file_behind() {
767 let dir = tempfile::tempdir().unwrap();
768 cache(dir.path()).store(&doc("o/r", 1, ItemKind::Issue));
769 let names: Vec<_> = std::fs::read_dir(dir.path().join("o/r"))
770 .unwrap()
771 .map(|e| e.unwrap().file_name().into_string().unwrap())
772 .collect();
773 assert_eq!(names, ["1.json"]);
774 }
775
776 #[test]
777 fn prune_sweeps_every_accounts_entries_and_a_zero_ttl_sweeps_them_all() {
778 let base = tempfile::tempdir().unwrap();
779 let env = MapEnv::new();
780 let open = |env: &MapEnv, account: &str| {
781 IssueCache::from_env_with(env, Some(base.path().to_path_buf()), Some(account), false)
782 };
783 let (a, b) = (open(&env, "aaaa"), open(&env, "bbbb"));
784 a.store(&doc("o/r", 1, ItemKind::Issue));
785 b.store(&doc("o/r", 1, ItemKind::Issue));
786 let (path_a, path_b) = (
787 a.entry_path("o/r", 1).unwrap(),
788 b.entry_path("o/r", 1).unwrap(),
789 );
790 set_age(&path_a, 3600);
791
792 b.prune_expired();
793 assert!(!path_a.exists(), "another account's expired entry");
794 assert!(path_b.exists());
795
796 let off = open(&MapEnv::new().with(GITHUB_CACHE_TTL_ENV, "0"), "bbbb");
799 assert!(off.dir.is_none());
800 off.prune_expired();
801 assert!(!path_b.exists());
802 }
803
804 fn dirs_under(root: &Path) -> Vec<PathBuf> {
806 fn walk(dir: &Path, out: &mut Vec<PathBuf>) {
807 for child in std::fs::read_dir(dir).unwrap().flatten() {
808 if child.file_type().unwrap().is_dir() {
809 out.push(child.path());
810 walk(&child.path(), out);
811 }
812 }
813 }
814 let mut out = Vec::new();
815 walk(root, &mut out);
816 out
817 }
818
819 #[test]
820 fn prune_removes_the_directories_an_expired_tree_leaves_empty() {
821 let base = tempfile::tempdir().unwrap();
822 let env = MapEnv::new();
823 let open = |account: &str| {
824 IssueCache::from_env_with(&env, Some(base.path().to_path_buf()), Some(account), false)
825 };
826 let (a, b) = (open("aaaa"), open("bbbb"));
827 for cache in [&a, &b] {
828 cache.store(&doc("o/r", 1, ItemKind::Issue));
829 cache.store(&doc("o/other", 1, ItemKind::Issue));
830 }
831 let entries = [("o/r", &a), ("o/other", &a), ("o/r", &b), ("o/other", &b)];
832 for (project, cache) in entries {
833 set_age(&cache.entry_path(project, 1).unwrap(), 3600);
834 }
835
836 a.prune_expired();
837 let root = a.root.clone().unwrap();
838 assert_eq!(dirs_under(&root), Vec::<PathBuf>::new());
839 assert!(root.is_dir(), "the cache root itself is never removed");
840 }
841
842 #[test]
843 fn prune_keeps_the_directories_a_fresh_sibling_or_bystander_still_needs() {
844 let dir = tempfile::tempdir().unwrap();
845 let cache = cache(dir.path());
846 for (project, number) in [("o/r", 1), ("o/r", 2), ("o/gone", 1), ("o/kept", 1)] {
847 cache.store(&doc(project, number, ItemKind::Issue));
848 }
849 let path = |project, n| cache.entry_path(project, n).unwrap();
850 set_age(&path("o/r", 1), 3600);
851 set_age(&path("o/gone", 1), 3600);
852 set_age(&path("o/kept", 1), 3600);
853 let bystander = path("o/kept", 1).with_file_name("notes.txt");
854 std::fs::write(&bystander, "not ours").unwrap();
855
856 cache.prune_expired();
857 assert!(path("o/r", 2).exists(), "the fresh sibling is untouched");
858 assert!(
859 bystander.exists(),
860 "a file that is not ours pins its directory"
861 );
862 assert!(!dir.path().join("o/gone").exists(), "emptied");
863 assert!(dir.path().join("o/r").is_dir());
864 assert!(dir.path().join("o/kept").is_dir());
865 assert!(dir.path().join("o").is_dir(), "still has children");
866
867 let mut left = dirs_under(dir.path());
868 left.sort();
869 let expected = ["o", "o/kept", "o/r"].map(|d| dir.path().join(d));
870 assert_eq!(left, expected);
871 }
872
873 #[test]
874 fn only_a_refusal_for_a_reason_other_than_content_or_absence_is_worth_logging() {
875 use std::io::{Error, ErrorKind};
876 assert!(is_not_empty(&Error::from(ErrorKind::DirectoryNotEmpty)));
879 assert!(is_not_empty(&Error::from(ErrorKind::AlreadyExists)));
880 assert!(!is_not_empty(&Error::from(ErrorKind::PermissionDenied)));
881
882 let path = Path::new("/cache/o/r");
884 log_unexpected_remove_dir_failure(path, Ok(()));
885 log_unexpected_remove_dir_failure(path, Err(Error::from(ErrorKind::NotFound)));
886 log_unexpected_remove_dir_failure(path, Err(Error::from(ErrorKind::DirectoryNotEmpty)));
887 log_unexpected_remove_dir_failure(path, Err(Error::from(ErrorKind::PermissionDenied)));
888 }
889
890 #[test]
891 fn prune_removes_directories_that_were_already_empty() {
892 let dir = tempfile::tempdir().unwrap();
893 let cache = cache(dir.path());
894 cache.store(&doc("o/kept", 1, ItemKind::Issue));
895 std::fs::create_dir_all(dir.path().join("o/empty")).unwrap();
896 std::fs::create_dir_all(dir.path().join("x/y")).unwrap();
897
898 cache.prune_expired();
899 assert!(cache.entry_path("o/kept", 1).unwrap().exists());
900 assert!(!dir.path().join("o/empty").exists());
901 assert!(!dir.path().join("x").exists());
902 }
903
904 #[test]
905 fn prune_keeps_a_directory_holding_a_live_writes_temp_file() {
906 let dir = tempfile::tempdir().unwrap();
907 let cache = cache(dir.path());
908 cache.store(&doc("o/r", 1, ItemKind::Issue));
909 let live = dir
910 .path()
911 .join("o/r")
912 .join(format!(".entry-abc.{TEMP_EXT}"));
913 std::fs::write(&live, "issue text").unwrap();
914 set_age(&cache.entry_path("o/r", 1).unwrap(), 3600);
915
916 cache.prune_expired();
917 assert!(live.exists(), "the writer's directory survives its sweep");
918 }
919
920 #[test]
921 fn a_write_after_the_sweep_removed_its_directories_recreates_them() {
922 let dir = tempfile::tempdir().unwrap();
923 let cache = cache(dir.path());
924 let item = doc("o/r", 1, ItemKind::Issue);
925 cache.store(&item);
926 set_age(&cache.entry_path("o/r", 1).unwrap(), 3600);
927 cache.prune_expired();
928 assert!(!dir.path().join("o").exists());
929
930 cache.store(&item);
931 assert!(cache.entry_path("o/r", 1).unwrap().exists());
932 }
933
934 #[test]
935 fn the_error_a_vanished_directory_gives_a_write_is_recognised() {
936 let dir = tempfile::tempdir().unwrap();
937 let gone = dir.path().join("gone");
938 let err = tempfile::Builder::new()
939 .tempfile_in(&gone)
940 .with_context(|| format!("Failed to create a temp file in {}", gone.display()))
941 .unwrap_err();
942 assert!(is_not_found(&err), "{err:?}");
943 assert!(!is_not_found(&anyhow::anyhow!("no io error at all")));
944 }
945
946 #[test]
947 fn a_write_that_loses_its_directory_starts_over_a_bounded_number_of_times() {
948 let vanished = || -> Result<()> {
949 Err(std::io::Error::from(std::io::ErrorKind::NotFound))
950 .context("Failed to create a temp file")
951 };
952
953 let mut calls = 0;
954 retry_on_not_found(|| {
955 calls += 1;
956 if calls < WRITE_ATTEMPTS {
957 vanished()
958 } else {
959 Ok(())
960 }
961 })
962 .unwrap();
963 assert_eq!(calls, WRITE_ATTEMPTS, "the last attempt succeeds");
964
965 let mut calls = 0;
966 retry_on_not_found(|| {
967 calls += 1;
968 vanished()
969 })
970 .unwrap_err();
971 assert_eq!(calls, WRITE_ATTEMPTS, "then it gives up");
972
973 let mut calls = 0;
974 retry_on_not_found(|| {
975 calls += 1;
976 Err(std::io::Error::from(std::io::ErrorKind::PermissionDenied).into())
977 })
978 .unwrap_err();
979 assert_eq!(calls, 1, "any other failure is not retried");
980 }
981
982 #[test]
983 fn prune_is_a_no_op_when_disabled_or_empty() {
984 IssueCache::disabled().prune_expired();
985 let dir = tempfile::tempdir().unwrap();
986 cache(&dir.path().join("missing")).prune_expired();
987 }
988
989 #[test]
993 fn schema_tracks_the_issue_doc_shape() {
994 let mut issue = doc("o/r", 7, ItemKind::Issue);
995 issue.comments.push(crate::provider::Comment {
996 author: "a".to_string(),
997 body: "b".to_string(),
998 id: Some(1),
999 });
1000 let value = serde_json::to_value(&issue).unwrap();
1001 let keys = |v: &serde_json::Value| -> Vec<String> {
1002 let mut keys: Vec<String> = v.as_object().unwrap().keys().cloned().collect();
1003 keys.sort();
1004 keys
1005 };
1006 assert_eq!(
1007 (SCHEMA, keys(&value), keys(&value["comments"][0])),
1008 (
1009 1,
1010 [
1011 "body", "closed_by", "comments", "kind", "number", "project", "provider",
1012 "state", "title", "url"
1013 ]
1014 .map(String::from)
1015 .to_vec(),
1016 ["author", "body", "id"].map(String::from).to_vec(),
1017 ),
1018 "IssueDoc's shape changed: bump SCHEMA in github_issues/cache.rs, then update this test"
1019 );
1020 }
1021
1022 #[test]
1023 fn a_future_fetched_at_is_a_miss() {
1024 let dir = tempfile::tempdir().unwrap();
1025 let cache = cache(dir.path());
1026 cache.store(&doc("o/r", 7, ItemKind::Issue));
1027 backdate(dir.path(), "o/r", 7, -60);
1028 assert!(cache.lookup(&item_ref("o/r", 7), |_| true).is_none());
1029 }
1030
1031 #[test]
1032 fn a_corrupt_or_old_schema_entry_is_a_miss() {
1033 let dir = tempfile::tempdir().unwrap();
1034 let cache = cache(dir.path());
1035 cache.store(&doc("o/r", 7, ItemKind::Issue));
1036 let path = cache.entry_path("o/r", 7).unwrap();
1037
1038 let mut value: serde_json::Value =
1039 serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap();
1040 value["schema"] = serde_json::json!(SCHEMA + 1);
1041 std::fs::write(&path, value.to_string()).unwrap();
1042 assert!(cache.lookup(&item_ref("o/r", 7), |_| true).is_none());
1043
1044 std::fs::write(&path, "{not json").unwrap();
1045 assert!(cache.lookup(&item_ref("o/r", 7), |_| true).is_none());
1046 }
1047
1048 #[test]
1049 fn an_entry_filed_under_the_wrong_key_is_a_miss() {
1050 let dir = tempfile::tempdir().unwrap();
1051 let cache = cache(dir.path());
1052 cache.store(&doc("o/r", 7, ItemKind::Issue));
1053 let from = cache.entry_path("o/r", 7).unwrap();
1054 std::fs::copy(&from, cache.entry_path("o/r", 8).unwrap()).unwrap();
1055 assert!(cache.lookup(&item_ref("o/r", 8), |_| true).is_none());
1056 }
1057
1058 #[test]
1059 fn a_rejected_kind_is_a_miss() {
1060 let dir = tempfile::tempdir().unwrap();
1061 let cache = cache(dir.path());
1062 cache.store(&doc("o/r", 7, ItemKind::ChangeRequest));
1063 let issues_only = |d: &IssueDoc| d.kind == ItemKind::Issue;
1064 assert!(cache.lookup(&item_ref("o/r", 7), issues_only).is_none());
1065 assert!(cache.lookup(&item_ref("o/r", 7), |_| true).is_some());
1066 }
1067
1068 #[test]
1069 fn refresh_skips_reads_but_still_writes() {
1070 let dir = tempfile::tempdir().unwrap();
1071 let refreshing = IssueCache::new(dir.path().to_path_buf(), DEFAULT_TTL, true);
1072 refreshing.store(&doc("o/r", 7, ItemKind::Issue));
1073 assert!(refreshing.lookup(&item_ref("o/r", 7), |_| true).is_none());
1074 assert!(cache(dir.path())
1075 .lookup(&item_ref("o/r", 7), |_| true)
1076 .is_some());
1077 }
1078
1079 #[test]
1080 fn a_zero_ttl_neither_reads_nor_writes() {
1081 let dir = tempfile::tempdir().unwrap();
1082 let off = IssueCache::new(dir.path().to_path_buf(), Duration::ZERO, false);
1083 off.store(&doc("o/r", 7, ItemKind::Issue));
1084 assert_eq!(std::fs::read_dir(dir.path()).unwrap().count(), 0);
1085 cache(dir.path()).store(&doc("o/r", 7, ItemKind::Issue));
1086 assert!(off.lookup(&item_ref("o/r", 7), |_| true).is_none());
1087 assert!(IssueCache::disabled()
1088 .lookup(&item_ref("o/r", 7), |_| true)
1089 .is_none());
1090 }
1091
1092 #[test]
1093 fn unsafe_project_segments_are_never_cached() {
1094 let dir = tempfile::tempdir().unwrap();
1095 let cache = cache(dir.path());
1096 for project in ["../r", "o/..", "o/.", "no-slash", "o/r/x", "o w/r", "/r"] {
1097 assert!(cache.entry_path(project, 1).is_none(), "{project}");
1098 }
1099 assert!(cache.entry_path("rust-works/.github", 1).is_some());
1100 }
1101
1102 #[cfg(unix)]
1103 #[test]
1104 fn entries_are_private_to_the_user() {
1105 use std::os::unix::fs::PermissionsExt;
1106 let dir = tempfile::tempdir().unwrap();
1107 let cache = cache(dir.path());
1108 cache.store(&doc("o/r", 7, ItemKind::Issue));
1109 let path = cache.entry_path("o/r", 7).unwrap();
1110 let mode = |p: &Path| std::fs::metadata(p).unwrap().permissions().mode() & 0o777;
1111 assert_eq!(mode(&path), 0o600);
1112 assert_eq!(mode(path.parent().unwrap()), 0o700);
1113 }
1114
1115 #[test]
1116 fn a_failed_write_is_ignored() {
1117 let dir = tempfile::tempdir().unwrap();
1118 std::fs::write(dir.path().join("o"), "").unwrap();
1120 let cache = cache(dir.path());
1121 cache.store(&doc("o/r", 7, ItemKind::Issue));
1122 assert!(cache.lookup(&item_ref("o/r", 7), |_| true).is_none());
1123 }
1124
1125 #[test]
1126 fn remove_entry_logs_and_ignores_a_non_not_found_failure() {
1127 let dir = tempfile::tempdir().unwrap();
1128 let path = dir.path().join("looks_like_an_entry");
1131 std::fs::create_dir(&path).unwrap();
1132 remove_entry(&path);
1133 assert!(path.exists(), "a directory can't be removed as a file");
1134 }
1135
1136 #[test]
1137 fn env_ttl_defaults_parses_and_ignores_garbage() {
1138 let base = Some(PathBuf::from("/cache"));
1139 let ttl =
1140 |env: &MapEnv| IssueCache::from_env_with(env, base.clone(), Some("acct"), false).ttl;
1141 assert_eq!(ttl(&MapEnv::new()), DEFAULT_TTL);
1142 assert_eq!(
1143 ttl(&MapEnv::new().with(GITHUB_CACHE_TTL_ENV, "60")),
1144 Duration::from_secs(60)
1145 );
1146 assert_eq!(
1147 ttl(&MapEnv::new().with(GITHUB_CACHE_TTL_ENV, "soon")),
1148 DEFAULT_TTL
1149 );
1150 }
1151
1152 #[test]
1153 fn env_ttl_zero_or_no_cache_dir_disables_the_cache() {
1154 let off = IssueCache::from_env_with(
1155 &MapEnv::new().with(GITHUB_CACHE_TTL_ENV, "0"),
1156 Some(PathBuf::from("/cache")),
1157 Some("acct"),
1158 false,
1159 );
1160 assert!(off.dir.is_none());
1161 assert!(
1162 IssueCache::from_env_with(&MapEnv::new(), None, Some("acct"), false)
1163 .dir
1164 .is_none()
1165 );
1166 }
1167
1168 #[test]
1169 fn no_usable_gh_login_disables_the_cache_but_keeps_the_root_to_sweep() {
1170 let base = Some(PathBuf::from("/cache"));
1171 for account in [None, Some(""), Some(".."), Some("a/b")] {
1172 let cache = IssueCache::from_env_with(&MapEnv::new(), base.clone(), account, false);
1173 assert!(cache.dir.is_none(), "{account:?}");
1174 assert!(cache.root.is_some(), "{account:?}");
1175 }
1176 }
1177
1178 #[test]
1179 fn the_cache_lives_under_omni_dev_github_issues() {
1180 let cache = IssueCache::from_env_with(
1181 &MapEnv::new(),
1182 Some(PathBuf::from("/c")),
1183 Some("0123abcd"),
1184 false,
1185 );
1186 assert_eq!(
1187 cache.entry_path("o/r", 7).unwrap(),
1188 Path::new("/c/omni-dev/github-issues/0123abcd/o/r/7.json")
1189 );
1190 }
1191
1192 #[test]
1193 fn a_different_gh_login_never_reads_an_entry_it_could_not_have_fetched() {
1194 let base = tempfile::tempdir().unwrap();
1195 let open = |account: &str| {
1196 IssueCache::from_env_with(
1197 &MapEnv::new(),
1198 Some(base.path().to_path_buf()),
1199 Some(account),
1200 false,
1201 )
1202 };
1203 open(&account_scope("token-a")).store(&doc("o/r", 7, ItemKind::Issue));
1204 let item = item_ref("o/r", 7);
1205 assert!(open(&account_scope("token-a"))
1206 .lookup(&item, |_| true)
1207 .is_some());
1208 assert!(open(&account_scope("token-b"))
1209 .lookup(&item, |_| true)
1210 .is_none());
1211 }
1212
1213 #[test]
1214 fn the_account_scope_is_a_stable_path_safe_digest_that_hides_the_token() {
1215 let scope = account_scope("gho_secret");
1216 assert_eq!(scope, account_scope("gho_secret"));
1217 assert_ne!(scope, account_scope("gho_other"));
1218 assert_eq!(scope.len(), 16);
1219 assert!(
1220 is_safe_segment(&scope) && !scope.contains("secret"),
1221 "{scope}"
1222 );
1223 }
1224
1225 #[test]
1226 fn owner_and_repo_case_share_one_entry_and_the_callers_spelling_wins() {
1227 let dir = tempfile::tempdir().unwrap();
1228 let cache = cache(dir.path());
1229 cache.store(&doc("Rust-Works/Omni-Dev", 1, ItemKind::Issue));
1230 for spelling in [
1231 "Rust-Works/Omni-Dev",
1232 "rust-works/omni-dev",
1233 "RUST-WORKS/omni-dev",
1234 ] {
1235 let served = cache.lookup(&item_ref(spelling, 1), |_| true).unwrap();
1236 assert_eq!(served.project, spelling);
1237 }
1238 assert_eq!(std::fs::read_dir(dir.path()).unwrap().count(), 1);
1239 }
1240
1241 #[test]
1242 fn usage_reports_the_count_and_oldest_age_for_machine_readable_output() {
1243 let dir = tempfile::tempdir().unwrap();
1244 assert_eq!(cache(dir.path()).usage(), None);
1245 cache(dir.path()).store(&doc("o/r", 1, ItemKind::Issue));
1246 cache(dir.path()).store(&doc("o/r", 2, ItemKind::Issue));
1247 backdate(dir.path(), "o/r", 2, 150);
1248 let cache = cache(dir.path());
1249 assert!(cache.lookup(&item_ref("o/r", 1), |_| true).is_some());
1250 assert!(cache.lookup(&item_ref("o/r", 2), |_| true).is_some());
1251 let usage = cache.usage().unwrap();
1252 assert_eq!(usage.items_reused, 2);
1253 assert!((150..=152).contains(&usage.oldest_age_secs), "{usage:?}");
1254 }
1255
1256 #[test]
1257 fn a_poisoned_verified_set_marks_nothing_and_never_panics() {
1258 let dir = tempfile::tempdir().unwrap();
1259 let cache = cache(dir.path());
1260 std::thread::scope(|scope| {
1261 let poisoner = scope.spawn(|| {
1262 let _held = cache.verified.lock().unwrap();
1263 panic!("poison the verified set");
1264 });
1265 assert!(poisoner.join().is_err());
1266 });
1267 cache.mark_state_verified(&[item_ref("o/r", 1)]);
1270 assert!(!cache.state_verified("o/r", 1));
1271 }
1272
1273 #[test]
1274 fn ages_format_in_seconds_then_minutes() {
1275 assert_eq!(format_age(0), "0s");
1276 assert_eq!(format_age(119), "119s");
1277 assert_eq!(format_age(120), "2m");
1278 assert_eq!(format_age(299), "4m");
1279 }
1280}