1use std::path::{Path, PathBuf};
2use std::sync::Arc;
3
4use serde::{Deserialize, Serialize};
5
6use crate::error::RuntimeError;
7use crate::index::AnchorIndex;
8use crate::memory::{MemoryId, append_jsonl, read_jsonl};
9
10#[derive(Debug, Clone, Serialize, Deserialize)]
11pub struct Confession {
12 pub id: MemoryId,
13 pub trigger: String,
14 pub rule_violated: String,
15 pub what_i_did: String,
16 pub why: String,
17 pub mitigation: String,
18 #[serde(default)]
19 pub anchors: Vec<String>,
20 pub created_at: chrono::DateTime<chrono::Utc>,
21}
22
23impl Confession {
24 fn md_slug(&self) -> String {
25 let date = self.created_at.format("%Y-%m-%d");
26 let slug: String = self
27 .trigger
28 .chars()
29 .filter(|c| c.is_alphanumeric() || *c == '-')
30 .take(48)
31 .collect::<String>()
32 .to_lowercase();
33 let slug = if slug.is_empty() {
34 "trigger".into()
35 } else {
36 slug
37 };
38 format!("{date}-{slug}-{}.md", &self.id.to_string()[..8])
39 }
40
41 fn render_md(&self) -> String {
42 format!(
43 "# {trigger}\n\n\
44 - **id**: `{id}`\n\
45 - **rule_violated**: {rule}\n\
46 - **created_at**: {ts}\n\n\
47 ## What I did\n\n{what}\n\n\
48 ## Why\n\n{why}\n\n\
49 ## Mitigation\n\n{mit}\n",
50 trigger = self.trigger,
51 id = self.id,
52 rule = self.rule_violated,
53 ts = self.created_at.to_rfc3339(),
54 what = self.what_i_did,
55 why = self.why,
56 mit = self.mitigation,
57 )
58 }
59}
60
61pub struct ConfessionStore {
62 dir: PathBuf,
63 index_path: PathBuf,
64 anchor_index: Option<Arc<AnchorIndex>>,
65 redactor: Option<Arc<crate::redact::Redactor>>,
66}
67
68impl ConfessionStore {
69 pub fn at(scope_dir: impl AsRef<Path>) -> Self {
70 let dir = scope_dir.as_ref().to_path_buf();
71 let index_path = dir.join("confessions.jsonl");
72 Self {
73 dir,
74 index_path,
75 anchor_index: None,
76 redactor: None,
77 }
78 }
79
80 pub fn with_index(mut self, index: Arc<AnchorIndex>) -> Self {
81 self.anchor_index = Some(index);
82 self
83 }
84
85 pub fn with_redactor(mut self, redactor: Arc<crate::redact::Redactor>) -> Self {
86 self.redactor = Some(redactor);
87 self
88 }
89
90 pub async fn append(&self, confession: Confession) -> Result<MemoryId, RuntimeError> {
91 let confession = self.redact_if_needed(confession);
92 let id = confession.id.clone();
93 tokio::fs::create_dir_all(&self.dir)
94 .await
95 .map_err(|e| RuntimeError::ToolFailed(format!("mkdir {}: {e}", self.dir.display())))?;
96 let md_path = self.dir.join(confession.md_slug());
97 tokio::fs::write(&md_path, confession.render_md())
98 .await
99 .map_err(|e| RuntimeError::ToolFailed(format!("write {}: {e}", md_path.display())))?;
100 append_jsonl(&self.index_path, &confession).await?;
101 if let Some(idx) = &self.anchor_index
102 && let Err(e) = insert_confession(idx, &confession)
103 {
104 let key = format!("confession.index:{id}");
105 crate::notify!(
106 warn,
107 location = Inline,
108 stack = dedupe(key, 60_000),
109 "confession index insert failed (id={id}): {e}"
110 );
111 }
112 Ok(id)
113 }
114
115 fn redact_if_needed(&self, mut c: Confession) -> Confession {
116 let Some(r) = &self.redactor else {
117 return c;
118 };
119 c.trigger = r.redact(&c.trigger).0;
120 c.rule_violated = r.redact(&c.rule_violated).0;
121 c.what_i_did = r.redact(&c.what_i_did).0;
122 c.why = r.redact(&c.why).0;
123 c.mitigation = r.redact(&c.mitigation).0;
124 c
125 }
126
127 pub async fn find_by_trigger_fts(
128 &self,
129 query: &str,
130 ) -> Result<Option<Vec<Confession>>, RuntimeError> {
131 let Some(idx) = self.anchor_index.as_deref() else {
132 return Ok(None);
133 };
134 let conn = idx.conn();
135 let mut stmt = conn
136 .prepare(
137 "SELECT c.id, c.trigger, c.rule_violated, c.what_i_did, c.why, c.mitigation, c.created_at \
138 FROM confessions c \
139 JOIN confessions_fts f ON f.rowid = c.rowid \
140 WHERE f.confessions_fts MATCH ? \
141 ORDER BY c.rowid",
142 )
143 .map_err(|e| RuntimeError::ToolFailed(format!("fts prepare: {e}")))?;
144 let rows = stmt
145 .query_map(rusqlite::params![query], |row| {
146 let created_at: String = row.get(6)?;
147 let created = chrono::DateTime::parse_from_rfc3339(&created_at)
148 .map(|d| d.with_timezone(&chrono::Utc))
149 .unwrap_or_else(|_| chrono::Utc::now());
150 let id_str: String = row.get(0)?;
151 let id = uuid::Uuid::parse_str(&id_str)
152 .map(MemoryId)
153 .unwrap_or_else(|_| MemoryId::now());
154 Ok(Confession {
155 id,
156 trigger: row.get(1)?,
157 rule_violated: row.get(2)?,
158 what_i_did: row.get(3)?,
159 why: row.get(4)?,
160 mitigation: row.get(5)?,
161 anchors: Vec::new(),
162 created_at: created,
163 })
164 })
165 .map_err(|e| RuntimeError::ToolFailed(format!("fts query: {e}")))?;
166 let mut out = Vec::new();
167 for r in rows {
168 match r {
169 Ok(c) => out.push(c),
170 Err(e) => return Err(RuntimeError::ToolFailed(format!("fts row: {e}"))),
171 }
172 }
173 Ok(Some(out))
174 }
175
176 pub async fn list(&self) -> Result<Vec<Confession>, RuntimeError> {
177 read_jsonl(&self.index_path).await
178 }
179
180 pub async fn find_by_trigger(&self, needle: &str) -> Result<Vec<Confession>, RuntimeError> {
181 if let Ok(Some(hits)) = self.find_by_trigger_fts(needle).await
182 && !hits.is_empty()
183 {
184 return Ok(hits);
185 }
186 let all = self.list().await?;
187 Ok(all
188 .into_iter()
189 .filter(|c| c.trigger.contains(needle))
190 .collect())
191 }
192
193 pub fn dir(&self) -> &Path {
194 &self.dir
195 }
196
197 pub fn index_path(&self) -> &Path {
198 &self.index_path
199 }
200}
201
202fn insert_confession(index: &AnchorIndex, c: &Confession) -> rusqlite::Result<()> {
203 let conn = index.conn();
204 let body = c.render_md();
205 conn.execute(
206 "INSERT OR REPLACE INTO confessions \
207 (id, trigger, rule_violated, what_i_did, why, mitigation, body, created_at) \
208 VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
209 rusqlite::params![
210 c.id.to_string(),
211 c.trigger,
212 c.rule_violated,
213 c.what_i_did,
214 c.why,
215 c.mitigation,
216 body,
217 c.created_at.to_rfc3339(),
218 ],
219 )?;
220 let rowid: i64 = conn.last_insert_rowid();
221 conn.execute(
222 "INSERT OR REPLACE INTO confessions_fts \
223 (rowid, trigger, rule_violated, what_i_did, why, mitigation, body) \
224 VALUES (?, ?, ?, ?, ?, ?, ?)",
225 rusqlite::params![
226 rowid,
227 c.trigger,
228 c.rule_violated,
229 c.what_i_did,
230 c.why,
231 c.mitigation,
232 body,
233 ],
234 )?;
235 for anchor in &c.anchors {
236 if let Some((kind, r)) = anchor.split_once(':') {
237 conn.execute(
238 "INSERT INTO anchors (kind, ref, subject_kind, subject_id, session_id, created_at) \
239 VALUES (?, ?, 'confession', ?, NULL, ?)",
240 rusqlite::params![kind, r, c.id.to_string(), c.created_at.to_rfc3339()],
241 )?;
242 }
243 }
244 Ok(())
245}
246
247#[cfg(test)]
248mod tests {
249 use super::*;
250 use tempfile::TempDir;
251
252 fn sample(trigger: &str, rule: &str) -> Confession {
253 Confession {
254 id: MemoryId::now(),
255 trigger: trigger.into(),
256 rule_violated: rule.into(),
257 what_i_did: "wrote `as any`".into(),
258 why: "was in a hurry".into(),
259 mitigation: "run cargo check on every edit".into(),
260 anchors: vec![],
261 created_at: chrono::Utc::now(),
262 }
263 }
264
265 #[tokio::test]
266 async fn append_then_list_returns_confession() {
267 let dir = TempDir::new().unwrap();
268 let store = ConfessionStore::at(dir.path());
269 let id = store
270 .append(sample("you keep doing X", "no-as-any"))
271 .await
272 .unwrap();
273 let items = store.list().await.unwrap();
274 assert_eq!(items.len(), 1);
275 assert_eq!(items[0].id, id);
276 }
277
278 #[tokio::test]
279 async fn find_by_trigger_filters() {
280 let dir = TempDir::new().unwrap();
281 let store = ConfessionStore::at(dir.path());
282 store
283 .append(sample("comment discipline", "no-narrative-comments"))
284 .await
285 .unwrap();
286 store
287 .append(sample("type safety", "no-as-any"))
288 .await
289 .unwrap();
290 let hits = store.find_by_trigger("comment").await.unwrap();
291 assert_eq!(hits.len(), 1);
292 assert_eq!(hits[0].rule_violated, "no-narrative-comments");
293 }
294
295 #[tokio::test]
296 async fn empty_returns_empty() {
297 let dir = TempDir::new().unwrap();
298 let store = ConfessionStore::at(dir.path());
299 assert!(store.list().await.unwrap().is_empty());
300 }
301
302 #[tokio::test]
303 async fn append_with_index_populates_confessions_and_fts() {
304 let dir = TempDir::new().unwrap();
305 let index = Arc::new(AnchorIndex::open_project(dir.path()).unwrap());
306 let store = ConfessionStore::at(dir.path()).with_index(index.clone());
307 let mut c = sample("comment discipline yet again", "no-narrative-comments");
308 c.anchors = vec!["flow_run:00000000-0000-0000-0000-000000000001".into()];
309 store.append(c).await.unwrap();
310
311 let conn = index.conn();
312 let count: i64 = conn
313 .query_row(
314 "SELECT COUNT(*) FROM confessions",
315 rusqlite::params![],
316 |r| r.get(0),
317 )
318 .unwrap();
319 assert_eq!(count, 1);
320
321 let fts_hit: i64 = conn
322 .query_row(
323 "SELECT COUNT(*) FROM confessions_fts WHERE confessions_fts MATCH ?",
324 rusqlite::params!["narrative"],
325 |r| r.get(0),
326 )
327 .unwrap();
328 assert_eq!(fts_hit, 1, "fts should find `narrative` in rule_violated");
329
330 let anchor_count: i64 = conn
331 .query_row(
332 "SELECT COUNT(*) FROM anchors WHERE kind='flow_run'",
333 rusqlite::params![],
334 |r| r.get(0),
335 )
336 .unwrap();
337 assert_eq!(anchor_count, 1);
338 }
339
340 #[tokio::test]
341 async fn find_by_trigger_fts_returns_none_without_index() {
342 let dir = TempDir::new().unwrap();
343 let store = ConfessionStore::at(dir.path());
344 assert!(store.find_by_trigger_fts("x").await.unwrap().is_none());
345 }
346
347 #[tokio::test]
348 async fn find_by_trigger_fts_returns_matching_rows() {
349 let dir = TempDir::new().unwrap();
350 let index = Arc::new(AnchorIndex::open_project(dir.path()).unwrap());
351 let store = ConfessionStore::at(dir.path()).with_index(index);
352 store
353 .append(sample("boot flow crash", "no-panic-in-boot"))
354 .await
355 .unwrap();
356 store
357 .append(sample("type safety again", "no-as-any"))
358 .await
359 .unwrap();
360 let hits = store.find_by_trigger_fts("boot").await.unwrap().unwrap();
361 assert_eq!(hits.len(), 1);
362 assert_eq!(hits[0].rule_violated, "no-panic-in-boot");
363 }
364
365 #[tokio::test]
366 async fn append_writes_md_body_alongside_index() {
367 let dir = TempDir::new().unwrap();
368 let store = ConfessionStore::at(dir.path());
369 store
370 .append(sample("comment discipline again", "no-narrative-comments"))
371 .await
372 .unwrap();
373 let mut md_files = tokio::fs::read_dir(dir.path()).await.unwrap();
374 let mut found_md = false;
375 while let Some(entry) = md_files.next_entry().await.unwrap() {
376 let name = entry.file_name();
377 if name.to_string_lossy().ends_with(".md") {
378 found_md = true;
379 let body = tokio::fs::read_to_string(entry.path()).await.unwrap();
380 assert!(body.starts_with("# comment discipline again"));
381 assert!(body.contains("no-narrative-comments"));
382 }
383 }
384 assert!(found_md, "expected a `.md` body file next to the index");
385 }
386}