Skip to main content

bifp_core/
store.rs

1//! Real, native-trit persistence for BIFP flags — a JSONL-backed store where `trit` is a
2//! first-class signed column, not folded into a tags array.
3//!
4//! This does NOT modify or extend RFI-IRFOS's hosted `ternlang-engram` MCP server (its
5//! source isn't available on this machine, and it is a separately published, versioned
6//! package). It is a new, independent store, purpose-built for BIFP, that closes the same
7//! *gap* honestly instead of claiming to patch code this crate has no access to.
8
9use crate::flag::Flag;
10use std::fs::{File, OpenOptions};
11use std::io::{self, BufRead, BufReader, Write};
12use std::path::{Path, PathBuf};
13
14pub struct FlagStore {
15    path: PathBuf,
16}
17
18impl FlagStore {
19    pub fn open(path: impl AsRef<Path>) -> Self {
20        FlagStore { path: path.as_ref().to_path_buf() }
21    }
22
23    /// Append one flag as a JSON line. Never rewrites prior entries — append-only, so a
24    /// revision (`Flag::revises`) is a new line pointing back, not an edit in place.
25    pub fn remember(&self, flag: &Flag) -> io::Result<()> {
26        if let Some(parent) = self.path.parent() {
27            if !parent.as_os_str().is_empty() {
28                std::fs::create_dir_all(parent)?;
29            }
30        }
31        let mut f = OpenOptions::new().create(true).append(true).open(&self.path)?;
32        let line = serde_json::to_string(flag).map_err(io::Error::other)?;
33        writeln!(f, "{line}")
34    }
35
36    /// All flags, in insertion order.
37    pub fn recall_all(&self) -> io::Result<Vec<Flag>> {
38        if !self.path.exists() {
39            return Ok(Vec::new());
40        }
41        let f = File::open(&self.path)?;
42        let mut out = Vec::new();
43        for line in BufReader::new(f).lines() {
44            let line = line?;
45            if line.trim().is_empty() {
46                continue;
47            }
48            if let Ok(flag) = serde_json::from_str::<Flag>(&line) {
49                out.push(flag);
50            }
51        }
52        Ok(out)
53    }
54
55    /// How many affirms (trit=+1) have been recorded for a given note-prefix — the crate's
56    /// own reference implementation of "reconfirmed across multiple recalls" for badge::BadgeContext.
57    pub fn reconfirm_count(&self, note_prefix: &str) -> io::Result<u32> {
58        Ok(self
59            .recall_all()?
60            .iter()
61            .filter(|f| f.trit == 1 && f.note.starts_with(note_prefix))
62            .count() as u32)
63    }
64}
65
66#[cfg(test)]
67mod tests {
68    use super::*;
69    use crate::flag::Source;
70    use crate::trit::Trit;
71
72    #[test]
73    fn remember_creates_missing_parent_directory() {
74        // Regression test: the real MCP server hit this live on 2026-07-02 — the
75        // default path's parent dir (~/.bifp/) didn't exist yet, so `remember` silently
76        // returned an Err and the tool reported `stored: false`.
77        let dir = tempfile::tempdir().unwrap();
78        let nested = dir.path().join("does").join("not").join("exist").join("flags.jsonl");
79        let store = FlagStore::open(&nested);
80        let f = Flag::new(Trit::Affirm, 0.9, "regress", Source::Agent, "t");
81        assert!(store.remember(&f).is_ok());
82        assert_eq!(store.recall_all().unwrap().len(), 1);
83    }
84
85    #[test]
86    fn remember_and_recall_roundtrip() {
87        let dir = tempfile::tempdir().unwrap();
88        let store = FlagStore::open(dir.path().join("flags.jsonl"));
89        let f1 = Flag::new(Trit::Affirm, 0.9, "test:one", Source::Agent, "2026-07-02T20:00:00Z");
90        store.remember(&f1).unwrap();
91        let all = store.recall_all().unwrap();
92        assert_eq!(all.len(), 1);
93        assert_eq!(all[0].trit, 1);
94    }
95
96    #[test]
97    fn reconfirm_count_counts_affirms_only() {
98        let dir = tempfile::tempdir().unwrap();
99        let store = FlagStore::open(dir.path().join("flags.jsonl"));
100        store.remember(&Flag::new(Trit::Affirm, 0.9, "x:1", Source::Agent, "t1")).unwrap();
101        store.remember(&Flag::new(Trit::Affirm, 0.8, "x:2", Source::Human, "t2")).unwrap();
102        store.remember(&Flag::new(Trit::Reject, 0.5, "x:3", Source::Agent, "t3")).unwrap();
103        assert_eq!(store.reconfirm_count("x:").unwrap(), 2);
104    }
105}