use std::collections::HashSet;
use std::path::PathBuf;
#[derive(Debug, Default)]
pub struct GuideLedger {
path: Option<PathBuf>,
emitted: HashSet<String>,
}
impl GuideLedger {
pub fn load(session_id: &str, dir: Option<PathBuf>) -> Self {
let path = dir.map(|d| d.join(format!("{}.json", sanitize(session_id))));
let emitted = path
.as_ref()
.and_then(|p| std::fs::read_to_string(p).ok())
.and_then(|s| serde_json::from_str::<Vec<String>>(&s).ok())
.map(|v| v.into_iter().collect())
.unwrap_or_default();
Self { path, emitted }
}
pub fn contains(&self, topic: &str) -> bool {
self.emitted.contains(topic)
}
pub fn insert(&mut self, topic: String) -> bool {
let added = self.emitted.insert(topic);
if added {
self.persist();
}
added
}
pub fn clear(&mut self) {
let was_nonempty = !self.emitted.is_empty();
self.emitted.clear();
if was_nonempty {
self.persist();
}
}
fn persist(&self) {
let Some(path) = &self.path else { return };
if self.emitted.is_empty() {
let _ = std::fs::remove_file(path);
return;
}
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
let topics: Vec<&String> = self.emitted.iter().collect();
match serde_json::to_string(&topics) {
Ok(json) => {
if let Err(e) = std::fs::write(path, json) {
tracing::debug!("guide ledger persist failed ({}): {e}", path.display());
}
}
Err(e) => tracing::debug!("guide ledger serialize failed: {e}"),
}
}
}
fn sanitize(s: &str) -> String {
s.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
c
} else {
'_'
}
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
#[test]
fn ledger_survives_reload_and_isolates_sessions() {
let dir = tempdir().unwrap();
let hints_dir = dir.path().join(".codescout").join("guide_hints");
let mut l = GuideLedger::load("sess-A", Some(hints_dir.clone()));
assert!(!l.contains("librarian"));
assert!(l.insert("librarian".to_string()), "first insert is new");
assert!(
!l.insert("librarian".to_string()),
"second insert is a no-op"
);
drop(l);
let l2 = GuideLedger::load("sess-A", Some(hints_dir.clone()));
assert!(
l2.contains("librarian"),
"ledger must survive reconstruction (the bug)"
);
let l3 = GuideLedger::load("sess-B", Some(hints_dir.clone()));
assert!(!l3.contains("librarian"), "sessions must be isolated");
let mut l4 = GuideLedger::load("sess-A", Some(hints_dir.clone()));
l4.clear();
drop(l4);
let l5 = GuideLedger::load("sess-A", Some(hints_dir));
assert!(!l5.contains("librarian"), "clear must persist");
}
#[test]
fn ephemeral_ledger_is_in_memory_only() {
let mut l = GuideLedger::default();
assert!(l.insert("x".to_string()));
assert!(l.contains("x"));
l.clear();
assert!(!l.contains("x"));
}
}