Skip to main content

context_forge/lexicon/
appender.rs

1use std::path::PathBuf;
2
3use crate::{Error, Result};
4
5use super::config::LexiconConfig;
6
7/// A candidate term proposed for addition to the lexicon.
8///
9/// Produced by the caller's growth-loop analyzer (outside this library)
10/// and passed to [`LexiconAppender::append`] after operator approval.
11#[derive(Debug, Clone)]
12pub struct LexiconProposal {
13    /// The term to add.
14    pub term: String,
15    /// Proposed importance weight (e.g. `1.3`).
16    pub weight: f64,
17    /// Human-readable rationale produced by the LLM reasoner. Written as a
18    /// TOML inline comment on the term line when `Some`.
19    pub rationale: Option<String>,
20    /// IDs of the entries that provided evidence for this proposal.
21    pub source_ids: Vec<String>,
22}
23
24/// Atomically appends or removes entries from a TOML lexicon file.
25///
26/// Uses a write-to-temp-then-rename pattern so a crash mid-write never
27/// corrupts the existing file. The temp file is written to the same
28/// directory as the target to ensure both are on the same filesystem
29/// (required for atomic rename on most platforms).
30#[derive(Debug, Clone)]
31pub struct LexiconAppender {
32    path: PathBuf,
33}
34
35impl LexiconAppender {
36    /// Create an appender targeting `path`. The file need not exist yet.
37    #[must_use]
38    pub fn new(path: PathBuf) -> Self {
39        Self { path }
40    }
41
42    // --- internal helpers ---
43
44    fn read_config(&self) -> Result<LexiconConfig> {
45        if self.path.exists() {
46            let raw = std::fs::read_to_string(&self.path)
47                .map_err(|e| Error::Migration(format!("lexicon read error: {e}")))?;
48            toml::from_str(&raw).map_err(|e| Error::Migration(format!("lexicon parse error: {e}")))
49        } else {
50            Ok(LexiconConfig::default())
51        }
52    }
53
54    fn write_config(&self, config: &LexiconConfig) -> Result<()> {
55        let serialized = toml::to_string_pretty(config)
56            .map_err(|e| Error::Migration(format!("lexicon serialize error: {e}")))?;
57        self.write_raw(&serialized)
58    }
59
60    fn write_raw(&self, content: &str) -> Result<()> {
61        let tmp_path = self.path.with_extension("toml.tmp");
62        std::fs::write(&tmp_path, content)
63            .map_err(|e| Error::Migration(format!("lexicon write error: {e}")))?;
64        std::fs::rename(&tmp_path, &self.path)
65            .map_err(|e| Error::Migration(format!("lexicon rename error: {e}")))
66    }
67
68    // --- public API ---
69
70    /// Append `proposal.term` with `proposal.weight` to the lexicon file.
71    ///
72    /// Reads the current file (creating an empty config if absent), inserts
73    /// the new term, serializes back to TOML, and atomically replaces the
74    /// file via a temp-file rename. When `proposal.rationale` is `Some`, the
75    /// rationale is written as an inline comment on the term's line.
76    ///
77    /// # Errors
78    ///
79    /// Returns an error if the file cannot be read, parsed, serialized, or
80    /// written.
81    pub fn append(&self, proposal: &LexiconProposal) -> Result<()> {
82        let mut config = self.read_config()?;
83        config.terms.insert(proposal.term.clone(), proposal.weight);
84
85        let serialized = toml::to_string_pretty(&config)
86            .map_err(|e| Error::Migration(format!("lexicon serialize error: {e}")))?;
87
88        let content = if let Some(rationale) = &proposal.rationale {
89            // toml 0.8 quotes keys only when necessary (special chars, whitespace, etc.).
90            // Match on both forms anchored by " =" so we don't match key prefixes.
91            let bare = format!("{} =", proposal.term);
92            let quoted = format!("\"{}\" =", proposal.term.replace('"', "\\\""));
93            serialized
94                .lines()
95                .map(|line| {
96                    let t = line.trim_start();
97                    if t.starts_with(&bare) || t.starts_with(&quoted) {
98                        format!("{line}   # {rationale}")
99                    } else {
100                        line.to_owned()
101                    }
102                })
103                .collect::<Vec<_>>()
104                .join("\n")
105                + "\n"
106        } else {
107            serialized
108        };
109
110        self.write_raw(&content)
111    }
112
113    /// Append a pattern to `[affirmations]`, deduplicating case-insensitively.
114    ///
115    /// # Errors
116    ///
117    /// Returns an error if the file cannot be read, parsed, serialized, or written.
118    pub fn append_affirmation(&self, pattern: &str) -> Result<()> {
119        let mut config = self.read_config()?;
120        let lower = pattern.to_lowercase();
121        if !config
122            .affirmations
123            .patterns
124            .iter()
125            .any(|p| p.to_lowercase() == lower)
126        {
127            config.affirmations.patterns.push(pattern.to_owned());
128        }
129        self.write_config(&config)
130    }
131
132    /// Append a pattern to `[negations]`, deduplicating case-insensitively.
133    ///
134    /// # Errors
135    ///
136    /// Returns an error if the file cannot be read, parsed, serialized, or written.
137    pub fn append_negation(&self, pattern: &str) -> Result<()> {
138        let mut config = self.read_config()?;
139        let lower = pattern.to_lowercase();
140        if !config
141            .negations
142            .patterns
143            .iter()
144            .any(|p| p.to_lowercase() == lower)
145        {
146            config.negations.patterns.push(pattern.to_owned());
147        }
148        self.write_config(&config)
149    }
150
151    /// Remove a term from `[terms]`. No-op if the term is not present.
152    ///
153    /// # Errors
154    ///
155    /// Returns an error if the file cannot be read, parsed, serialized, or written.
156    pub fn remove_term(&self, term: &str) -> Result<()> {
157        let mut config = self.read_config()?;
158        config.terms.remove(term);
159        self.write_config(&config)
160    }
161
162    /// Remove a pattern from `[affirmations]`, matching case-insensitively.
163    /// No-op if the pattern is not present.
164    ///
165    /// # Errors
166    ///
167    /// Returns an error if the file cannot be read, parsed, serialized, or written.
168    pub fn remove_affirmation(&self, pattern: &str) -> Result<()> {
169        let mut config = self.read_config()?;
170        let lower = pattern.to_lowercase();
171        config
172            .affirmations
173            .patterns
174            .retain(|p| p.to_lowercase() != lower);
175        self.write_config(&config)
176    }
177
178    /// Remove a pattern from `[negations]`, matching case-insensitively.
179    /// No-op if the pattern is not present.
180    ///
181    /// # Errors
182    ///
183    /// Returns an error if the file cannot be read, parsed, serialized, or written.
184    pub fn remove_negation(&self, pattern: &str) -> Result<()> {
185        let mut config = self.read_config()?;
186        let lower = pattern.to_lowercase();
187        config
188            .negations
189            .patterns
190            .retain(|p| p.to_lowercase() != lower);
191        self.write_config(&config)
192    }
193}
194
195#[cfg(test)]
196mod tests {
197    use super::*;
198
199    fn make_appender() -> (LexiconAppender, tempfile::TempDir) {
200        let dir = tempfile::tempdir().unwrap();
201        let path = dir.path().join("lexicon.toml");
202        (LexiconAppender::new(path), dir)
203    }
204
205    #[test]
206    fn appender_creates_and_updates_file() {
207        let (appender, _dir) = make_appender();
208
209        appender
210            .append(&LexiconProposal {
211                term: "Battle-Sister".into(),
212                weight: 1.2,
213                rationale: Some("high-salience domain noun".into()),
214                source_ids: vec!["id-1".into()],
215            })
216            .unwrap();
217
218        assert!(appender.path.exists());
219        let raw = std::fs::read_to_string(&appender.path).unwrap();
220        assert!(raw.contains("Battle-Sister"));
221        assert!(raw.contains("high-salience domain noun"));
222
223        appender
224            .append(&LexiconProposal {
225                term: "Inquisitor".into(),
226                weight: 1.1,
227                rationale: None,
228                source_ids: vec![],
229            })
230            .unwrap();
231
232        let config: LexiconConfig =
233            toml::from_str(&std::fs::read_to_string(&appender.path).unwrap()).unwrap();
234        assert_eq!(config.terms.len(), 2);
235        assert!(config.terms.contains_key("Battle-Sister"));
236        assert!(config.terms.contains_key("Inquisitor"));
237    }
238
239    #[test]
240    fn append_affirmation_deduplicates_case_insensitively() {
241        let (appender, _dir) = make_appender();
242
243        appender.append_affirmation("For the Emperor").unwrap();
244        appender.append_affirmation("for the emperor").unwrap(); // duplicate
245        appender.append_affirmation("FOR THE EMPEROR").unwrap(); // duplicate
246
247        let config: LexiconConfig =
248            toml::from_str(&std::fs::read_to_string(&appender.path).unwrap()).unwrap();
249        assert_eq!(config.affirmations.patterns.len(), 1);
250    }
251
252    #[test]
253    fn append_negation_deduplicates_case_insensitively() {
254        let (appender, _dir) = make_appender();
255
256        appender.append_negation("Cogitator returns null").unwrap();
257        appender.append_negation("cogitator returns null").unwrap();
258
259        let config: LexiconConfig =
260            toml::from_str(&std::fs::read_to_string(&appender.path).unwrap()).unwrap();
261        assert_eq!(config.negations.patterns.len(), 1);
262    }
263
264    #[test]
265    fn remove_term_noop_for_missing() {
266        let (appender, _dir) = make_appender();
267
268        appender
269            .append(&LexiconProposal {
270                term: "Omnissiah".into(),
271                weight: 1.2,
272                rationale: None,
273                source_ids: vec![],
274            })
275            .unwrap();
276
277        // Removing a term that doesn't exist must not error or disturb other terms.
278        appender.remove_term("NonExistent").unwrap();
279
280        let config: LexiconConfig =
281            toml::from_str(&std::fs::read_to_string(&appender.path).unwrap()).unwrap();
282        assert_eq!(config.terms.len(), 1);
283        assert!(config.terms.contains_key("Omnissiah"));
284    }
285
286    #[test]
287    fn remove_term_removes_existing() {
288        let (appender, _dir) = make_appender();
289
290        appender
291            .append(&LexiconProposal {
292                term: "Omnissiah".into(),
293                weight: 1.2,
294                rationale: None,
295                source_ids: vec![],
296            })
297            .unwrap();
298        appender.remove_term("Omnissiah").unwrap();
299
300        let config: LexiconConfig =
301            toml::from_str(&std::fs::read_to_string(&appender.path).unwrap()).unwrap();
302        assert!(config.terms.is_empty());
303    }
304
305    #[test]
306    fn remove_affirmation_case_insensitive() {
307        let (appender, _dir) = make_appender();
308
309        appender.append_affirmation("For the Emperor").unwrap();
310        appender.append_affirmation("It shall be done").unwrap();
311        appender.remove_affirmation("FOR THE EMPEROR").unwrap();
312
313        let config: LexiconConfig =
314            toml::from_str(&std::fs::read_to_string(&appender.path).unwrap()).unwrap();
315        assert_eq!(config.affirmations.patterns.len(), 1);
316        assert_eq!(config.affirmations.patterns[0], "It shall be done");
317    }
318
319    #[test]
320    fn remove_negation_case_insensitive() {
321        let (appender, _dir) = make_appender();
322
323        appender.append_negation("Cogitator returns null").unwrap();
324        appender.append_negation("The logic fails").unwrap();
325        appender.remove_negation("COGITATOR RETURNS NULL").unwrap();
326
327        let config: LexiconConfig =
328            toml::from_str(&std::fs::read_to_string(&appender.path).unwrap()).unwrap();
329        assert_eq!(config.negations.patterns.len(), 1);
330        assert_eq!(config.negations.patterns[0], "The logic fails");
331    }
332
333    #[test]
334    fn remove_affirmation_noop_for_missing() {
335        let (appender, _dir) = make_appender();
336
337        appender.append_affirmation("For the Emperor").unwrap();
338        appender.remove_affirmation("NonExistent").unwrap();
339
340        let config: LexiconConfig =
341            toml::from_str(&std::fs::read_to_string(&appender.path).unwrap()).unwrap();
342        assert_eq!(config.affirmations.patterns.len(), 1);
343    }
344}