use std::collections::HashSet;
use std::fmt::Write as _;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
use std::sync::Mutex;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use tracing::{debug, warn};
use crate::provider::{IssueDoc, ItemRef};
use crate::utils::env::{non_empty_var, EnvSource};
pub const GITHUB_CACHE_TTL_ENV: &str = "OMNI_DEV_GITHUB_CACHE_TTL_SECS";
pub const DEFAULT_TTL: Duration = Duration::from_secs(300);
const SCHEMA: u32 = 1;
const CACHE_SUBDIR: [&str; 2] = ["omni-dev", "github-issues"];
const MAX_SWEEP_DEPTH: usize = 4;
const TEMP_EXT: &str = "partial";
const TEMP_GRACE: Duration = Duration::from_secs(3600);
#[derive(Serialize, Deserialize)]
struct Entry {
schema: u32,
fetched_at: u64,
doc: IssueDoc,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
pub struct CacheUsage {
pub items_reused: usize,
pub oldest_age_secs: u64,
}
#[derive(Debug)]
pub struct IssueCache {
root: Option<PathBuf>,
dir: Option<PathBuf>,
ttl: Duration,
refresh: bool,
hits: AtomicUsize,
oldest_hit_secs: AtomicU64,
stored: Mutex<HashSet<(String, u64)>>,
verified: Mutex<HashSet<(String, u64)>>,
}
impl IssueCache {
#[must_use]
pub fn new(dir: PathBuf, ttl: Duration, refresh: bool) -> Self {
let active = (!ttl.is_zero()).then(|| dir.clone());
Self::build(Some(dir), active, ttl, refresh)
}
fn build(root: Option<PathBuf>, dir: Option<PathBuf>, ttl: Duration, refresh: bool) -> Self {
Self {
root,
dir,
ttl,
refresh,
hits: AtomicUsize::new(0),
oldest_hit_secs: AtomicU64::new(0),
stored: Mutex::new(HashSet::new()),
verified: Mutex::new(HashSet::new()),
}
}
#[must_use]
pub fn disabled() -> Self {
Self::build(None, None, Duration::ZERO, false)
}
#[must_use]
pub fn from_env_with(
env: &impl EnvSource,
base: Option<PathBuf>,
account: Option<&str>,
refresh: bool,
) -> Self {
let Some(base) = base else {
debug!("No user cache directory; the GitHub fetch cache is disabled");
return Self::disabled();
};
let root = CACHE_SUBDIR.iter().fold(base, |dir, part| dir.join(part));
let ttl = ttl_from_env(env);
let dir = account
.filter(|account| is_safe_segment(account))
.filter(|_| !ttl.is_zero())
.map(|account| root.join(account));
if dir.is_none() && !ttl.is_zero() {
debug!("No usable gh login; the GitHub fetch cache is disabled");
}
Self::build(Some(root), dir, ttl, refresh)
}
fn read_fresh(&self, item_ref: &ItemRef) -> Option<(IssueDoc, u64)> {
if self.refresh {
return None;
}
let path = self.entry_path(&item_ref.project, item_ref.number)?;
let bytes = match std::fs::read(&path) {
Ok(bytes) => bytes,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return None,
Err(e) => {
let shown = path.display();
debug!("Ignoring GitHub cache entry {shown}: {e}");
return None;
}
};
let entry = match serde_json::from_slice::<Entry>(&bytes) {
Ok(entry) if entry.schema == SCHEMA => entry,
Ok(_) => return None,
Err(e) => {
let shown = path.display();
debug!("Ignoring unreadable GitHub cache entry {shown}: {e}");
return None;
}
};
let age = self.fresh_age(entry.fetched_at)?;
if !entry.doc.project.eq_ignore_ascii_case(&item_ref.project)
|| entry.doc.number != item_ref.number
{
return None;
}
Some((entry.doc, age))
}
pub(super) fn lookup(
&self,
item_ref: &ItemRef,
accept: impl Fn(&IssueDoc) -> bool,
) -> Option<IssueDoc> {
let (mut doc, age) = self.read_fresh(item_ref).filter(|(doc, _)| accept(doc))?;
if !self.stored_this_run(&item_ref.project, item_ref.number) {
self.hits.fetch_add(1, Ordering::Relaxed);
self.oldest_hit_secs.fetch_max(age, Ordering::Relaxed);
}
doc.project.clone_from(&item_ref.project);
Some(doc)
}
pub(super) fn unverified_refs<'a>(
&self,
refs: &'a [ItemRef],
accept: impl Fn(&IssueDoc) -> bool,
) -> Vec<&'a ItemRef> {
refs.iter()
.filter(|item_ref| !self.state_verified(&item_ref.project, item_ref.number))
.filter(|item_ref| {
self.read_fresh(item_ref)
.is_some_and(|(doc, _)| accept(&doc))
})
.collect()
}
pub(super) fn mark_state_verified(&self, refs: &[ItemRef]) {
if let Ok(mut verified) = self.verified.lock() {
verified.extend(
refs.iter()
.map(|r| (r.project.to_ascii_lowercase(), r.number)),
);
}
}
pub(super) fn state_verified(&self, project: &str, number: u64) -> bool {
self.stored_this_run(project, number)
|| self
.verified
.lock()
.is_ok_and(|v| v.contains(&(project.to_ascii_lowercase(), number)))
}
pub fn prune_expired(&self) {
let Some(root) = &self.root else {
return;
};
for (path, is_temp) in sweep_files(root) {
let age = file_age(&path);
let expired = if is_temp {
age.is_some_and(|age| age >= TEMP_GRACE)
} else {
age.is_none_or(|age| age.as_secs() >= self.ttl.as_secs())
};
if expired {
remove_entry(&path);
}
}
remove_empty_dirs(root);
}
fn fresh_age(&self, fetched_at: u64) -> Option<u64> {
now_secs()
.checked_sub(fetched_at)
.filter(|age| *age < self.ttl.as_secs())
}
fn stored_this_run(&self, project: &str, number: u64) -> bool {
self.stored
.lock()
.is_ok_and(|stored| stored.contains(&(project.to_ascii_lowercase(), number)))
}
pub(super) fn store(&self, doc: &IssueDoc) {
let Some(path) = self.entry_path(&doc.project, doc.number) else {
return;
};
match write_entry(&path, doc) {
Ok(()) => {
if let Ok(mut stored) = self.stored.lock() {
stored.insert((doc.project.to_ascii_lowercase(), doc.number));
}
}
Err(e) => {
let (project, number) = (&doc.project, doc.number);
debug!("Failed to cache {project}#{number}: {e:#}");
}
}
}
#[must_use]
pub fn usage(&self) -> Option<CacheUsage> {
let items_reused = self.hits.load(Ordering::Relaxed);
(items_reused > 0).then(|| CacheUsage {
items_reused,
oldest_age_secs: self.oldest_hit_secs.load(Ordering::Relaxed),
})
}
#[must_use]
pub fn reuse_note(&self) -> Option<String> {
let hits = self.hits.load(Ordering::Relaxed);
if hits == 0 {
return None;
}
let age = format_age(self.oldest_hit_secs.load(Ordering::Relaxed));
let noun = if hits == 1 { "item" } else { "items" };
Some(format!(
"note: reused {hits} cached GitHub {noun}, up to {age} old; pass --refresh to re-fetch"
))
}
fn entry_path(&self, project: &str, number: u64) -> Option<PathBuf> {
let dir = self.dir.as_ref()?;
let project = project.to_ascii_lowercase();
let (owner, repo) = project.split_once('/')?;
if !is_safe_segment(owner) || !is_safe_segment(repo) {
return None;
}
Some(dir.join(owner).join(repo).join(format!("{number}.json")))
}
}
fn ttl_from_env(env: &impl EnvSource) -> Duration {
let Some(raw) = non_empty_var(env, GITHUB_CACHE_TTL_ENV) else {
return DEFAULT_TTL;
};
match raw.trim().parse::<u64>() {
Ok(secs) => Duration::from_secs(secs),
Err(e) => {
let default = DEFAULT_TTL.as_secs();
warn!("Ignoring {GITHUB_CACHE_TTL_ENV}={raw:?} ({e}); using {default}s");
DEFAULT_TTL
}
}
}
fn is_safe_segment(segment: &str) -> bool {
!segment.is_empty()
&& segment != "."
&& segment != ".."
&& segment
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.'))
}
const WRITE_ATTEMPTS: usize = 3;
fn write_entry(path: &Path, doc: &IssueDoc) -> Result<()> {
retry_on_not_found(|| write_entry_once(path, doc))
}
fn retry_on_not_found(mut write: impl FnMut() -> Result<()>) -> Result<()> {
let mut attempt = 1;
loop {
match write() {
Err(e) if attempt < WRITE_ATTEMPTS && is_not_found(&e) => attempt += 1,
result => return result,
}
}
}
fn is_not_found(err: &anyhow::Error) -> bool {
err.chain().any(|cause| {
cause
.downcast_ref::<std::io::Error>()
.is_some_and(|io_err| io_err.kind() == std::io::ErrorKind::NotFound)
})
}
fn write_entry_once(path: &Path, doc: &IssueDoc) -> Result<()> {
crate::daemon::paths::ensure_parent_dir_0700(path)?;
let dir = path.parent().context("cache entry path has no parent")?;
let entry = Entry {
schema: SCHEMA,
fetched_at: now_secs(),
doc: doc.clone(),
};
let mut tmp = tempfile::Builder::new()
.prefix(".entry-")
.suffix(&format!(".{TEMP_EXT}"))
.tempfile_in(dir)
.with_context(|| format!("Failed to create a temp file in {}", dir.display()))?;
serde_json::to_writer(&mut tmp, &entry).context("Failed to serialise cache entry")?;
tmp.persist(path)
.with_context(|| format!("Failed to replace {}", path.display()))?;
Ok(())
}
fn remove_entry(path: &Path) {
match std::fs::remove_file(path) {
Err(e) if e.kind() != std::io::ErrorKind::NotFound => {
let shown = path.display();
debug!("Failed to remove GitHub cache file {shown}: {e}");
}
_ => {}
}
}
fn remove_empty_dirs(root: &Path) {
fn walk(dir: &Path, depth: usize) {
let Ok(children) = std::fs::read_dir(dir) else {
return;
};
for child in children.flatten() {
if depth < MAX_SWEEP_DEPTH && child.file_type().is_ok_and(|kind| kind.is_dir()) {
let path = child.path();
walk(&path, depth + 1);
log_unexpected_remove_dir_failure(&path, std::fs::remove_dir(&path));
}
}
}
walk(root, 1);
}
fn log_unexpected_remove_dir_failure(path: &Path, result: std::io::Result<()>) {
match result {
Err(e) if !is_not_empty(&e) && e.kind() != std::io::ErrorKind::NotFound => {
let shown = path.display();
debug!("Failed to remove GitHub cache directory {shown}: {e}");
}
_ => {}
}
}
fn is_not_empty(err: &std::io::Error) -> bool {
matches!(
err.kind(),
std::io::ErrorKind::DirectoryNotEmpty | std::io::ErrorKind::AlreadyExists
)
}
fn file_age(path: &Path) -> Option<Duration> {
let modified = std::fs::metadata(path).ok()?.modified().ok()?;
SystemTime::now().duration_since(modified).ok()
}
fn sweep_files(root: &Path) -> Vec<(PathBuf, bool)> {
fn walk(dir: &Path, depth: usize, out: &mut Vec<(PathBuf, bool)>) {
let Ok(children) = std::fs::read_dir(dir) else {
return;
};
for child in children.flatten() {
let path = child.path();
let Ok(kind) = child.file_type() else {
continue;
};
if kind.is_dir() {
if depth < MAX_SWEEP_DEPTH {
walk(&path, depth + 1, out);
}
} else if kind.is_file() {
match path.extension().and_then(|ext| ext.to_str()) {
Some("json") => out.push((path, false)),
Some(TEMP_EXT) => out.push((path, true)),
_ => {}
}
}
}
}
let mut out = Vec::new();
walk(root, 1, &mut out);
out
}
#[must_use]
pub fn account_scope(token: &str) -> String {
let digest = Sha256::digest(token.as_bytes());
digest[..8].iter().fold(String::new(), |mut hex, byte| {
let _ = write!(hex, "{byte:02x}");
hex
})
}
fn now_secs() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_or(0, |d| d.as_secs())
}
fn format_age(secs: u64) -> String {
if secs < 120 {
format!("{secs}s")
} else {
format!("{}m", secs / 60)
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
use crate::provider::{GitProvider, ItemKind, ItemState};
use crate::test_support::env::MapEnv;
fn doc(project: &str, number: u64, kind: ItemKind) -> IssueDoc {
IssueDoc {
provider: GitProvider::GitHub,
project: project.to_string(),
number,
kind,
title: format!("title {number}"),
state: ItemState::Open,
body: "body".to_string(),
comments: Vec::new(),
closed_by: Vec::new(),
url: format!("https://github.com/{project}/issues/{number}"),
}
}
fn item_ref(project: &str, number: u64) -> ItemRef {
ItemRef {
provider: GitProvider::GitHub,
project: project.to_string(),
kind: ItemKind::Issue,
number,
}
}
fn cache(dir: &Path) -> IssueCache {
IssueCache::new(dir.to_path_buf(), DEFAULT_TTL, false)
}
fn backdate(dir: &Path, project: &str, number: u64, secs_ago: i64) {
let path = cache(dir).entry_path(project, number).unwrap();
let mut value: serde_json::Value =
serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap();
let at = i64::try_from(now_secs()).unwrap() - secs_ago;
value["fetched_at"] = serde_json::json!(at);
std::fs::write(&path, value.to_string()).unwrap();
}
#[test]
fn a_stored_doc_is_served_back_and_counted() {
let dir = tempfile::tempdir().unwrap();
let stored = doc("o/r", 7, ItemKind::Issue);
cache(dir.path()).store(&stored);
let cache = cache(dir.path());
assert_eq!(cache.lookup(&item_ref("o/r", 7), |_| true), Some(stored));
assert!(cache.lookup(&item_ref("o/r", 8), |_| true).is_none());
let note = cache.reuse_note().unwrap();
assert!(
note.starts_with("note: reused 1 cached GitHub item,"),
"{note}"
);
assert!(note.contains("--refresh"), "{note}");
}
#[test]
fn nothing_reused_means_no_note() {
let dir = tempfile::tempdir().unwrap();
assert!(cache(dir.path()).reuse_note().is_none());
}
#[test]
fn an_item_stored_this_run_is_served_but_not_reported_as_reused() {
let dir = tempfile::tempdir().unwrap();
let cache = cache(dir.path());
cache.store(&doc("o/r", 7, ItemKind::Issue));
assert!(cache.lookup(&item_ref("o/r", 7), |_| true).is_some());
assert!(cache.reuse_note().is_none());
}
#[test]
fn the_note_reports_the_count_and_oldest_age() {
let dir = tempfile::tempdir().unwrap();
cache(dir.path()).store(&doc("o/r", 1, ItemKind::Issue));
cache(dir.path()).store(&doc("o/r", 2, ItemKind::Issue));
backdate(dir.path(), "o/r", 2, 150);
let cache = cache(dir.path());
assert!(cache.lookup(&item_ref("o/r", 1), |_| true).is_some());
assert!(cache.lookup(&item_ref("o/r", 2), |_| true).is_some());
assert_eq!(
cache.reuse_note().unwrap(),
"note: reused 2 cached GitHub items, up to 2m old; pass --refresh to re-fetch"
);
}
#[test]
fn an_expired_entry_is_a_miss_and_is_left_for_the_sweep() {
let dir = tempfile::tempdir().unwrap();
let cache = cache(dir.path());
cache.store(&doc("o/r", 7, ItemKind::Issue));
backdate(dir.path(), "o/r", 7, 300);
assert!(cache.lookup(&item_ref("o/r", 7), |_| true).is_none());
assert!(cache.reuse_note().is_none());
assert!(cache.entry_path("o/r", 7).unwrap().exists());
}
fn set_age(path: &Path, secs_ago: i64) {
let now = SystemTime::now();
let by = Duration::from_secs(secs_ago.unsigned_abs());
let at = if secs_ago >= 0 { now - by } else { now + by };
std::fs::File::options()
.write(true)
.open(path)
.unwrap()
.set_modified(at)
.unwrap();
}
#[test]
fn prune_removes_entries_older_than_the_ttl_only() {
let dir = tempfile::tempdir().unwrap();
let cache = cache(dir.path());
for number in 1..=3 {
cache.store(&doc("o/r", number, ItemKind::Issue));
}
let path = |n| cache.entry_path("o/r", n).unwrap();
set_age(&path(2), 3600);
set_age(&path(3), -3600);
let bystander = dir.path().join("o/r/notes.txt");
std::fs::write(&bystander, "not ours").unwrap();
set_age(&bystander, 86_400);
cache.prune_expired();
assert!(path(1).exists());
assert!(!path(2).exists(), "expired");
assert!(!path(3).exists(), "an mtime in the future is not trusted");
assert!(bystander.exists(), "only the cache's own files are swept");
}
#[test]
fn prune_removes_a_crashed_writes_temp_file_but_not_a_live_one() {
let dir = tempfile::tempdir().unwrap();
let cache = cache(dir.path());
cache.store(&doc("o/r", 1, ItemKind::Issue));
let repo_dir = dir.path().join("o/r");
let orphan = repo_dir.join(format!(".entry-abc.{TEMP_EXT}"));
let live = repo_dir.join(format!(".entry-def.{TEMP_EXT}"));
std::fs::write(&orphan, "issue text").unwrap();
std::fs::write(&live, "issue text").unwrap();
set_age(&orphan, i64::try_from(TEMP_GRACE.as_secs()).unwrap() + 60);
cache.prune_expired();
assert!(!orphan.exists(), "an orphaned temp file holds issue text");
assert!(live.exists(), "an in-flight write is never pruned");
}
#[test]
fn a_write_leaves_no_temp_file_behind() {
let dir = tempfile::tempdir().unwrap();
cache(dir.path()).store(&doc("o/r", 1, ItemKind::Issue));
let names: Vec<_> = std::fs::read_dir(dir.path().join("o/r"))
.unwrap()
.map(|e| e.unwrap().file_name().into_string().unwrap())
.collect();
assert_eq!(names, ["1.json"]);
}
#[test]
fn prune_sweeps_every_accounts_entries_and_a_zero_ttl_sweeps_them_all() {
let base = tempfile::tempdir().unwrap();
let env = MapEnv::new();
let open = |env: &MapEnv, account: &str| {
IssueCache::from_env_with(env, Some(base.path().to_path_buf()), Some(account), false)
};
let (a, b) = (open(&env, "aaaa"), open(&env, "bbbb"));
a.store(&doc("o/r", 1, ItemKind::Issue));
b.store(&doc("o/r", 1, ItemKind::Issue));
let (path_a, path_b) = (
a.entry_path("o/r", 1).unwrap(),
b.entry_path("o/r", 1).unwrap(),
);
set_age(&path_a, 3600);
b.prune_expired();
assert!(!path_a.exists(), "another account's expired entry");
assert!(path_b.exists());
let off = open(&MapEnv::new().with(GITHUB_CACHE_TTL_ENV, "0"), "bbbb");
assert!(off.dir.is_none());
off.prune_expired();
assert!(!path_b.exists());
}
fn dirs_under(root: &Path) -> Vec<PathBuf> {
fn walk(dir: &Path, out: &mut Vec<PathBuf>) {
for child in std::fs::read_dir(dir).unwrap().flatten() {
if child.file_type().unwrap().is_dir() {
out.push(child.path());
walk(&child.path(), out);
}
}
}
let mut out = Vec::new();
walk(root, &mut out);
out
}
#[test]
fn prune_removes_the_directories_an_expired_tree_leaves_empty() {
let base = tempfile::tempdir().unwrap();
let env = MapEnv::new();
let open = |account: &str| {
IssueCache::from_env_with(&env, Some(base.path().to_path_buf()), Some(account), false)
};
let (a, b) = (open("aaaa"), open("bbbb"));
for cache in [&a, &b] {
cache.store(&doc("o/r", 1, ItemKind::Issue));
cache.store(&doc("o/other", 1, ItemKind::Issue));
}
let entries = [("o/r", &a), ("o/other", &a), ("o/r", &b), ("o/other", &b)];
for (project, cache) in entries {
set_age(&cache.entry_path(project, 1).unwrap(), 3600);
}
a.prune_expired();
let root = a.root.clone().unwrap();
assert_eq!(dirs_under(&root), Vec::<PathBuf>::new());
assert!(root.is_dir(), "the cache root itself is never removed");
}
#[test]
fn prune_keeps_the_directories_a_fresh_sibling_or_bystander_still_needs() {
let dir = tempfile::tempdir().unwrap();
let cache = cache(dir.path());
for (project, number) in [("o/r", 1), ("o/r", 2), ("o/gone", 1), ("o/kept", 1)] {
cache.store(&doc(project, number, ItemKind::Issue));
}
let path = |project, n| cache.entry_path(project, n).unwrap();
set_age(&path("o/r", 1), 3600);
set_age(&path("o/gone", 1), 3600);
set_age(&path("o/kept", 1), 3600);
let bystander = path("o/kept", 1).with_file_name("notes.txt");
std::fs::write(&bystander, "not ours").unwrap();
cache.prune_expired();
assert!(path("o/r", 2).exists(), "the fresh sibling is untouched");
assert!(
bystander.exists(),
"a file that is not ours pins its directory"
);
assert!(!dir.path().join("o/gone").exists(), "emptied");
assert!(dir.path().join("o/r").is_dir());
assert!(dir.path().join("o/kept").is_dir());
assert!(dir.path().join("o").is_dir(), "still has children");
let mut left = dirs_under(dir.path());
left.sort();
let expected = ["o", "o/kept", "o/r"].map(|d| dir.path().join(d));
assert_eq!(left, expected);
}
#[test]
fn only_a_refusal_for_a_reason_other_than_content_or_absence_is_worth_logging() {
use std::io::{Error, ErrorKind};
assert!(is_not_empty(&Error::from(ErrorKind::DirectoryNotEmpty)));
assert!(is_not_empty(&Error::from(ErrorKind::AlreadyExists)));
assert!(!is_not_empty(&Error::from(ErrorKind::PermissionDenied)));
let path = Path::new("/cache/o/r");
log_unexpected_remove_dir_failure(path, Ok(()));
log_unexpected_remove_dir_failure(path, Err(Error::from(ErrorKind::NotFound)));
log_unexpected_remove_dir_failure(path, Err(Error::from(ErrorKind::DirectoryNotEmpty)));
log_unexpected_remove_dir_failure(path, Err(Error::from(ErrorKind::PermissionDenied)));
}
#[test]
fn prune_removes_directories_that_were_already_empty() {
let dir = tempfile::tempdir().unwrap();
let cache = cache(dir.path());
cache.store(&doc("o/kept", 1, ItemKind::Issue));
std::fs::create_dir_all(dir.path().join("o/empty")).unwrap();
std::fs::create_dir_all(dir.path().join("x/y")).unwrap();
cache.prune_expired();
assert!(cache.entry_path("o/kept", 1).unwrap().exists());
assert!(!dir.path().join("o/empty").exists());
assert!(!dir.path().join("x").exists());
}
#[test]
fn prune_keeps_a_directory_holding_a_live_writes_temp_file() {
let dir = tempfile::tempdir().unwrap();
let cache = cache(dir.path());
cache.store(&doc("o/r", 1, ItemKind::Issue));
let live = dir
.path()
.join("o/r")
.join(format!(".entry-abc.{TEMP_EXT}"));
std::fs::write(&live, "issue text").unwrap();
set_age(&cache.entry_path("o/r", 1).unwrap(), 3600);
cache.prune_expired();
assert!(live.exists(), "the writer's directory survives its sweep");
}
#[test]
fn a_write_after_the_sweep_removed_its_directories_recreates_them() {
let dir = tempfile::tempdir().unwrap();
let cache = cache(dir.path());
let item = doc("o/r", 1, ItemKind::Issue);
cache.store(&item);
set_age(&cache.entry_path("o/r", 1).unwrap(), 3600);
cache.prune_expired();
assert!(!dir.path().join("o").exists());
cache.store(&item);
assert!(cache.entry_path("o/r", 1).unwrap().exists());
}
#[test]
fn the_error_a_vanished_directory_gives_a_write_is_recognised() {
let dir = tempfile::tempdir().unwrap();
let gone = dir.path().join("gone");
let err = tempfile::Builder::new()
.tempfile_in(&gone)
.with_context(|| format!("Failed to create a temp file in {}", gone.display()))
.unwrap_err();
assert!(is_not_found(&err), "{err:?}");
assert!(!is_not_found(&anyhow::anyhow!("no io error at all")));
}
#[test]
fn a_write_that_loses_its_directory_starts_over_a_bounded_number_of_times() {
let vanished = || -> Result<()> {
Err(std::io::Error::from(std::io::ErrorKind::NotFound))
.context("Failed to create a temp file")
};
let mut calls = 0;
retry_on_not_found(|| {
calls += 1;
if calls < WRITE_ATTEMPTS {
vanished()
} else {
Ok(())
}
})
.unwrap();
assert_eq!(calls, WRITE_ATTEMPTS, "the last attempt succeeds");
let mut calls = 0;
retry_on_not_found(|| {
calls += 1;
vanished()
})
.unwrap_err();
assert_eq!(calls, WRITE_ATTEMPTS, "then it gives up");
let mut calls = 0;
retry_on_not_found(|| {
calls += 1;
Err(std::io::Error::from(std::io::ErrorKind::PermissionDenied).into())
})
.unwrap_err();
assert_eq!(calls, 1, "any other failure is not retried");
}
#[test]
fn prune_is_a_no_op_when_disabled_or_empty() {
IssueCache::disabled().prune_expired();
let dir = tempfile::tempdir().unwrap();
cache(&dir.path().join("missing")).prune_expired();
}
#[test]
fn schema_tracks_the_issue_doc_shape() {
let mut issue = doc("o/r", 7, ItemKind::Issue);
issue.comments.push(crate::provider::Comment {
author: "a".to_string(),
body: "b".to_string(),
id: Some(1),
});
let value = serde_json::to_value(&issue).unwrap();
let keys = |v: &serde_json::Value| -> Vec<String> {
let mut keys: Vec<String> = v.as_object().unwrap().keys().cloned().collect();
keys.sort();
keys
};
assert_eq!(
(SCHEMA, keys(&value), keys(&value["comments"][0])),
(
1,
[
"body", "closed_by", "comments", "kind", "number", "project", "provider",
"state", "title", "url"
]
.map(String::from)
.to_vec(),
["author", "body", "id"].map(String::from).to_vec(),
),
"IssueDoc's shape changed: bump SCHEMA in github_issues/cache.rs, then update this test"
);
}
#[test]
fn a_future_fetched_at_is_a_miss() {
let dir = tempfile::tempdir().unwrap();
let cache = cache(dir.path());
cache.store(&doc("o/r", 7, ItemKind::Issue));
backdate(dir.path(), "o/r", 7, -60);
assert!(cache.lookup(&item_ref("o/r", 7), |_| true).is_none());
}
#[test]
fn a_corrupt_or_old_schema_entry_is_a_miss() {
let dir = tempfile::tempdir().unwrap();
let cache = cache(dir.path());
cache.store(&doc("o/r", 7, ItemKind::Issue));
let path = cache.entry_path("o/r", 7).unwrap();
let mut value: serde_json::Value =
serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap();
value["schema"] = serde_json::json!(SCHEMA + 1);
std::fs::write(&path, value.to_string()).unwrap();
assert!(cache.lookup(&item_ref("o/r", 7), |_| true).is_none());
std::fs::write(&path, "{not json").unwrap();
assert!(cache.lookup(&item_ref("o/r", 7), |_| true).is_none());
}
#[test]
fn an_entry_filed_under_the_wrong_key_is_a_miss() {
let dir = tempfile::tempdir().unwrap();
let cache = cache(dir.path());
cache.store(&doc("o/r", 7, ItemKind::Issue));
let from = cache.entry_path("o/r", 7).unwrap();
std::fs::copy(&from, cache.entry_path("o/r", 8).unwrap()).unwrap();
assert!(cache.lookup(&item_ref("o/r", 8), |_| true).is_none());
}
#[test]
fn a_rejected_kind_is_a_miss() {
let dir = tempfile::tempdir().unwrap();
let cache = cache(dir.path());
cache.store(&doc("o/r", 7, ItemKind::ChangeRequest));
let issues_only = |d: &IssueDoc| d.kind == ItemKind::Issue;
assert!(cache.lookup(&item_ref("o/r", 7), issues_only).is_none());
assert!(cache.lookup(&item_ref("o/r", 7), |_| true).is_some());
}
#[test]
fn refresh_skips_reads_but_still_writes() {
let dir = tempfile::tempdir().unwrap();
let refreshing = IssueCache::new(dir.path().to_path_buf(), DEFAULT_TTL, true);
refreshing.store(&doc("o/r", 7, ItemKind::Issue));
assert!(refreshing.lookup(&item_ref("o/r", 7), |_| true).is_none());
assert!(cache(dir.path())
.lookup(&item_ref("o/r", 7), |_| true)
.is_some());
}
#[test]
fn a_zero_ttl_neither_reads_nor_writes() {
let dir = tempfile::tempdir().unwrap();
let off = IssueCache::new(dir.path().to_path_buf(), Duration::ZERO, false);
off.store(&doc("o/r", 7, ItemKind::Issue));
assert_eq!(std::fs::read_dir(dir.path()).unwrap().count(), 0);
cache(dir.path()).store(&doc("o/r", 7, ItemKind::Issue));
assert!(off.lookup(&item_ref("o/r", 7), |_| true).is_none());
assert!(IssueCache::disabled()
.lookup(&item_ref("o/r", 7), |_| true)
.is_none());
}
#[test]
fn unsafe_project_segments_are_never_cached() {
let dir = tempfile::tempdir().unwrap();
let cache = cache(dir.path());
for project in ["../r", "o/..", "o/.", "no-slash", "o/r/x", "o w/r", "/r"] {
assert!(cache.entry_path(project, 1).is_none(), "{project}");
}
assert!(cache.entry_path("rust-works/.github", 1).is_some());
}
#[cfg(unix)]
#[test]
fn entries_are_private_to_the_user() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let cache = cache(dir.path());
cache.store(&doc("o/r", 7, ItemKind::Issue));
let path = cache.entry_path("o/r", 7).unwrap();
let mode = |p: &Path| std::fs::metadata(p).unwrap().permissions().mode() & 0o777;
assert_eq!(mode(&path), 0o600);
assert_eq!(mode(path.parent().unwrap()), 0o700);
}
#[test]
fn a_failed_write_is_ignored() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("o"), "").unwrap();
let cache = cache(dir.path());
cache.store(&doc("o/r", 7, ItemKind::Issue));
assert!(cache.lookup(&item_ref("o/r", 7), |_| true).is_none());
}
#[test]
fn remove_entry_logs_and_ignores_a_non_not_found_failure() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("looks_like_an_entry");
std::fs::create_dir(&path).unwrap();
remove_entry(&path);
assert!(path.exists(), "a directory can't be removed as a file");
}
#[test]
fn env_ttl_defaults_parses_and_ignores_garbage() {
let base = Some(PathBuf::from("/cache"));
let ttl =
|env: &MapEnv| IssueCache::from_env_with(env, base.clone(), Some("acct"), false).ttl;
assert_eq!(ttl(&MapEnv::new()), DEFAULT_TTL);
assert_eq!(
ttl(&MapEnv::new().with(GITHUB_CACHE_TTL_ENV, "60")),
Duration::from_secs(60)
);
assert_eq!(
ttl(&MapEnv::new().with(GITHUB_CACHE_TTL_ENV, "soon")),
DEFAULT_TTL
);
}
#[test]
fn env_ttl_zero_or_no_cache_dir_disables_the_cache() {
let off = IssueCache::from_env_with(
&MapEnv::new().with(GITHUB_CACHE_TTL_ENV, "0"),
Some(PathBuf::from("/cache")),
Some("acct"),
false,
);
assert!(off.dir.is_none());
assert!(
IssueCache::from_env_with(&MapEnv::new(), None, Some("acct"), false)
.dir
.is_none()
);
}
#[test]
fn no_usable_gh_login_disables_the_cache_but_keeps_the_root_to_sweep() {
let base = Some(PathBuf::from("/cache"));
for account in [None, Some(""), Some(".."), Some("a/b")] {
let cache = IssueCache::from_env_with(&MapEnv::new(), base.clone(), account, false);
assert!(cache.dir.is_none(), "{account:?}");
assert!(cache.root.is_some(), "{account:?}");
}
}
#[test]
fn the_cache_lives_under_omni_dev_github_issues() {
let cache = IssueCache::from_env_with(
&MapEnv::new(),
Some(PathBuf::from("/c")),
Some("0123abcd"),
false,
);
assert_eq!(
cache.entry_path("o/r", 7).unwrap(),
Path::new("/c/omni-dev/github-issues/0123abcd/o/r/7.json")
);
}
#[test]
fn a_different_gh_login_never_reads_an_entry_it_could_not_have_fetched() {
let base = tempfile::tempdir().unwrap();
let open = |account: &str| {
IssueCache::from_env_with(
&MapEnv::new(),
Some(base.path().to_path_buf()),
Some(account),
false,
)
};
open(&account_scope("token-a")).store(&doc("o/r", 7, ItemKind::Issue));
let item = item_ref("o/r", 7);
assert!(open(&account_scope("token-a"))
.lookup(&item, |_| true)
.is_some());
assert!(open(&account_scope("token-b"))
.lookup(&item, |_| true)
.is_none());
}
#[test]
fn the_account_scope_is_a_stable_path_safe_digest_that_hides_the_token() {
let scope = account_scope("gho_secret");
assert_eq!(scope, account_scope("gho_secret"));
assert_ne!(scope, account_scope("gho_other"));
assert_eq!(scope.len(), 16);
assert!(
is_safe_segment(&scope) && !scope.contains("secret"),
"{scope}"
);
}
#[test]
fn owner_and_repo_case_share_one_entry_and_the_callers_spelling_wins() {
let dir = tempfile::tempdir().unwrap();
let cache = cache(dir.path());
cache.store(&doc("Rust-Works/Omni-Dev", 1, ItemKind::Issue));
for spelling in [
"Rust-Works/Omni-Dev",
"rust-works/omni-dev",
"RUST-WORKS/omni-dev",
] {
let served = cache.lookup(&item_ref(spelling, 1), |_| true).unwrap();
assert_eq!(served.project, spelling);
}
assert_eq!(std::fs::read_dir(dir.path()).unwrap().count(), 1);
}
#[test]
fn usage_reports_the_count_and_oldest_age_for_machine_readable_output() {
let dir = tempfile::tempdir().unwrap();
assert_eq!(cache(dir.path()).usage(), None);
cache(dir.path()).store(&doc("o/r", 1, ItemKind::Issue));
cache(dir.path()).store(&doc("o/r", 2, ItemKind::Issue));
backdate(dir.path(), "o/r", 2, 150);
let cache = cache(dir.path());
assert!(cache.lookup(&item_ref("o/r", 1), |_| true).is_some());
assert!(cache.lookup(&item_ref("o/r", 2), |_| true).is_some());
let usage = cache.usage().unwrap();
assert_eq!(usage.items_reused, 2);
assert!((150..=152).contains(&usage.oldest_age_secs), "{usage:?}");
}
#[test]
fn a_poisoned_verified_set_marks_nothing_and_never_panics() {
let dir = tempfile::tempdir().unwrap();
let cache = cache(dir.path());
std::thread::scope(|scope| {
let poisoner = scope.spawn(|| {
let _held = cache.verified.lock().unwrap();
panic!("poison the verified set");
});
assert!(poisoner.join().is_err());
});
cache.mark_state_verified(&[item_ref("o/r", 1)]);
assert!(!cache.state_verified("o/r", 1));
}
#[test]
fn ages_format_in_seconds_then_minutes() {
assert_eq!(format_age(0), "0s");
assert_eq!(format_age(119), "119s");
assert_eq!(format_age(120), "2m");
assert_eq!(format_age(299), "4m");
}
}