use std::fs;
use std::path::Path;
use std::time::{SystemTime, UNIX_EPOCH};
use serde::{Deserialize, Serialize};
pub const WINDOW_SECS: u64 = 2 * 60 * 60;
pub const MAX_ENTRIES: usize = 200;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct DroppedEntry {
pub memory_id: String,
pub dropped_at: u64,
}
#[derive(Debug, Default, Serialize, Deserialize)]
pub struct DroppedCapsuleState {
#[serde(default)]
pub entries: Vec<DroppedEntry>,
}
pub fn now_secs() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
pub fn sidecar_path(cache_dir: &Path) -> std::path::PathBuf {
cache_dir.join("dropped-recent.json")
}
pub fn prune_window(
entries: Vec<DroppedEntry>,
now_secs: u64,
window_secs: u64,
) -> Vec<DroppedEntry> {
let cutoff = now_secs.saturating_sub(window_secs);
let mut pruned: Vec<DroppedEntry> = entries
.into_iter()
.filter(|e| e.dropped_at >= cutoff)
.collect();
if pruned.len() > MAX_ENTRIES {
let excess = pruned.len() - MAX_ENTRIES;
pruned.drain(0..excess);
}
pruned
}
pub fn match_and_remove(entries: &mut Vec<DroppedEntry>, memory_id: &str) -> Option<DroppedEntry> {
entries
.iter()
.position(|e| e.memory_id == memory_id)
.map(|pos| entries.remove(pos))
}
pub fn load(path: &Path) -> DroppedCapsuleState {
fs::read_to_string(path)
.ok()
.and_then(|text| serde_json::from_str(&text).ok())
.unwrap_or_default()
}
fn atomic_write_json<T: serde::Serialize>(path: &Path, value: &T) {
let Some(parent) = path.parent() else {
return;
};
let _ = fs::create_dir_all(parent);
let Ok(text) = serde_json::to_string(value) else {
return;
};
let tmp_path = path.with_extension("tmp");
if fs::write(&tmp_path, &text).is_ok() {
let _ = fs::rename(&tmp_path, path);
}
}
pub fn save(path: &Path, state: &DroppedCapsuleState) {
atomic_write_json(path, state);
}
pub fn append_dropped(cache_dir: &Path, memory_ids: impl Iterator<Item = String>, dropped_at: u64) {
let path = sidecar_path(cache_dir);
let mut state = load(&path);
for id in memory_ids {
state.entries.push(DroppedEntry {
memory_id: id,
dropped_at,
});
}
state.entries = prune_window(state.entries, dropped_at, WINDOW_SECS);
save(&path, &state);
}
pub fn take_if_dropped(cache_dir: &Path, memory_id: &str, now: u64) -> Option<DroppedEntry> {
let path = sidecar_path(cache_dir);
let mut state = load(&path);
state.entries = prune_window(state.entries, now, WINDOW_SECS);
let found = match_and_remove(&mut state.entries, memory_id);
if found.is_some() {
save(&path, &state);
}
found
}
#[cfg(test)]
mod tests {
use super::*;
fn entry(id: &str, dropped_at: u64) -> DroppedEntry {
DroppedEntry {
memory_id: id.to_string(),
dropped_at,
}
}
#[test]
fn prune_window_removes_old_entries() {
let now = 1_000_000u64;
let entries = vec![
entry("old", now - WINDOW_SECS - 1), entry("fresh", now - 60), ];
let pruned = prune_window(entries, now, WINDOW_SECS);
assert_eq!(pruned.len(), 1);
assert_eq!(pruned[0].memory_id, "fresh");
}
#[test]
fn prune_window_caps_at_max_entries() {
let now = 1_000_000u64;
let entries: Vec<DroppedEntry> = (0..=MAX_ENTRIES)
.map(|i| entry(&format!("m{i}"), now - 10))
.collect();
let pruned = prune_window(entries, now, WINDOW_SECS);
assert_eq!(pruned.len(), MAX_ENTRIES);
assert!(!pruned.iter().any(|e| e.memory_id == "m0"));
}
#[test]
fn prune_window_keeps_boundary_entry() {
let now = 1_000_000u64;
let entries = vec![
entry("boundary", now - WINDOW_SECS), entry("outside", now - WINDOW_SECS - 1), ];
let pruned = prune_window(entries, now, WINDOW_SECS);
assert_eq!(pruned.len(), 1);
assert_eq!(pruned[0].memory_id, "boundary");
}
#[test]
fn match_and_remove_finds_and_removes() {
let mut entries = vec![entry("a", 100), entry("b", 200), entry("c", 300)];
let found = match_and_remove(&mut entries, "b");
assert!(found.is_some());
assert_eq!(found.unwrap().memory_id, "b");
assert_eq!(entries.len(), 2);
assert!(!entries.iter().any(|e| e.memory_id == "b"));
}
#[test]
fn match_and_remove_returns_none_when_absent() {
let mut entries = vec![entry("a", 100)];
assert!(match_and_remove(&mut entries, "missing").is_none());
assert_eq!(entries.len(), 1);
}
#[test]
fn match_and_remove_on_empty_is_safe() {
let mut entries: Vec<DroppedEntry> = vec![];
assert!(match_and_remove(&mut entries, "x").is_none());
}
#[test]
fn prune_window_empty_input_is_safe() {
let pruned = prune_window(vec![], 1_000_000, WINDOW_SECS);
assert!(pruned.is_empty());
}
#[test]
fn save_is_atomic_no_tmp_leftover() {
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.subsec_nanos())
.unwrap_or(0);
let cache_dir = std::env::temp_dir().join(format!("kimetsu-atomic-dc-test-{nanos}"));
std::fs::create_dir_all(&cache_dir).expect("mkdir");
let path = sidecar_path(&cache_dir);
let state = DroppedCapsuleState {
entries: vec![entry("mem-atomic", 999_999)],
};
save(&path, &state);
let loaded = load(&path);
assert_eq!(
loaded.entries.len(),
1,
"saved state must be loadable after atomic write"
);
assert_eq!(loaded.entries[0].memory_id, "mem-atomic");
let tmp_path = path.with_extension("tmp");
assert!(
!tmp_path.exists(),
".tmp sibling must not remain after atomic save"
);
let _ = std::fs::remove_dir_all(&cache_dir);
}
}