use serde::{Serialize, de::DeserializeOwned};
use std::collections::hash_map::DefaultHasher;
use std::fs::{self, File};
use std::hash::{Hash, Hasher};
use std::io::Write;
use std::path::{Path, PathBuf};
use std::time::{Duration, SystemTime};
const PRUNABLE_KINDS: [&str; 5] = ["spend", "cache", "idle", "month", "pr"];
const MAX_AGE: Duration = Duration::from_secs(30 * 24 * 60 * 60);
const PRUNE_INTERVAL: Duration = Duration::from_secs(24 * 60 * 60);
const PRUNE_MARKER: &str = "last_prune";
pub fn base_dir() -> PathBuf {
dirs::cache_dir()
.unwrap_or_else(std::env::temp_dir)
.join("copilot-powerline")
}
pub fn ensure_private_dir(dir: &Path) -> std::io::Result<()> {
fs::create_dir_all(dir)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = fs::set_permissions(dir, fs::Permissions::from_mode(0o700));
}
Ok(())
}
pub fn write_private_atomic(path: &Path, contents: &[u8]) -> std::io::Result<()> {
if let Some(parent) = path.parent() {
ensure_private_dir(parent)?;
}
let tmp_path = path.with_extension(format!("tmp.{}", std::process::id()));
{
let mut file = File::create(&tmp_path)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
file.set_permissions(fs::Permissions::from_mode(0o600))?;
}
file.write_all(contents)?;
file.flush()?;
}
fs::rename(&tmp_path, path)
}
pub fn session_hash(session_id: &str) -> String {
let mut hasher = DefaultHasher::new();
session_id.hash(&mut hasher);
format!("{:016x}", hasher.finish())
}
pub fn session_file(kind: &str, session_id: &str) -> PathBuf {
debug_assert!(
PRUNABLE_KINDS.contains(&kind),
"unknown state kind {kind:?}: add it to PRUNABLE_KINDS or it is never pruned"
);
base_dir().join(format!("{kind}_{}.json", session_hash(session_id)))
}
pub fn update_snapshot<T>(path: &Path, assess: impl FnOnce(Option<T>) -> T) -> T
where
T: Serialize + DeserializeOwned + PartialEq + Copy,
{
let prev = fs::read_to_string(path)
.ok()
.and_then(|content| serde_json::from_str(&content).ok());
let next = assess(prev);
if prev != Some(next)
&& let Ok(json) = serde_json::to_string(&next)
{
let _ = write_private_atomic(path, json.as_bytes());
}
next
}
fn is_prunable_name(name: &str) -> bool {
PRUNABLE_KINDS.iter().any(|kind| {
name.strip_prefix(kind)
.and_then(|rest| rest.strip_prefix('_'))
.and_then(|rest| rest.split_at_checked(16))
.is_some_and(|(hash, suffix)| {
hash.bytes().all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f'))
&& (matches!(suffix, ".json" | ".lock")
|| suffix.strip_prefix(".tmp.").is_some_and(|pid| {
!pid.is_empty() && pid.bytes().all(|b| b.is_ascii_digit())
}))
})
})
}
fn prune_stale(dir: &Path, now: SystemTime) {
let Ok(entries) = fs::read_dir(dir) else {
return;
};
for entry in entries.flatten() {
if !entry.file_name().to_str().is_some_and(is_prunable_name) {
continue;
}
let is_stale = entry
.metadata()
.ok()
.filter(|metadata| metadata.is_file())
.and_then(|metadata| metadata.modified().ok())
.is_some_and(|modified| now.duration_since(modified).is_ok_and(|age| age > MAX_AGE));
if is_stale {
let _ = fs::remove_file(entry.path());
}
}
}
pub fn prune_if_due(dir: &Path, now: SystemTime) {
let marker = dir.join(PRUNE_MARKER);
let recently_pruned = fs::metadata(&marker)
.and_then(|metadata| metadata.modified())
.is_ok_and(|modified| {
now.duration_since(modified)
.is_ok_and(|age| age < PRUNE_INTERVAL)
});
if recently_pruned {
return;
}
if File::create(&marker)
.and_then(|file| file.set_modified(now))
.is_ok()
{
prune_stale(dir, now);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_session_file_is_private_and_per_session_and_kind() {
let a = session_file("spend", "sess-a");
assert!(a.starts_with(base_dir()));
assert_eq!(a, session_file("spend", "sess-a"));
assert_ne!(a, session_file("spend", "sess-b"));
assert_ne!(a, session_file("cache", "sess-a"));
}
const DAY: Duration = Duration::from_secs(24 * 60 * 60);
fn touch(dir: &Path, name: &str, age: Duration, now: SystemTime) -> PathBuf {
let path = dir.join(name);
let file = File::create(&path).unwrap();
file.set_modified(now - age).unwrap();
path
}
#[test]
fn test_prune_stale_removes_only_old_per_session_files() {
let dir = tempfile::tempdir().unwrap();
let now = SystemTime::now();
let old = 31 * DAY;
let removed: Vec<PathBuf> = [
"spend_0123456789abcdef.json",
"cache_0123456789abcdef.json",
"idle_0123456789abcdef.json",
"month_0123456789abcdef.json",
"pr_0123456789abcdef.json",
"pr_0123456789abcdef.lock",
"spend_0123456789abcdef.tmp.4242",
]
.iter()
.map(|name| touch(dir.path(), name, old, now))
.collect();
let recent = touch(dir.path(), "spend_fedcba9876543210.json", 29 * DAY, now);
let mode = touch(dir.path(), "mode", old, now);
let unrelated = touch(dir.path(), "notes.txt", old, now);
let unseparated = touch(dir.path(), "spendthrift.json", old, now);
prune_stale(dir.path(), now);
assert!(removed.iter().all(|path| !path.exists()));
assert!(recent.exists() && mode.exists() && unrelated.exists() && unseparated.exists());
}
#[test]
fn test_prune_stale_keeps_old_files_with_an_allowed_prefix_but_another_shape() {
let dir = tempfile::tempdir().unwrap();
let now = SystemTime::now();
let kept: Vec<PathBuf> = [
"spend_notes.txt",
"cache_backup",
"idle_0123456789abcdef.json.bak",
"month_0123456789abcdef",
"pr_0123456789abcdef.txt",
"spend_0123.json",
"cache_0123456789ABCDEF.json",
"spend_0123456789abcdef0.json",
"spend_0123456789abcdef.tmp.",
"spend_0123456789abcdef.tmp.pid",
"spend_0123456789abcdé.json",
]
.iter()
.map(|name| touch(dir.path(), name, 31 * DAY, now))
.collect();
prune_stale(dir.path(), now);
for path in &kept {
assert!(path.exists(), "{} was pruned", path.display());
}
}
#[test]
fn test_prune_stale_keeps_files_dated_in_the_future() {
let dir = tempfile::tempdir().unwrap();
let now = SystemTime::now();
let path = dir.path().join("spend_0123456789abcdef.json");
File::create(&path)
.unwrap()
.set_modified(now + 40 * DAY)
.unwrap();
prune_stale(dir.path(), now);
assert!(path.exists());
}
#[test]
fn test_prune_stale_skips_directories_and_tolerates_a_missing_dir() {
let dir = tempfile::tempdir().unwrap();
let now = SystemTime::now();
let subdir = dir.path().join("spend_0123456789abcdef.json");
fs::create_dir(&subdir).unwrap();
File::open(&subdir)
.unwrap()
.set_modified(now - 40 * DAY)
.unwrap();
prune_stale(dir.path(), now);
prune_stale(&dir.path().join("missing"), now);
assert!(subdir.is_dir());
}
#[test]
fn test_prune_if_due_prunes_once_then_waits_for_the_interval() {
let dir = tempfile::tempdir().unwrap();
let now = SystemTime::now();
let first = touch(dir.path(), "spend_aaaaaaaaaaaaaaaa.json", 31 * DAY, now);
prune_if_due(dir.path(), now);
assert!(!first.exists());
assert!(dir.path().join(PRUNE_MARKER).exists());
let second = touch(dir.path(), "spend_bbbbbbbbbbbbbbbb.json", 31 * DAY, now);
prune_if_due(dir.path(), now + DAY / 2);
assert!(second.exists());
prune_if_due(dir.path(), now + DAY + DAY / 2);
assert!(!second.exists());
}
#[test]
fn test_prune_if_due_treats_a_future_marker_as_due_and_resets_it() {
let dir = tempfile::tempdir().unwrap();
let now = SystemTime::now();
touch(dir.path(), PRUNE_MARKER, Duration::ZERO, now + 10 * DAY);
let old = touch(dir.path(), "cache_aaaaaaaaaaaaaaaa.json", 31 * DAY, now);
prune_if_due(dir.path(), now);
assert!(!old.exists());
let next = touch(dir.path(), "cache_bbbbbbbbbbbbbbbb.json", 31 * DAY, now);
prune_if_due(dir.path(), now + DAY / 2);
assert!(next.exists());
}
#[test]
fn test_prune_if_due_skips_the_scan_when_the_marker_cannot_be_written() {
let dir = tempfile::tempdir().unwrap();
let now = SystemTime::now();
fs::create_dir(dir.path().join(PRUNE_MARKER)).unwrap();
let old = touch(dir.path(), "spend_aaaaaaaaaaaaaaaa.json", 31 * DAY, now);
prune_if_due(dir.path(), now);
prune_if_due(&dir.path().join("missing"), now);
assert!(old.exists());
}
#[test]
fn test_every_state_file_kind_is_prunable() {
let names = [
crate::spend::snapshot_path("s"),
crate::cache_trend::snapshot_path("s"),
crate::idle::snapshot_path("s"),
crate::month_spend::snapshot_path("s"),
crate::github::get_cache_file_path(Path::new("repo"), "branch"),
]
.map(|path| path.file_name().unwrap().to_string_lossy().into_owned());
for name in &names {
assert!(is_prunable_name(name), "{name} would never be pruned");
}
assert!(!is_prunable_name(
&crate::mode::override_file_path()
.file_name()
.unwrap()
.to_string_lossy()
));
assert!(!is_prunable_name(PRUNE_MARKER));
}
#[test]
#[cfg(debug_assertions)]
#[should_panic(expected = "unknown state kind")]
fn test_session_file_rejects_a_kind_that_pruning_does_not_know() {
session_file("brand_new", "sess-a");
}
}