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 eprintln!("[atman] confession index insert failed (id={id}): {e}");
105 }
106 Ok(id)
107 }
108
109 fn redact_if_needed(&self, mut c: Confession) -> Confession {
110 let Some(r) = &self.redactor else {
111 return c;
112 };
113 c.trigger = r.redact(&c.trigger).0;
114 c.rule_violated = r.redact(&c.rule_violated).0;
115 c.what_i_did = r.redact(&c.what_i_did).0;
116 c.why = r.redact(&c.why).0;
117 c.mitigation = r.redact(&c.mitigation).0;
118 c
119 }
120
121 pub async fn find_by_trigger_fts(
122 &self,
123 query: &str,
124 ) -> Result<Option<Vec<Confession>>, RuntimeError> {
125 let Some(idx) = self.anchor_index.as_deref() else {
126 return Ok(None);
127 };
128 let conn = idx.conn();
129 let mut stmt = conn
130 .prepare(
131 "SELECT c.id, c.trigger, c.rule_violated, c.what_i_did, c.why, c.mitigation, c.created_at \
132 FROM confessions c \
133 JOIN confessions_fts f ON f.rowid = c.rowid \
134 WHERE f.confessions_fts MATCH ? \
135 ORDER BY c.rowid",
136 )
137 .map_err(|e| RuntimeError::ToolFailed(format!("fts prepare: {e}")))?;
138 let rows = stmt
139 .query_map(rusqlite::params![query], |row| {
140 let created_at: String = row.get(6)?;
141 let created = chrono::DateTime::parse_from_rfc3339(&created_at)
142 .map(|d| d.with_timezone(&chrono::Utc))
143 .unwrap_or_else(|_| chrono::Utc::now());
144 let id_str: String = row.get(0)?;
145 let id = uuid::Uuid::parse_str(&id_str)
146 .map(MemoryId)
147 .unwrap_or_else(|_| MemoryId::now());
148 Ok(Confession {
149 id,
150 trigger: row.get(1)?,
151 rule_violated: row.get(2)?,
152 what_i_did: row.get(3)?,
153 why: row.get(4)?,
154 mitigation: row.get(5)?,
155 anchors: Vec::new(),
156 created_at: created,
157 })
158 })
159 .map_err(|e| RuntimeError::ToolFailed(format!("fts query: {e}")))?;
160 let mut out = Vec::new();
161 for r in rows {
162 match r {
163 Ok(c) => out.push(c),
164 Err(e) => return Err(RuntimeError::ToolFailed(format!("fts row: {e}"))),
165 }
166 }
167 Ok(Some(out))
168 }
169
170 pub async fn list(&self) -> Result<Vec<Confession>, RuntimeError> {
171 read_jsonl(&self.index_path).await
172 }
173
174 pub async fn find_by_trigger(&self, needle: &str) -> Result<Vec<Confession>, RuntimeError> {
175 if let Ok(Some(hits)) = self.find_by_trigger_fts(needle).await
176 && !hits.is_empty()
177 {
178 return Ok(hits);
179 }
180 let all = self.list().await?;
181 Ok(all
182 .into_iter()
183 .filter(|c| c.trigger.contains(needle))
184 .collect())
185 }
186
187 pub fn dir(&self) -> &Path {
188 &self.dir
189 }
190
191 pub fn index_path(&self) -> &Path {
192 &self.index_path
193 }
194}
195
196fn insert_confession(index: &AnchorIndex, c: &Confession) -> rusqlite::Result<()> {
197 let conn = index.conn();
198 let body = c.render_md();
199 conn.execute(
200 "INSERT OR REPLACE INTO confessions \
201 (id, trigger, rule_violated, what_i_did, why, mitigation, body, created_at) \
202 VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
203 rusqlite::params![
204 c.id.to_string(),
205 c.trigger,
206 c.rule_violated,
207 c.what_i_did,
208 c.why,
209 c.mitigation,
210 body,
211 c.created_at.to_rfc3339(),
212 ],
213 )?;
214 let rowid: i64 = conn.last_insert_rowid();
215 conn.execute(
216 "INSERT OR REPLACE INTO confessions_fts \
217 (rowid, trigger, rule_violated, what_i_did, why, mitigation, body) \
218 VALUES (?, ?, ?, ?, ?, ?, ?)",
219 rusqlite::params![
220 rowid,
221 c.trigger,
222 c.rule_violated,
223 c.what_i_did,
224 c.why,
225 c.mitigation,
226 body,
227 ],
228 )?;
229 for anchor in &c.anchors {
230 if let Some((kind, r)) = anchor.split_once(':') {
231 conn.execute(
232 "INSERT INTO anchors (kind, ref, subject_kind, subject_id, session_id, created_at) \
233 VALUES (?, ?, 'confession', ?, NULL, ?)",
234 rusqlite::params![kind, r, c.id.to_string(), c.created_at.to_rfc3339()],
235 )?;
236 }
237 }
238 Ok(())
239}
240
241#[cfg(test)]
242mod tests {
243 use super::*;
244 use tempfile::TempDir;
245
246 fn sample(trigger: &str, rule: &str) -> Confession {
247 Confession {
248 id: MemoryId::now(),
249 trigger: trigger.into(),
250 rule_violated: rule.into(),
251 what_i_did: "wrote `as any`".into(),
252 why: "was in a hurry".into(),
253 mitigation: "run cargo check on every edit".into(),
254 anchors: vec![],
255 created_at: chrono::Utc::now(),
256 }
257 }
258
259 #[tokio::test]
260 async fn append_then_list_returns_confession() {
261 let dir = TempDir::new().unwrap();
262 let store = ConfessionStore::at(dir.path());
263 let id = store
264 .append(sample("you keep doing X", "no-as-any"))
265 .await
266 .unwrap();
267 let items = store.list().await.unwrap();
268 assert_eq!(items.len(), 1);
269 assert_eq!(items[0].id, id);
270 }
271
272 #[tokio::test]
273 async fn find_by_trigger_filters() {
274 let dir = TempDir::new().unwrap();
275 let store = ConfessionStore::at(dir.path());
276 store
277 .append(sample("comment discipline", "no-narrative-comments"))
278 .await
279 .unwrap();
280 store
281 .append(sample("type safety", "no-as-any"))
282 .await
283 .unwrap();
284 let hits = store.find_by_trigger("comment").await.unwrap();
285 assert_eq!(hits.len(), 1);
286 assert_eq!(hits[0].rule_violated, "no-narrative-comments");
287 }
288
289 #[tokio::test]
290 async fn empty_returns_empty() {
291 let dir = TempDir::new().unwrap();
292 let store = ConfessionStore::at(dir.path());
293 assert!(store.list().await.unwrap().is_empty());
294 }
295
296 #[tokio::test]
297 async fn append_with_index_populates_confessions_and_fts() {
298 let dir = TempDir::new().unwrap();
299 let index = Arc::new(AnchorIndex::open_project(dir.path()).unwrap());
300 let store = ConfessionStore::at(dir.path()).with_index(index.clone());
301 let mut c = sample("comment discipline yet again", "no-narrative-comments");
302 c.anchors = vec!["flow_run:00000000-0000-0000-0000-000000000001".into()];
303 store.append(c).await.unwrap();
304
305 let conn = index.conn();
306 let count: i64 = conn
307 .query_row(
308 "SELECT COUNT(*) FROM confessions",
309 rusqlite::params![],
310 |r| r.get(0),
311 )
312 .unwrap();
313 assert_eq!(count, 1);
314
315 let fts_hit: i64 = conn
316 .query_row(
317 "SELECT COUNT(*) FROM confessions_fts WHERE confessions_fts MATCH ?",
318 rusqlite::params!["narrative"],
319 |r| r.get(0),
320 )
321 .unwrap();
322 assert_eq!(fts_hit, 1, "fts should find `narrative` in rule_violated");
323
324 let anchor_count: i64 = conn
325 .query_row(
326 "SELECT COUNT(*) FROM anchors WHERE kind='flow_run'",
327 rusqlite::params![],
328 |r| r.get(0),
329 )
330 .unwrap();
331 assert_eq!(anchor_count, 1);
332 }
333
334 #[tokio::test]
335 async fn find_by_trigger_fts_returns_none_without_index() {
336 let dir = TempDir::new().unwrap();
337 let store = ConfessionStore::at(dir.path());
338 assert!(store.find_by_trigger_fts("x").await.unwrap().is_none());
339 }
340
341 #[tokio::test]
342 async fn find_by_trigger_fts_returns_matching_rows() {
343 let dir = TempDir::new().unwrap();
344 let index = Arc::new(AnchorIndex::open_project(dir.path()).unwrap());
345 let store = ConfessionStore::at(dir.path()).with_index(index);
346 store
347 .append(sample("boot flow crash", "no-panic-in-boot"))
348 .await
349 .unwrap();
350 store
351 .append(sample("type safety again", "no-as-any"))
352 .await
353 .unwrap();
354 let hits = store.find_by_trigger_fts("boot").await.unwrap().unwrap();
355 assert_eq!(hits.len(), 1);
356 assert_eq!(hits[0].rule_violated, "no-panic-in-boot");
357 }
358
359 #[tokio::test]
360 async fn append_writes_md_body_alongside_index() {
361 let dir = TempDir::new().unwrap();
362 let store = ConfessionStore::at(dir.path());
363 store
364 .append(sample("comment discipline again", "no-narrative-comments"))
365 .await
366 .unwrap();
367 let mut md_files = tokio::fs::read_dir(dir.path()).await.unwrap();
368 let mut found_md = false;
369 while let Some(entry) = md_files.next_entry().await.unwrap() {
370 let name = entry.file_name();
371 if name.to_string_lossy().ends_with(".md") {
372 found_md = true;
373 let body = tokio::fs::read_to_string(entry.path()).await.unwrap();
374 assert!(body.starts_with("# comment discipline again"));
375 assert!(body.contains("no-narrative-comments"));
376 }
377 }
378 assert!(found_md, "expected a `.md` body file next to the index");
379 }
380}