//! The tiering listing: identity entries and notes with recall-hit counts (M5d spec, 4.4). use std::collections::{HashMap, HashSet}; use std::io::ErrorKind; use bxcore::{MemoryFile, Timestamp}; use serde::Deserialize; use crate::files::Store; use crate::notes::{self, Kind, Note}; #[derive(Deserialize)] struct Logged { time: String, hits: Vec, } #[derive(Deserialize)] struct Hit { kind: String, session: String, record: i64, } /// How many recall lines named one note, and the latest of those lines' times. #[derive(Default)] struct Tally { n: u64, last: String, } fn fail(path: &std::path::Path, e: std::io::Error) -> String { format!("{}: {e}", path.display()) } fn class_of(note: &Note) -> &'static str { match ¬e.kind { Kind::Noted { class, .. } => notes::class_name(*class), Kind::Moved(_) | Kind::Dropped(_) => "private", } } fn trust_of(note: &Note) -> &'static str { match ¬e.kind { Kind::Noted { untrusted, .. } => { if *untrusted { "untrusted" } else { "trusted" } } Kind::Moved(_) | Kind::Dropped(_) => "trusted", } } fn read_notes(store: &Store) -> Result { let path = store.path("notes.md"); match std::fs::read_to_string(&path) { Ok(text) => Ok(text), Err(e) if e.kind() == ErrorKind::NotFound => Ok(String::new()), Err(e) => Err(fail(&path, e)), } } /// The recall hits per `(session, record)`: how many lines named it and the latest of their times /// (M5d spec, section 4.4). A missing `recalls/` is an empty map; a line that does not parse is /// skipped, and a line counts once for each note it names. fn tally(store: &Store) -> Result, String> { let dir = store.path("recalls"); let files = match std::fs::read_dir(&dir) { Ok(files) => files, Err(e) if e.kind() == ErrorKind::NotFound => return Ok(HashMap::new()), Err(e) => return Err(fail(&dir, e)), }; let mut hits: HashMap<(String, i64), Tally> = HashMap::new(); for file in files { let file = file.map_err(|e| fail(&dir, e))?; if !file.file_type().map_err(|e| fail(&dir, e))?.is_file() { continue; } let path = file.path(); let text = match std::fs::read_to_string(&path) { Ok(text) => text, Err(e) if e.kind() == ErrorKind::NotFound => continue, Err(e) => return Err(fail(&path, e)), }; for line in text.lines() { let logged: Logged = match serde_json::from_str(line) { Ok(logged) => logged, Err(_) => continue, }; let mut seen: HashSet<(String, i64)> = HashSet::new(); for hit in &logged.hits { if hit.kind == "noted" { seen.insert((hit.session.clone(), hit.record)); } } for key in seen { let tally = hits.entry(key).or_default(); tally.n += 1; if logged.time > tally.last { tally.last = logged.time.clone(); } } } } Ok(hits) } /// One listing for `bxctl memory tiers` and for the tiering job's user text. pub fn list(store: &Store, _now: Timestamp) -> Result { let mut out = String::new(); for file in [MemoryFile::Memory, MemoryFile::User] { for entry in store.entries(file)? { out.push_str(&format!( "identity {} hits 0 last -\n {}\n", file.name(), entry )); } } let hits = tally(store)?; for (i, note) in notes::parse(&read_notes(store)?).into_iter().enumerate() { let key = (note.session.clone(), i as i64); let (n, last) = match hits.get(&key) { Some(t) => (t.n, t.last.clone()), None => (0, "-".to_string()), }; out.push_str(&format!( "note session {} hits {} last {} {} {}\n {}\n", note.session, n, last, class_of(¬e), trust_of(¬e), note.text )); } Ok(out) }