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()
}
pub fn save(path: &Path, state: &DroppedCapsuleState) {
if let Some(parent) = path.parent() {
let _ = fs::create_dir_all(parent);
}
if let Ok(text) = serde_json::to_string(state) {
let _ = fs::write(path, text);
}
}
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());
}
}