Skip to main content

omni_dev/github_issues/
cache.rs

1//! A short-lived, on-disk cache of fetched GitHub issues and pull requests
2//! (#1858).
3//!
4//! `ai jev route` and `verify-decision` are typically re-run against the same
5//! issue while tuning flags or prompts, by people and by agents alike. Each
6//! run used to pay a fresh `gh api graphql` round trip for identical data;
7//! this cache lets a run within [`DEFAULT_TTL`] of the last fetch reuse it.
8//!
9//! One JSON file per item, keyed by `(project, number)` and namespaced by the
10//! `gh` login that fetched it, under the user cache directory:
11//! `<cache_dir>/omni-dev/github-issues/<account>/<owner>/<repo>/<number>.json`.
12//! `<account>` is a digest of the token `gh` is using (see `account_scope`),
13//! so a different host or a switched `gh` account never reads another's
14//! entries, and no login can be served issue text it could not fetch itself.
15//! The project is lowercased in the path, since GitHub names are
16//! case-insensitive. Both [`super::fetch_issues`] and [`super::fetch_items`]
17//! build the same [`IssueDoc`] for an issue, so they share entries; a caller
18//! that only accepts issues filters on [`IssueDoc::kind`] at lookup.
19//!
20//! The cache is best-effort throughout: a missing, unreadable, corrupt,
21//! old-schema or expired entry is a miss, and a failed write is logged and
22//! ignored, so the worst case is exactly the uncached behaviour. Not-found
23//! results are never cached, so fixing a typo or granting `gh` access takes
24//! effect at once. Entries hold issue text from possibly private
25//! repositories, so [`IssueCache::prune_expired`] sweeps expired ones (and
26//! the orphaned temp files of a crashed write) each run rather than leaving
27//! them on disk indefinitely, along with any empty
28//! `<account>/<owner>/<repo>` directory. A lookup never deletes: the fetch
29//! that follows a miss overwrites the entry, and leaving removal to the sweep
30//! means a reader can never delete a fresh entry a concurrent writer just
31//! renamed into place.
32//!
33//! A flat file rather than a daemon op was a deliberate choice: the usage
34//! pattern is *sequential* re-runs, which a shared file serves fully, while a
35//! daemon would add a wire op, a daemon-down fallback and version skew for
36//! the one extra it offers — deduplicating concurrent in-flight fetches.
37
38use 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
53/// Environment variable / settings key setting the cache lifetime in
54/// seconds. `0` disables the cache entirely (no reads, no writes).
55pub const GITHUB_CACHE_TTL_ENV: &str = "OMNI_DEV_GITHUB_CACHE_TTL_SECS";
56
57/// How long a fetched item is reused when [`GITHUB_CACHE_TTL_ENV`] is unset.
58///
59/// Long enough to cover rapid re-runs, short enough that a closed or
60/// newly-commented issue is seen soon (and `--refresh` covers "I just
61/// changed it").
62pub const DEFAULT_TTL: Duration = Duration::from_secs(300);
63
64/// Bumped whenever the entry layout (or [`IssueDoc`]'s) changes; an entry
65/// with any other value is a miss. A new `#[serde(default)]` field would
66/// otherwise deserialise from an old entry without error and be served
67/// empty, so `schema_tracks_the_issue_doc_shape` pins the field set.
68const SCHEMA: u32 = 1;
69
70/// The cache's directory under the user cache directory.
71const CACHE_SUBDIR: [&str; 2] = ["omni-dev", "github-issues"];
72
73/// Entries deeper than `<account>/<owner>/<repo>/<file>` are never written, so
74/// the sweep does not descend further.
75const MAX_SWEEP_DEPTH: usize = 4;
76
77/// Extension of an in-flight write's temp file, which is renamed to `.json`.
78const TEMP_EXT: &str = "partial";
79
80/// How long a temp file may sit before the sweep treats it as orphaned by a
81/// crash. Far longer than any write takes, so a live one is never removed.
82const TEMP_GRACE: Duration = Duration::from_secs(3600);
83
84/// One cached item, as stored on disk.
85#[derive(Serialize, Deserialize)]
86struct Entry {
87    schema: u32,
88    /// Unix seconds at which `doc` was fetched.
89    fetched_at: u64,
90    doc: IssueDoc,
91}
92
93/// How much of a run's GitHub input came from the cache.
94///
95/// Reported as `github_cache` in `route` and `verify-decision`'s JSON and YAML
96/// output, and omitted when nothing was reused, so an uncached run serialises
97/// as before.
98#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
99pub struct CacheUsage {
100    /// How many items were served from the cache.
101    pub items_reused: usize,
102    /// The age in seconds of the oldest of them.
103    pub oldest_age_secs: u64,
104}
105
106/// The GitHub fetch cache shared by `route` and `verify-decision`.
107///
108/// Also counts how many items a run reused and the age of the oldest, so the
109/// CLI can say so ([`reuse_note`](Self::reuse_note)) — stale input is never
110/// silent.
111#[derive(Debug)]
112pub struct IssueCache {
113    /// Everything this cache has ever written, across accounts, for the
114    /// sweep. `None` only when there is no cache directory at all.
115    root: Option<PathBuf>,
116    /// Where this run reads and writes (`root` plus the account digest);
117    /// `None` when the cache is disabled.
118    dir: Option<PathBuf>,
119    ttl: Duration,
120    /// Skip reads (but still write), for `--refresh`.
121    refresh: bool,
122    hits: AtomicUsize,
123    oldest_hit_secs: AtomicU64,
124    /// Items this run fetched and stored itself, so reading one back (say, a
125    /// judged issue that another judged issue cites) is not reported as a
126    /// reuse of an earlier run's data.
127    stored: Mutex<HashSet<(String, u64)>>,
128    /// Items whose cached `state` this run has checked against GitHub, so a
129    /// second call in the same run (a cited issue that was also routed) does
130    /// not check it again.
131    verified: Mutex<HashSet<(String, u64)>>,
132}
133
134impl IssueCache {
135    /// A cache rooted at `dir` (the directory holding `<owner>/<repo>/`),
136    /// reusing entries younger than `ttl`. `refresh` re-fetches everything
137    /// while still writing the fresh copies back.
138    #[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    /// A cache that never reads or writes anything.
158    #[must_use]
159    pub fn disabled() -> Self {
160        Self::build(None, None, Duration::ZERO, false)
161    }
162
163    /// The cache configured from `env` (in production, the `SettingsEnv` the
164    /// command already loaded), rooted under `base` (`dirs::cache_dir()`).
165    ///
166    /// `account` is the `account_scope` digest of the `gh` login the fetches will
167    /// run as. Without one (`gh` could not say who it is) the cache is
168    /// disabled, since an entry it wrote could later be served to another
169    /// login. With no base directory the cache is disabled too. A zero TTL
170    /// disables reads and writes but keeps the root, so the sweep still
171    /// clears what an earlier run cached.
172    #[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    /// Reads `item_ref`'s entry if it exists, is readable and is still fresh,
196    /// with its age in seconds. Never deletes and never counts a reuse: see
197    /// the module docs.
198    ///
199    /// A corrupt or old-layout entry is a miss; the fetch it triggers rewrites
200    /// it, so it heals itself (debug, not warn).
201    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        // A mismatched key is a miss but still a valid entry, so it is kept.
226        // GitHub names are case-insensitive, and so is the entry's path.
227        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    /// Returns the cached doc for `item_ref` if it is fresh and `accept`s it,
236    /// counting the reuse. Never deletes: see the module docs.
237    pub(super) fn lookup(
238        &self,
239        item_ref: &ItemRef,
240        accept: impl Fn(&IssueDoc) -> bool,
241    ) -> Option<IssueDoc> {
242        // A rejected kind or state is a miss but still a valid entry (a cached
243        // pull request serves `fetch_items`), so it is kept.
244        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        // The caller's spelling, so the doc is what an uncached fetch of this
250        // ref would have returned.
251        doc.project.clone_from(&item_ref.project);
252        Some(doc)
253    }
254
255    /// The `refs` whose fresh entry `accept`s, and whose `state` this run has
256    /// neither verified against GitHub nor stored itself — the ones a caller
257    /// must check before trusting. Counts no reuse.
258    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    /// Records that this run has checked `refs`' cached `state` against GitHub,
273    /// so a later call in the same run does not check them again.
274    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    /// Whether `project#number`'s `state` is as fresh as a fetch this run: it
284    /// stored the item itself, or checked its cached state.
285    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    /// Deletes every expired entry, and every temp file a crashed write
294    /// orphaned. Best-effort, like everything else here; **blocking**, so run
295    /// it on the same blocking thread as the fetch.
296    ///
297    /// Age is the file's mtime, which a write sets to when it stored the entry,
298    /// so the sweep never reads or parses an entry. It covers every account's
299    /// entries, and with a zero TTL it removes them all.
300    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            // An unknown age (a vanished file, or an mtime in the future after
306            // the clock moved) is not trusted, as in `fresh_age`; but only an
307            // entry is removed on it, since a temp file may be live.
308            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    /// The age of an entry fetched at `fetched_at`, or `None` once it has
322    /// expired. A `fetched_at` in the future means the clock went backwards;
323    /// the entry's age is unknown, so it is not trusted either.
324    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    /// Writes `doc` to the cache. Best-effort: a failure is logged at debug
337    /// level and otherwise ignored, since the caller already has the doc.
338    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    /// What this run reused, for the machine-readable output, or `None` when
356    /// it reused nothing. The stderr [`reuse_note`](Self::reuse_note) is the
357    /// human's copy of the same facts; a script reading only stdout needs this.
358    #[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    /// A one-line note for stderr when this run reused cached items, naming
368    /// how many, the oldest's age, and how to bypass the cache.
369    #[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    /// The entry file for `project#number` (lowercased, as GitHub names are
383    /// case-insensitive), or `None` when the cache is disabled or `project`
384    /// isn't a pair of path-safe segments.
385    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
396/// Parses [`GITHUB_CACHE_TTL_ENV`], warning on (and ignoring) a malformed
397/// value rather than silently disabling or keeping the cache.
398fn 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
412/// Whether `segment` can be used as a path component as-is: GitHub owner and
413/// repository names only use ASCII alphanumerics, `-`, `_` and `.`, and `.`
414/// and `..` would escape the cache directory.
415fn 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
424/// How many times a write starts over when the sweep removes its directory
425/// from under it. A second collision in a row means a sweep is running in a
426/// loop, not a race, and the write is given up as any other failed write is.
427const WRITE_ATTEMPTS: usize = 3;
428
429/// Writes one entry atomically: a `0600` temp file in the same `0700`
430/// directory, then a rename, so a concurrent reader sees either the old entry
431/// or the new one, never a partial file.
432///
433/// The sweep removes the directories it empties, and a directory is empty
434/// between its creation and the temp file landing in it, so the whole
435/// sequence starts over when a directory vanishes. Once the temp file exists
436/// its directory is non-empty and `remove_dir` refuses it, so the rename
437/// itself cannot lose that race.
438fn write_entry(path: &Path, doc: &IssueDoc) -> Result<()> {
439    retry_on_not_found(|| write_entry_once(path, doc))
440}
441
442/// Runs `write`, up to [`WRITE_ATTEMPTS`] times while it fails with `NotFound`.
443fn 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    // `NamedTempFile` is created `0600` on Unix with a random name, so two
470    // processes caching the same item never share a temp file. Its name is
471    // recognisable (`TEMP_EXT`) so the sweep can remove one a crash orphaned.
472    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
483/// Deletes one file, best-effort: a failure only means it is tried again on
484/// the next sweep.
485fn 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
495/// Removes every directory under `root` (never `root` itself) that holds
496/// nothing, deepest first, down to [`MAX_SWEEP_DEPTH`] levels. Directories
497/// that were already empty — an earlier release's, or a write that failed
498/// after making its directory — go too, not only those this sweep emptied.
499///
500/// `remove_dir` refuses a directory that still holds anything — a fresh
501/// entry, a live write's temp file, a file that is not the cache's — so it is
502/// the emptiness check as well, and a directory a writer has just filled keeps
503/// it. One a writer has just made but not yet filled can lose the race, which
504/// `write_entry` absorbs. Symlinks are not followed.
505fn 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
521/// Logs a `remove_dir` failure worth knowing about. Refusing a directory that
522/// still holds something, or one a concurrent sweep already removed, is the
523/// sweep working as designed and stays silent.
524fn 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
534/// Whether `remove_dir` refused because the directory still holds something:
535/// `DirectoryNotEmpty`, or `AlreadyExists` where `rmdir` reports `EEXIST`.
536fn 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
543/// How long ago `path` was last written, or `None` if that is unknown (the
544/// file is gone, or its mtime is in the future).
545fn 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
550/// Every regular file the cache could have written under `root` — an entry
551/// (`*.json`, `false`) or an in-flight write's temp file (`true`) — down to
552/// [`MAX_SWEEP_DEPTH`] levels. Anything else is left alone, and symlinks are
553/// never followed.
554fn 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/// The cache namespace for one `gh` login: a digest of `token`, which `gh
583/// auth token` prints for the account and host `gh` is currently using, so a
584/// switched account or another host lands in a different directory. Only the
585/// digest is kept; the token itself is never stored or logged.
586#[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
601/// `42s` under two minutes, else whole minutes (`7m`).
602fn 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    /// Overwrites an entry's `fetched_at`, to age it without sleeping.
646    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        // A lookup must not delete: it could be racing a writer's rename.
710        assert!(cache.entry_path("o/r", 7).unwrap().exists());
711    }
712
713    /// Sets `path`'s mtime to `secs_ago` seconds in the past (negative: the
714    /// future), which is what the sweep reads.
715    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        // TTL 0 turns reads and writes off but must still clear the root:
797        // what an earlier run cached is private text nothing else will delete.
798        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    /// The `<account>/<owner>/<repo>` directories under `root`, at any depth.
805    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        // The kinds `remove_dir` gives for a directory that is not empty
877        // (`ENOTEMPTY`, or `EEXIST` on platforms that report it so).
878        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        // None of these may panic; the logged arm has no observable effect.
883        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    /// Adding, removing or renaming a field of [`IssueDoc`] or [`Comment`]
990    /// changes what a cached entry means, so it must bump [`SCHEMA`] — and
991    /// then update this list.
992    #[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        // A file where the owner directory should be makes every write fail.
1119        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        // remove_file on a directory fails with something other than
1129        // NotFound, exercising the branch a missing entry never takes.
1130        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        // Best-effort like the rest of the cache: the mark is skipped, so the
1268        // item simply stays unverified and gets checked again.
1269        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}