context_forge/lexicon/
appender.rs1use std::path::PathBuf;
2
3use crate::{Error, Result};
4
5use super::config::LexiconConfig;
6
7#[derive(Debug, Clone)]
12pub struct LexiconProposal {
13 pub term: String,
15 pub weight: f64,
17 pub rationale: Option<String>,
20 pub source_ids: Vec<String>,
22}
23
24#[derive(Debug, Clone)]
31pub struct LexiconAppender {
32 path: PathBuf,
33}
34
35impl LexiconAppender {
36 #[must_use]
38 pub fn new(path: PathBuf) -> Self {
39 Self { path }
40 }
41
42 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 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 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("ed) {
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 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 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 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 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 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(); appender.append_affirmation("FOR THE EMPEROR").unwrap(); 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 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}