use std::io::Write;
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
use anyhow::{Context, Result, bail};
use serde::{Deserialize, Serialize};
use crate::gh::GhSnapshot;
use crate::git_status::GitStatusSnapshot;
use crate::paths::ShokaPaths;
pub const CACHE_VERSION: u32 = 4;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Cache {
#[serde(default = "default_version")]
pub version: u32,
#[serde(default)]
pub repos: Vec<RepoCache>,
}
fn default_version() -> u32 {
CACHE_VERSION
}
impl Default for Cache {
fn default() -> Self {
Self {
version: CACHE_VERSION,
repos: Vec::new(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RepoCache {
pub host: String,
pub owner: String,
pub name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub path: Option<PathBuf>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub last_refreshed: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub git_status: Option<GitStatusSnapshot>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub gh: Option<GhSnapshot>,
}
impl RepoCache {
pub fn new(host: impl Into<String>, owner: impl Into<String>, name: impl Into<String>) -> Self {
Self {
host: host.into(),
owner: owner.into(),
name: name.into(),
path: None,
last_refreshed: None,
git_status: None,
gh: None,
}
}
pub fn with_path(
host: impl Into<String>,
owner: impl Into<String>,
name: impl Into<String>,
path: Option<PathBuf>,
) -> Self {
Self {
host: host.into(),
owner: owner.into(),
name: name.into(),
path,
last_refreshed: None,
git_status: None,
gh: None,
}
}
pub fn slug(&self) -> String {
format!("{}/{}/{}", self.host, self.owner, self.name)
}
pub fn is_stale(&self, threshold_secs: u64, now: u64) -> bool {
match self.last_refreshed {
None => true,
Some(ts) => now.saturating_sub(ts) > threshold_secs,
}
}
}
pub fn current_unix_secs() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
impl Cache {
pub fn load(paths: &ShokaPaths) -> Result<Self> {
Self::load_from(paths.cache_file().as_path())
}
pub fn load_from(path: &Path) -> Result<Self> {
let raw = match std::fs::read_to_string(path) {
Ok(s) => s,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Self::default()),
Err(e) => {
return Err(e).with_context(|| format!("reading cache from {}", path.display()));
}
};
let cache: Cache =
toml::from_str(&raw).with_context(|| format!("parsing cache at {}", path.display()))?;
if cache.version > CACHE_VERSION {
bail!(
"cache at {} has schema version {}, newer than this build's {} — upgrade shoka",
path.display(),
cache.version,
CACHE_VERSION
);
}
Ok(cache)
}
pub fn save(&self, paths: &ShokaPaths) -> Result<()> {
self.save_to(paths.cache_file().as_path())
}
pub fn save_to(&self, path: &Path) -> Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("creating cache dir {}", parent.display()))?;
}
let body = toml::to_string_pretty(self).context("serialising cache to TOML")?;
let tmp = path.with_extension(format!("toml.{}.tmp", std::process::id()));
{
let mut f = std::fs::File::create(&tmp)
.with_context(|| format!("creating temp cache file {}", tmp.display()))?;
f.write_all(body.as_bytes())
.with_context(|| format!("writing temp cache file {}", tmp.display()))?;
f.sync_all()
.with_context(|| format!("syncing temp cache file {}", tmp.display()))?;
}
std::fs::rename(&tmp, path)
.with_context(|| format!("renaming {} -> {}", tmp.display(), path.display()))?;
Ok(())
}
pub fn find(
&self,
host: &str,
owner: &str,
name: &str,
path: Option<&Path>,
) -> Option<&RepoCache> {
self.repos.iter().find(|r| {
r.host == host && r.owner == owner && r.name == name && r.path.as_deref() == path
})
}
pub fn find_mut(
&mut self,
host: &str,
owner: &str,
name: &str,
path: Option<&Path>,
) -> Option<&mut RepoCache> {
self.repos.iter_mut().find(|r| {
r.host == host && r.owner == owner && r.name == name && r.path.as_deref() == path
})
}
pub fn find_any_by_triple(&self, host: &str, owner: &str, name: &str) -> Option<&RepoCache> {
self.repos
.iter()
.find(|r| r.host == host && r.owner == owner && r.name == name)
}
pub fn find_gh_by_triple(&self, host: &str, owner: &str, name: &str) -> Option<&GhSnapshot> {
self.repos
.iter()
.filter(|r| r.host == host && r.owner == owner && r.name == name)
.find_map(|r| r.gh.as_ref())
}
pub fn upsert(&mut self, repo: &crate::state::Repo) -> &mut RepoCache {
if let Some(i) = self.repos.iter().position(|r| {
r.host == repo.host
&& r.owner == repo.owner
&& r.name == repo.name
&& r.path.as_deref() == repo.path.as_deref()
}) {
return &mut self.repos[i];
}
if repo.path.is_some() {
if let Some(i) = self.repos.iter().position(|r| {
r.host == repo.host
&& r.owner == repo.owner
&& r.name == repo.name
&& r.path.is_none()
}) {
self.repos[i].path = repo.path.clone();
return &mut self.repos[i];
}
}
self.repos.push(RepoCache::with_path(
&repo.host,
&repo.owner,
&repo.name,
repo.path.clone(),
));
self.repos.last_mut().expect("just pushed")
}
pub fn remove(
&mut self,
host: &str,
owner: &str,
name: &str,
path: Option<&Path>,
) -> Option<RepoCache> {
let pos = self.repos.iter().position(|r| {
r.host == host && r.owner == owner && r.name == name && r.path.as_deref() == path
})?;
Some(self.repos.remove(pos))
}
pub fn len(&self) -> usize {
self.repos.len()
}
pub fn is_empty(&self) -> bool {
self.repos.is_empty()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::state::Repo;
use std::fs;
use tempfile::TempDir;
fn sample(name: &str) -> Repo {
Repo::new("github.com", "yukimemi", name)
}
#[test]
fn missing_file_yields_empty_cache() {
let tmp = TempDir::new().unwrap();
let target = tmp.path().join("cache.toml");
assert!(!target.exists());
let c = Cache::load_from(&target).expect("missing -> default");
assert_eq!(c.version, CACHE_VERSION);
assert!(c.repos.is_empty());
}
#[test]
fn save_then_load_round_trip() {
let tmp = TempDir::new().unwrap();
let target = tmp.path().join("nested").join("cache.toml");
let mut c = Cache::default();
c.upsert(&sample("shoka")).last_refreshed = Some(1_700_000_000);
c.upsert(&sample("renri")); c.save_to(&target).unwrap();
assert!(target.exists());
let loaded = Cache::load_from(&target).unwrap();
assert_eq!(loaded.repos.len(), 2);
assert_eq!(
loaded
.find("github.com", "yukimemi", "shoka", None)
.unwrap()
.last_refreshed,
Some(1_700_000_000)
);
assert!(
loaded
.find("github.com", "yukimemi", "renri", None)
.unwrap()
.last_refreshed
.is_none()
);
}
#[test]
fn save_uses_pid_suffixed_temp_file() {
let tmp = TempDir::new().unwrap();
let target = tmp.path().join("cache.toml");
let mut c = Cache::default();
c.upsert(&sample("shoka"));
c.save_to(&target).unwrap();
let leftover: Vec<_> = fs::read_dir(tmp.path())
.unwrap()
.filter_map(|e| e.ok())
.filter(|e| e.path().extension().and_then(|s| s.to_str()) == Some("tmp"))
.map(|e| e.file_name())
.collect();
assert!(
leftover.is_empty(),
"no .tmp siblings after rename, got: {leftover:?}"
);
assert!(target.exists());
}
#[test]
fn future_version_fails_load() {
let tmp = TempDir::new().unwrap();
let target = tmp.path().join("cache.toml");
fs::write(&target, format!("version = {}\n", CACHE_VERSION + 1)).unwrap();
let err = Cache::load_from(&target).unwrap_err();
assert!(
err.to_string().contains("newer"),
"error should mention newer schema: {err}"
);
}
#[test]
fn upsert_creates_then_updates() {
let mut c = Cache::default();
let r = sample("shoka");
let entry1 = c.upsert(&r);
entry1.last_refreshed = Some(100);
assert_eq!(c.len(), 1);
let entry2 = c.upsert(&r);
assert_eq!(entry2.last_refreshed, Some(100));
entry2.last_refreshed = Some(200);
assert_eq!(c.len(), 1);
assert_eq!(
c.find("github.com", "yukimemi", "shoka", None)
.unwrap()
.last_refreshed,
Some(200)
);
}
#[test]
fn is_stale_treats_unrefreshed_as_stale() {
let r = RepoCache::new("github.com", "u", "n");
assert!(r.is_stale(60, 1_700_000_000));
}
#[test]
fn is_stale_within_threshold_is_fresh() {
let mut r = RepoCache::new("github.com", "u", "n");
r.last_refreshed = Some(1_700_000_000);
assert!(!r.is_stale(60, 1_700_000_000 + 30)); assert!(!r.is_stale(60, 1_700_000_000 + 60)); }
#[test]
fn is_stale_beyond_threshold_is_stale() {
let mut r = RepoCache::new("github.com", "u", "n");
r.last_refreshed = Some(1_700_000_000);
assert!(r.is_stale(60, 1_700_000_000 + 61));
assert!(r.is_stale(60, 1_700_000_000 + 3600));
}
#[test]
fn is_stale_handles_clock_skew_safely() {
let mut r = RepoCache::new("github.com", "u", "n");
r.last_refreshed = Some(1_700_000_100);
assert!(!r.is_stale(60, 1_700_000_000));
}
#[test]
fn remove_returns_entry_and_shrinks_cache() {
let mut c = Cache::default();
c.upsert(&sample("shoka"));
c.upsert(&sample("renri"));
let removed = c.remove("github.com", "yukimemi", "shoka", None).unwrap();
assert_eq!(removed.name, "shoka");
assert_eq!(c.len(), 1);
assert!(c.remove("github.com", "yukimemi", "ghost", None).is_none());
}
fn pinned(name: &str, path: &str) -> Repo {
Repo::new("github.com", "yukimemi", name).with_path(PathBuf::from(path))
}
#[test]
fn path_aware_round_trip_keeps_two_clones_independent() {
let tmp = TempDir::new().unwrap();
let target = tmp.path().join("cache.toml");
let mut c = Cache::default();
c.upsert(&pinned("shoka", "/home/u/a/shoka")).last_refreshed = Some(100);
c.upsert(&pinned("shoka", "/home/u/b/shoka")).last_refreshed = Some(200);
assert_eq!(c.len(), 2);
c.save_to(&target).unwrap();
let loaded = Cache::load_from(&target).unwrap();
assert_eq!(loaded.len(), 2);
let a = loaded
.find(
"github.com",
"yukimemi",
"shoka",
Some(Path::new("/home/u/a/shoka")),
)
.expect("path-a entry survives round trip");
let b = loaded
.find(
"github.com",
"yukimemi",
"shoka",
Some(Path::new("/home/u/b/shoka")),
)
.expect("path-b entry survives round trip");
assert_eq!(a.last_refreshed, Some(100));
assert_eq!(b.last_refreshed, Some(200));
}
#[test]
fn find_with_mismatched_path_returns_none() {
let mut c = Cache::default();
c.upsert(&pinned("shoka", "/home/u/a/shoka"));
assert!(
c.find("github.com", "yukimemi", "shoka", None).is_none(),
"triple-less query must not match a path-pinned entry"
);
assert!(
c.find(
"github.com",
"yukimemi",
"shoka",
Some(Path::new("/home/u/b/shoka")),
)
.is_none(),
"wrong-path query must not match a different-path entry"
);
}
#[test]
fn find_any_by_triple_returns_first_match_regardless_of_path() {
let mut c = Cache::default();
c.upsert(&pinned("shoka", "/home/u/a/shoka")).gh = None;
c.upsert(&pinned("shoka", "/home/u/b/shoka")).gh = None;
assert!(
c.find_any_by_triple("github.com", "yukimemi", "shoka")
.is_some()
);
assert!(
c.find_any_by_triple("github.com", "yukimemi", "ghost")
.is_none()
);
}
#[test]
fn find_gh_by_triple_prefers_populated_sibling_over_unpopulated_first_row() {
use crate::gh::{CiStatus, GhSnapshot};
let populated = GhSnapshot {
open_pr_count: Some(3),
ci_status: Some(CiStatus::Success),
weekly_commits: None,
};
let mut c = Cache::default();
c.upsert(&pinned("shoka", "/home/u/a/shoka")).gh = None;
c.upsert(&pinned("shoka", "/home/u/b/shoka")).gh = Some(populated.clone());
let found = c
.find_gh_by_triple("github.com", "yukimemi", "shoka")
.expect("populated sibling must be reachable");
assert_eq!(found, &populated);
}
#[test]
fn find_gh_by_triple_returns_none_when_every_sibling_is_unpopulated() {
let mut c = Cache::default();
c.upsert(&pinned("shoka", "/home/u/a/shoka")).gh = None;
c.upsert(&pinned("shoka", "/home/u/b/shoka")).gh = None;
assert!(
c.find_gh_by_triple("github.com", "yukimemi", "shoka")
.is_none()
);
}
#[test]
fn upsert_promotes_legacy_path_less_entry_to_path_pinned() {
let mut c = Cache::default();
c.repos
.push(RepoCache::new("github.com", "yukimemi", "shoka"));
c.repos[0].last_refreshed = Some(500);
let entry = c.upsert(&pinned("shoka", "/home/u/a/shoka"));
entry.last_refreshed = Some(1000);
assert_eq!(c.len(), 1, "promotion must reuse the legacy row");
assert_eq!(
c.repos[0].path.as_deref(),
Some(Path::new("/home/u/a/shoka"))
);
assert_eq!(c.repos[0].last_refreshed, Some(1000));
}
#[test]
fn upsert_creates_distinct_rows_for_two_clones_of_same_remote() {
let mut c = Cache::default();
c.upsert(&pinned("shoka", "/home/u/a/shoka")).last_refreshed = Some(100);
c.upsert(&pinned("shoka", "/home/u/b/shoka")).last_refreshed = Some(200);
assert_eq!(c.len(), 2, "different paths -> different rows");
assert_eq!(
c.find(
"github.com",
"yukimemi",
"shoka",
Some(Path::new("/home/u/a/shoka")),
)
.unwrap()
.last_refreshed,
Some(100)
);
assert_eq!(
c.find(
"github.com",
"yukimemi",
"shoka",
Some(Path::new("/home/u/b/shoka")),
)
.unwrap()
.last_refreshed,
Some(200)
);
}
#[test]
fn remove_strict_path_does_not_take_siblings() {
let mut c = Cache::default();
c.upsert(&pinned("shoka", "/home/u/a/shoka"));
c.upsert(&pinned("shoka", "/home/u/b/shoka"));
let removed = c
.remove(
"github.com",
"yukimemi",
"shoka",
Some(Path::new("/home/u/a/shoka")),
)
.expect("targeted row removed");
assert_eq!(removed.path.as_deref(), Some(Path::new("/home/u/a/shoka")));
assert_eq!(c.len(), 1);
assert!(
c.find(
"github.com",
"yukimemi",
"shoka",
Some(Path::new("/home/u/b/shoka")),
)
.is_some(),
"sibling clone must survive"
);
}
#[test]
fn version_4_cache_file_loads_without_path_field_on_legacy_rows() {
let tmp = TempDir::new().unwrap();
let target = tmp.path().join("cache.toml");
let v3 = "version = 3\n\n[[repos]]\nhost = \"github.com\"\nowner = \"yukimemi\"\nname = \"shoka\"\nlast_refreshed = 100\n";
fs::write(&target, v3).unwrap();
let loaded = Cache::load_from(&target).expect("legacy v3-shape row loads");
assert_eq!(loaded.version, 3, "version field round-trips as v3");
assert_eq!(loaded.len(), 1);
assert_eq!(loaded.repos[0].path, None);
}
}