use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};
use chrono::{DateTime, Local};
use serde::{Deserialize, Serialize};
use crate::domain::error::{Error, Result};
use crate::domain::repo::GitInfo;
use crate::util::fs::write_atomic;
#[derive(Debug, Default, Serialize, Deserialize)]
struct CacheDoc {
generated_at: Option<String>,
fetched_at: Option<String>,
#[serde(default)]
entries: Vec<CacheEntry>,
}
#[derive(Debug, Serialize, Deserialize)]
struct CacheEntry {
path: String,
valid: bool,
error: Option<String>,
current_branch_name: Option<String>,
changes: Option<u32>,
ahead: Option<u32>,
behind: Option<u32>,
github_repo_name: Option<String>,
}
#[derive(Debug, Default)]
pub struct GitInfoCache {
pub generated_at: Option<DateTime<Local>>,
pub fetched_at: Option<DateTime<Local>>,
pub infos: HashMap<PathBuf, GitInfo>,
}
pub fn load(path: &Path) -> GitInfoCache {
let Ok(text) = fs::read_to_string(path) else {
return GitInfoCache::default();
};
let Ok(doc) = toml::from_str::<CacheDoc>(&text) else {
return GitInfoCache::default();
};
let generated_at = doc.generated_at.as_deref().and_then(parse_timestamp);
let fetched_at = doc.fetched_at.as_deref().and_then(parse_timestamp);
let infos = doc
.entries
.into_iter()
.map(|entry| (PathBuf::from(&entry.path), entry.into_info()))
.collect();
GitInfoCache {
generated_at,
fetched_at,
infos,
}
}
pub fn save(
path: &Path,
infos: &[(PathBuf, GitInfo)],
fetched_at: Option<DateTime<Local>>,
) -> Result<()> {
let doc = CacheDoc {
generated_at: Some(Local::now().to_rfc3339()),
fetched_at: fetched_at.map(|at| at.to_rfc3339()),
entries: infos
.iter()
.map(|(path, info)| CacheEntry::from_info(path, info))
.collect(),
};
let text = toml::to_string_pretty(&doc)
.map_err(|e| Error::invalid(format!("serialise cache: {e}")))?;
write_atomic(path, &text, "git-info cache")
}
fn parse_timestamp(text: &str) -> Option<DateTime<Local>> {
DateTime::parse_from_rfc3339(text)
.ok()
.map(|dt| dt.with_timezone(&Local))
}
impl CacheEntry {
fn from_info(path: &Path, info: &GitInfo) -> Self {
CacheEntry {
path: path.to_string_lossy().into_owned(),
valid: info.valid,
error: info.error.clone(),
current_branch_name: info.current_branch_name.clone(),
changes: info.changes,
ahead: info.ahead,
behind: info.behind,
github_repo_name: info.github_repo_name.clone(),
}
}
fn into_info(self) -> GitInfo {
GitInfo {
valid: self.valid,
error: self.error,
current_branch_name: self.current_branch_name,
changes: self.changes,
ahead: self.ahead,
behind: self.behind,
github_repo_name: self.github_repo_name,
raw_status: None,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn save_then_load_round_trips() {
let dir = std::env::temp_dir()
.join(format!("hop-cache-test-{}", std::process::id()));
let file = dir.join("cache.toml");
let info = GitInfo {
valid: true,
current_branch_name: Some("main".to_string()),
changes: Some(3),
ahead: Some(1),
behind: Some(0),
github_repo_name: Some("owner/repo".to_string()),
..GitInfo::default()
};
let path = PathBuf::from("/code/hop");
let fetched = Local::now();
save(&file, &[(path.clone(), info.clone())], Some(fetched)).unwrap();
let loaded = load(&file);
assert!(loaded.generated_at.is_some());
assert!(loaded.fetched_at.is_some());
assert_eq!(loaded.infos.get(&path), Some(&info));
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn missing_file_yields_empty_cache() {
let loaded = load(Path::new("/nonexistent/hop-cache.toml"));
assert!(loaded.infos.is_empty());
assert!(loaded.generated_at.is_none());
}
}