1use std::path::{Path, PathBuf};
2use std::sync::Arc;
3use std::{collections::HashMap, io::Write};
4
5use rusqlite::OptionalExtension;
6use serde::{Deserialize, Serialize};
7
8use crate::error::RuntimeError;
9use crate::index::AnchorIndex;
10use crate::memory::MemoryId;
11#[cfg(test)]
12use crate::memory::read_jsonl;
13
14#[derive(Debug, Clone, Serialize, Deserialize)]
15pub struct Confession {
16 pub id: MemoryId,
17 pub trigger: String,
18 pub rule_violated: String,
19 pub what_i_did: String,
20 pub why: String,
21 pub mitigation: String,
22 #[serde(default)]
23 pub anchors: Vec<String>,
24 pub created_at: chrono::DateTime<chrono::Utc>,
25}
26
27#[derive(Debug, Clone, Serialize, Deserialize)]
28pub struct ConfessionFields {
29 pub trigger: String,
30 pub rule_violated: String,
31 pub what_i_did: String,
32 pub why: String,
33 pub mitigation: String,
34}
35
36impl From<&Confession> for ConfessionFields {
37 fn from(value: &Confession) -> Self {
38 Self {
39 trigger: value.trigger.clone(),
40 rule_violated: value.rule_violated.clone(),
41 what_i_did: value.what_i_did.clone(),
42 why: value.why.clone(),
43 mitigation: value.mitigation.clone(),
44 }
45 }
46}
47
48#[derive(Debug, Clone, Serialize, Deserialize)]
49#[serde(tag = "kind", rename_all = "snake_case")]
50pub enum ConfessionChange {
51 Revised {
52 id: MemoryId,
53 base_revision: u64,
54 fields: ConfessionFields,
55 changed_at: chrono::DateTime<chrono::Utc>,
56 },
57 Organized {
58 id: MemoryId,
59 base_revision: u64,
60 category: String,
61 related_ids: Vec<MemoryId>,
62 changed_at: chrono::DateTime<chrono::Utc>,
63 },
64 Archived {
65 id: MemoryId,
66 base_revision: u64,
67 reason: String,
68 changed_at: chrono::DateTime<chrono::Utc>,
69 },
70}
71
72#[derive(Debug, Clone, Serialize, Deserialize)]
73#[serde(untagged)]
74enum ChangeLogEntry {
75 Single(ConfessionChange),
76 Batch { changes: Vec<ConfessionChange> },
77}
78
79impl ConfessionChange {
80 fn id(&self) -> &MemoryId {
81 match self {
82 Self::Revised { id, .. } | Self::Organized { id, .. } | Self::Archived { id, .. } => id,
83 }
84 }
85
86 fn base_revision(&self) -> u64 {
87 match self {
88 Self::Revised { base_revision, .. }
89 | Self::Organized { base_revision, .. }
90 | Self::Archived { base_revision, .. } => *base_revision,
91 }
92 }
93}
94
95#[derive(Debug, Clone, Serialize, Deserialize)]
96pub struct ConfessionView {
97 pub confession: Confession,
98 pub revision: u64,
99 pub category: Option<String>,
100 pub related_ids: Vec<MemoryId>,
101 pub archived: bool,
102}
103
104impl Confession {
105 fn md_slug(&self) -> String {
106 let date = self.created_at.format("%Y-%m-%d");
107 let slug: String = self
108 .trigger
109 .chars()
110 .filter(|c| c.is_alphanumeric() || *c == '-')
111 .take(48)
112 .collect::<String>()
113 .to_lowercase();
114 let slug = if slug.is_empty() {
115 "trigger".into()
116 } else {
117 slug
118 };
119 format!("{date}-{slug}-{}.md", &self.id.to_string()[..8])
120 }
121
122 fn render_md(&self) -> String {
123 format!(
124 "# {trigger}\n\n\
125 - **id**: `{id}`\n\
126 - **rule_violated**: {rule}\n\
127 - **created_at**: {ts}\n\n\
128 ## What I did\n\n{what}\n\n\
129 ## Why\n\n{why}\n\n\
130 ## Mitigation\n\n{mit}\n",
131 trigger = self.trigger,
132 id = self.id,
133 rule = self.rule_violated,
134 ts = self.created_at.to_rfc3339(),
135 what = self.what_i_did,
136 why = self.why,
137 mit = self.mitigation,
138 )
139 }
140}
141
142pub struct ConfessionStore {
143 dir: PathBuf,
144 index_path: PathBuf,
145 changes_path: PathBuf,
146 anchor_index: Option<Arc<AnchorIndex>>,
147 redactor: Option<Arc<crate::redact::Redactor>>,
148}
149
150impl ConfessionStore {
151 pub fn at(scope_dir: impl AsRef<Path>) -> Self {
152 let dir = scope_dir.as_ref().to_path_buf();
153 let index_path = dir.join("confessions.jsonl");
154 let changes_path = dir.join("changes.jsonl");
155 Self {
156 dir,
157 index_path,
158 changes_path,
159 anchor_index: None,
160 redactor: None,
161 }
162 }
163
164 pub fn with_index(mut self, index: Arc<AnchorIndex>) -> Self {
165 self.anchor_index = Some(index);
166 self
167 }
168
169 pub fn with_redactor(mut self, redactor: Arc<crate::redact::Redactor>) -> Self {
170 self.redactor = Some(redactor);
171 self
172 }
173
174 pub async fn append(&self, confession: Confession) -> Result<MemoryId, RuntimeError> {
175 let confession = self.redact_if_needed(confession);
176 let id = confession.id.clone();
177 let dir = self.dir.clone();
178 let index_path = self.index_path.clone();
179 let for_write = confession.clone();
180 let anchor_index = self.anchor_index.clone();
181 tokio::task::spawn_blocking(move || {
182 with_store_lock(&dir, || {
183 let md_path = dir.join(for_write.md_slug());
184 std::fs::write(&md_path, for_write.render_md()).map_err(store_error)?;
185 append_line(&index_path, &for_write)?;
186 if let Some(index) = &anchor_index
187 && let Err(error) = insert_confession(index, &for_write)
188 {
189 crate::notify!(
190 warn,
191 "confession index insert failed (id={}): {error}",
192 for_write.id
193 );
194 }
195 Ok(())
196 })
197 })
198 .await
199 .map_err(|e| RuntimeError::ToolFailed(format!("confession writer: {e}")))??;
200 Ok(id)
201 }
202
203 fn redact_if_needed(&self, mut c: Confession) -> Confession {
204 let Some(r) = &self.redactor else {
205 return c;
206 };
207 c.trigger = r.redact(&c.trigger).0;
208 c.rule_violated = r.redact(&c.rule_violated).0;
209 c.what_i_did = r.redact(&c.what_i_did).0;
210 c.why = r.redact(&c.why).0;
211 c.mitigation = r.redact(&c.mitigation).0;
212 c
213 }
214
215 pub async fn find_by_trigger_fts(
216 &self,
217 query: &str,
218 ) -> Result<Option<Vec<Confession>>, RuntimeError> {
219 let Some(idx) = self.anchor_index.as_deref() else {
220 return Ok(None);
221 };
222 let conn = idx.conn();
223 let mut stmt = conn
224 .prepare(
225 "SELECT c.id, c.trigger, c.rule_violated, c.what_i_did, c.why, c.mitigation, c.created_at \
226 FROM confessions c \
227 JOIN confessions_fts f ON f.rowid = c.rowid \
228 WHERE f.confessions_fts MATCH ? \
229 ORDER BY c.rowid",
230 )
231 .map_err(|e| RuntimeError::ToolFailed(format!("fts prepare: {e}")))?;
232 let rows = stmt
233 .query_map(rusqlite::params![query], |row| {
234 let created_at: String = row.get(6)?;
235 let created = chrono::DateTime::parse_from_rfc3339(&created_at)
236 .map(|d| d.with_timezone(&chrono::Utc))
237 .unwrap_or_else(|_| chrono::Utc::now());
238 let id_str: String = row.get(0)?;
239 let id = uuid::Uuid::parse_str(&id_str)
240 .map(MemoryId)
241 .unwrap_or_else(|_| MemoryId::now());
242 Ok(Confession {
243 id,
244 trigger: row.get(1)?,
245 rule_violated: row.get(2)?,
246 what_i_did: row.get(3)?,
247 why: row.get(4)?,
248 mitigation: row.get(5)?,
249 anchors: Vec::new(),
250 created_at: created,
251 })
252 })
253 .map_err(|e| RuntimeError::ToolFailed(format!("fts query: {e}")))?;
254 let mut out = Vec::new();
255 for r in rows {
256 match r {
257 Ok(c) => out.push(c),
258 Err(e) => return Err(RuntimeError::ToolFailed(format!("fts row: {e}"))),
259 }
260 }
261 Ok(Some(out))
262 }
263
264 pub async fn list(&self) -> Result<Vec<Confession>, RuntimeError> {
265 Ok(self
266 .list_with_meta(false)
267 .await?
268 .into_iter()
269 .map(|view| view.confession)
270 .collect())
271 }
272
273 pub async fn list_with_meta(
274 &self,
275 include_archived: bool,
276 ) -> Result<Vec<ConfessionView>, RuntimeError> {
277 let dir = self.dir.clone();
278 let index_path = self.index_path.clone();
279 let changes_path = self.changes_path.clone();
280 tokio::task::spawn_blocking(move || {
281 with_store_lock(&dir, || {
282 project_changes(
283 read_lines(&index_path)?,
284 read_change_lines(&changes_path)?,
285 include_archived,
286 )
287 })
288 })
289 .await
290 .map_err(|e| RuntimeError::ToolFailed(format!("confession reader: {e}")))?
291 }
292
293 pub async fn history(&self, id: &MemoryId) -> Result<Vec<ConfessionChange>, RuntimeError> {
294 let dir = self.dir.clone();
295 let changes_path = self.changes_path.clone();
296 let id = id.clone();
297 tokio::task::spawn_blocking(move || {
298 with_store_lock(&dir, || {
299 Ok(read_change_lines(&changes_path)?
300 .into_iter()
301 .filter(|change| change.id() == &id)
302 .collect())
303 })
304 })
305 .await
306 .map_err(|e| RuntimeError::ToolFailed(format!("confession reader: {e}")))?
307 }
308
309 pub async fn revise(
310 &self,
311 id: MemoryId,
312 base_revision: u64,
313 mut fields: ConfessionFields,
314 ) -> Result<ConfessionView, RuntimeError> {
315 if let Some(redactor) = &self.redactor {
316 fields.trigger = redactor.redact(&fields.trigger).0;
317 fields.rule_violated = redactor.redact(&fields.rule_violated).0;
318 fields.what_i_did = redactor.redact(&fields.what_i_did).0;
319 fields.why = redactor.redact(&fields.why).0;
320 fields.mitigation = redactor.redact(&fields.mitigation).0;
321 }
322 let change = ConfessionChange::Revised {
323 id: id.clone(),
324 base_revision,
325 fields,
326 changed_at: chrono::Utc::now(),
327 };
328 Ok(self.append_changes(vec![change]).await?.remove(0))
329 }
330
331 pub async fn organize(
332 &self,
333 changes: Vec<ConfessionChange>,
334 ) -> Result<Vec<ConfessionView>, RuntimeError> {
335 if changes
336 .iter()
337 .any(|change| !matches!(change, ConfessionChange::Organized { .. }))
338 {
339 return Err(RuntimeError::ToolFailed(
340 "expected organization changes".into(),
341 ));
342 }
343 self.append_changes(changes).await
344 }
345
346 pub async fn archive(
347 &self,
348 id: MemoryId,
349 base_revision: u64,
350 reason: String,
351 ) -> Result<ConfessionView, RuntimeError> {
352 let change = ConfessionChange::Archived {
353 id,
354 base_revision,
355 reason,
356 changed_at: chrono::Utc::now(),
357 };
358 Ok(self.append_changes(vec![change]).await?.remove(0))
359 }
360
361 async fn append_changes(
362 &self,
363 changes: Vec<ConfessionChange>,
364 ) -> Result<Vec<ConfessionView>, RuntimeError> {
365 let dir = self.dir.clone();
366 let index_path = self.index_path.clone();
367 let changes_path = self.changes_path.clone();
368 let anchor_index = self.anchor_index.clone();
369 tokio::task::spawn_blocking(move || {
370 with_store_lock(&dir, || {
371 let originals = read_lines::<Confession>(&index_path)?;
372 let existing = read_change_lines(&changes_path)?;
373 let current = project_changes(originals.clone(), existing.clone(), true)?;
374 let by_id: HashMap<_, _> = current
375 .iter()
376 .map(|view| (view.confession.id.clone(), view.revision))
377 .collect();
378 let mut seen = std::collections::HashSet::new();
379 for change in &changes {
380 if !seen.insert(change.id().clone()) {
381 return Err(RuntimeError::ToolFailed(
382 "duplicate confession id in batch".into(),
383 ));
384 }
385 if by_id.get(change.id()) != Some(&change.base_revision()) {
386 return Err(RuntimeError::ToolFailed(
387 "confession revision conflict".into(),
388 ));
389 }
390 if let ConfessionChange::Organized {
391 category,
392 related_ids,
393 ..
394 } = change
395 {
396 if category.trim().is_empty() || category.chars().count() > 60 {
397 return Err(RuntimeError::ToolFailed(
398 "invalid confession category".into(),
399 ));
400 }
401 if related_ids
402 .iter()
403 .any(|related| !by_id.contains_key(related))
404 {
405 return Err(RuntimeError::ToolFailed(
406 "unknown related confession".into(),
407 ));
408 }
409 }
410 }
411 append_line(
412 &changes_path,
413 &ChangeLogEntry::Batch {
414 changes: changes.clone(),
415 },
416 )?;
417 let all = project_changes(
418 originals,
419 existing
420 .into_iter()
421 .chain(changes.iter().cloned())
422 .collect(),
423 true,
424 )?;
425 if let Some(index) = &anchor_index {
426 for view in all.iter().filter(|view| {
427 changes.iter().any(|change| {
428 change.id() == &view.confession.id
429 && !matches!(change, ConfessionChange::Organized { .. })
430 })
431 }) {
432 let result = if view.archived {
433 remove_confession_index(index, &view.confession.id)
434 } else {
435 insert_confession(index, &view.confession)
436 };
437 if let Err(error) = result {
438 crate::notify!(
439 warn,
440 "confession index update failed (id={}): {error}",
441 view.confession.id
442 );
443 }
444 }
445 }
446 Ok(all
447 .into_iter()
448 .filter(|view| seen.contains(&view.confession.id))
449 .collect())
450 })
451 })
452 .await
453 .map_err(|e| RuntimeError::ToolFailed(format!("confession writer: {e}")))?
454 }
455
456 pub async fn find_by_trigger(&self, needle: &str) -> Result<Vec<Confession>, RuntimeError> {
457 if tokio::fs::try_exists(&self.changes_path)
458 .await
459 .unwrap_or(false)
460 {
461 let needle = needle.to_lowercase();
462 return Ok(self
463 .list()
464 .await?
465 .into_iter()
466 .filter(|c| {
467 [
468 &c.trigger,
469 &c.rule_violated,
470 &c.what_i_did,
471 &c.why,
472 &c.mitigation,
473 ]
474 .iter()
475 .any(|field| field.to_lowercase().contains(&needle))
476 })
477 .collect());
478 }
479 if let Ok(Some(hits)) = self.find_by_trigger_fts(needle).await
480 && !hits.is_empty()
481 {
482 return Ok(hits);
483 }
484 let all = self.list().await?;
485 Ok(all
486 .into_iter()
487 .filter(|c| c.trigger.contains(needle))
488 .collect())
489 }
490
491 pub fn dir(&self) -> &Path {
492 &self.dir
493 }
494
495 pub fn index_path(&self) -> &Path {
496 &self.index_path
497 }
498}
499
500fn store_error(error: std::io::Error) -> RuntimeError {
501 RuntimeError::ToolFailed(format!("confession storage: {error}"))
502}
503
504fn with_store_lock<T>(
505 dir: &Path,
506 operation: impl FnOnce() -> Result<T, RuntimeError>,
507) -> Result<T, RuntimeError> {
508 std::fs::create_dir_all(dir).map_err(store_error)?;
509 let lock = std::fs::OpenOptions::new()
510 .create(true)
511 .write(true)
512 .truncate(false)
513 .open(dir.join(".confessions.lock"))
514 .map_err(store_error)?;
515 fs2::FileExt::lock_exclusive(&lock).map_err(store_error)?;
516 operation()
517}
518
519fn append_line(path: &Path, value: &impl Serialize) -> Result<(), RuntimeError> {
520 append_lines(path, std::slice::from_ref(value))
521}
522
523fn append_lines(path: &Path, values: &[impl Serialize]) -> Result<(), RuntimeError> {
524 let mut file = std::fs::OpenOptions::new()
525 .create(true)
526 .append(true)
527 .open(path)
528 .map_err(store_error)?;
529 let mut output = Vec::new();
530 for value in values {
531 let mut line = serde_json::to_vec(value)
532 .map_err(|e| RuntimeError::ToolFailed(format!("encode confession: {e}")))?;
533 line.push(b'\n');
534 output.extend(line);
535 }
536 file.write_all(&output).map_err(store_error)?;
537 file.flush().map_err(store_error)
538}
539
540fn read_change_lines(path: &Path) -> Result<Vec<ConfessionChange>, RuntimeError> {
541 Ok(flatten_changes(read_lines::<ChangeLogEntry>(path)?))
542}
543
544fn flatten_changes(entries: Vec<ChangeLogEntry>) -> Vec<ConfessionChange> {
545 entries
546 .into_iter()
547 .flat_map(|entry| match entry {
548 ChangeLogEntry::Single(change) => vec![change],
549 ChangeLogEntry::Batch { changes } => changes,
550 })
551 .collect()
552}
553
554fn read_lines<T: for<'de> Deserialize<'de>>(path: &Path) -> Result<Vec<T>, RuntimeError> {
555 let text = match std::fs::read_to_string(path) {
556 Ok(text) => text,
557 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
558 Err(error) => return Err(store_error(error)),
559 };
560 text.lines()
561 .filter(|line| !line.trim().is_empty())
562 .map(|line| {
563 serde_json::from_str(line)
564 .map_err(|error| RuntimeError::ToolFailed(format!("decode confession: {error}")))
565 })
566 .collect()
567}
568
569fn project_changes(
570 originals: Vec<Confession>,
571 changes: Vec<ConfessionChange>,
572 include_archived: bool,
573) -> Result<Vec<ConfessionView>, RuntimeError> {
574 let mut views = originals
575 .into_iter()
576 .map(|confession| ConfessionView {
577 confession,
578 revision: 0,
579 category: None,
580 related_ids: Vec::new(),
581 archived: false,
582 })
583 .collect::<Vec<_>>();
584 let positions = views
585 .iter()
586 .enumerate()
587 .map(|(index, view)| (view.confession.id.clone(), index))
588 .collect::<HashMap<_, _>>();
589 for change in changes {
590 let position = *positions.get(change.id()).ok_or_else(|| {
591 RuntimeError::ToolFailed("confession change refers to missing id".into())
592 })?;
593 let view = &mut views[position];
594 if view.revision != change.base_revision() {
595 return Err(RuntimeError::ToolFailed(
596 "confession change revision mismatch".into(),
597 ));
598 }
599 match change {
600 ConfessionChange::Revised { fields, .. } => {
601 view.confession.trigger = fields.trigger;
602 view.confession.rule_violated = fields.rule_violated;
603 view.confession.what_i_did = fields.what_i_did;
604 view.confession.why = fields.why;
605 view.confession.mitigation = fields.mitigation;
606 }
607 ConfessionChange::Organized {
608 category,
609 related_ids,
610 ..
611 } => {
612 view.category = Some(category);
613 view.related_ids = related_ids;
614 }
615 ConfessionChange::Archived { .. } => view.archived = true,
616 }
617 view.revision += 1;
618 }
619 if !include_archived {
620 views.retain(|view| !view.archived);
621 }
622 Ok(views)
623}
624
625fn remove_confession_index(index: &AnchorIndex, id: &MemoryId) -> rusqlite::Result<()> {
626 let mut conn = index.conn();
627 let tx = conn.transaction()?;
628 let rowid = tx
629 .query_row(
630 "SELECT rowid FROM confessions WHERE id = ?",
631 rusqlite::params![id.to_string()],
632 |row| row.get::<_, i64>(0),
633 )
634 .optional()?;
635 if let Some(rowid) = rowid {
636 tx.execute(
637 "DELETE FROM confessions_fts WHERE rowid = ?",
638 rusqlite::params![rowid],
639 )?;
640 tx.execute(
641 "DELETE FROM confessions WHERE id = ?",
642 rusqlite::params![id.to_string()],
643 )?;
644 tx.execute(
645 "DELETE FROM anchors WHERE subject_kind = 'confession' AND subject_id = ?",
646 rusqlite::params![id.to_string()],
647 )?;
648 }
649 tx.commit()
650}
651
652fn insert_confession(index: &AnchorIndex, c: &Confession) -> rusqlite::Result<()> {
653 let mut conn = index.conn();
654 let tx = conn.transaction()?;
655 let old_rowid = tx
656 .query_row(
657 "SELECT rowid FROM confessions WHERE id = ?",
658 rusqlite::params![c.id.to_string()],
659 |row| row.get::<_, i64>(0),
660 )
661 .optional()?;
662 if let Some(rowid) = old_rowid {
663 tx.execute(
664 "DELETE FROM confessions_fts WHERE rowid = ?",
665 rusqlite::params![rowid],
666 )?;
667 }
668 let body = c.render_md();
669 tx.execute(
670 "INSERT OR REPLACE INTO confessions \
671 (id, trigger, rule_violated, what_i_did, why, mitigation, body, created_at) \
672 VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
673 rusqlite::params![
674 c.id.to_string(),
675 c.trigger,
676 c.rule_violated,
677 c.what_i_did,
678 c.why,
679 c.mitigation,
680 body,
681 c.created_at.to_rfc3339(),
682 ],
683 )?;
684 let rowid: i64 = tx.last_insert_rowid();
685 tx.execute(
686 "INSERT OR REPLACE INTO confessions_fts \
687 (rowid, trigger, rule_violated, what_i_did, why, mitigation, body) \
688 VALUES (?, ?, ?, ?, ?, ?, ?)",
689 rusqlite::params![
690 rowid,
691 c.trigger,
692 c.rule_violated,
693 c.what_i_did,
694 c.why,
695 c.mitigation,
696 body,
697 ],
698 )?;
699 tx.execute(
700 "DELETE FROM anchors WHERE subject_kind = 'confession' AND subject_id = ?",
701 rusqlite::params![c.id.to_string()],
702 )?;
703 for anchor in &c.anchors {
704 if let Some((kind, r)) = anchor.split_once(':') {
705 tx.execute(
706 "INSERT INTO anchors (kind, ref, subject_kind, subject_id, session_id, created_at) \
707 VALUES (?, ?, 'confession', ?, NULL, ?)",
708 rusqlite::params![kind, r, c.id.to_string(), c.created_at.to_rfc3339()],
709 )?;
710 }
711 }
712 tx.commit()
713}
714
715#[cfg(test)]
716mod tests {
717 use super::*;
718 use tempfile::TempDir;
719
720 fn sample(trigger: &str, rule: &str) -> Confession {
721 Confession {
722 id: MemoryId::now(),
723 trigger: trigger.into(),
724 rule_violated: rule.into(),
725 what_i_did: "wrote `as any`".into(),
726 why: "was in a hurry".into(),
727 mitigation: "run cargo check on every edit".into(),
728 anchors: vec![],
729 created_at: chrono::Utc::now(),
730 }
731 }
732
733 #[tokio::test]
734 async fn append_then_list_returns_confession() {
735 let dir = TempDir::new().unwrap();
736 let store = ConfessionStore::at(dir.path());
737 let id = store
738 .append(sample("you keep doing X", "no-as-any"))
739 .await
740 .unwrap();
741 let items = store.list().await.unwrap();
742 assert_eq!(items.len(), 1);
743 assert_eq!(items[0].id, id);
744 }
745
746 #[tokio::test]
747 async fn find_by_trigger_filters() {
748 let dir = TempDir::new().unwrap();
749 let store = ConfessionStore::at(dir.path());
750 store
751 .append(sample("comment discipline", "no-narrative-comments"))
752 .await
753 .unwrap();
754 store
755 .append(sample("type safety", "no-as-any"))
756 .await
757 .unwrap();
758 let hits = store.find_by_trigger("comment").await.unwrap();
759 assert_eq!(hits.len(), 1);
760 assert_eq!(hits[0].rule_violated, "no-narrative-comments");
761 }
762
763 #[tokio::test]
764 async fn empty_returns_empty() {
765 let dir = TempDir::new().unwrap();
766 let store = ConfessionStore::at(dir.path());
767 assert!(store.list().await.unwrap().is_empty());
768 }
769
770 #[tokio::test]
771 async fn append_with_index_populates_confessions_and_fts() {
772 let dir = TempDir::new().unwrap();
773 let index = Arc::new(AnchorIndex::open_project(dir.path()).unwrap());
774 let store = ConfessionStore::at(dir.path()).with_index(index.clone());
775 let mut c = sample("comment discipline yet again", "no-narrative-comments");
776 c.anchors = vec!["flow_run:00000000-0000-0000-0000-000000000001".into()];
777 store.append(c).await.unwrap();
778
779 let conn = index.conn();
780 let count: i64 = conn
781 .query_row(
782 "SELECT COUNT(*) FROM confessions",
783 rusqlite::params![],
784 |r| r.get(0),
785 )
786 .unwrap();
787 assert_eq!(count, 1);
788
789 let fts_hit: i64 = conn
790 .query_row(
791 "SELECT COUNT(*) FROM confessions_fts WHERE confessions_fts MATCH ?",
792 rusqlite::params!["narrative"],
793 |r| r.get(0),
794 )
795 .unwrap();
796 assert_eq!(fts_hit, 1, "fts should find `narrative` in rule_violated");
797
798 let anchor_count: i64 = conn
799 .query_row(
800 "SELECT COUNT(*) FROM anchors WHERE kind='flow_run'",
801 rusqlite::params![],
802 |r| r.get(0),
803 )
804 .unwrap();
805 assert_eq!(anchor_count, 1);
806 }
807
808 #[tokio::test]
809 async fn revision_preserves_history_and_changes_agent_search() {
810 let dir = TempDir::new().unwrap();
811 let store = ConfessionStore::at(dir.path());
812 let original = sample("old trigger", "old rule");
813 let id = original.id.clone();
814 store.append(original.clone()).await.unwrap();
815 let mut fields = ConfessionFields::from(&original);
816 fields.trigger = "new trigger".into();
817 fields.mitigation = "new mitigation".into();
818 let revised = store.revise(id.clone(), 0, fields).await.unwrap();
819 assert_eq!(revised.revision, 1);
820 assert_eq!(store.list().await.unwrap()[0].trigger, "new trigger");
821 assert_eq!(store.find_by_trigger("old trigger").await.unwrap().len(), 0);
822 assert_eq!(store.find_by_trigger("new trigger").await.unwrap().len(), 1);
823 assert_eq!(store.history(&id).await.unwrap().len(), 1);
824 let raw: Vec<Confession> = read_jsonl(&store.index_path).await.unwrap();
825 assert_eq!(raw[0].trigger, "old trigger");
826 assert!(
827 store
828 .revise(id, 0, ConfessionFields::from(&original))
829 .await
830 .is_err()
831 );
832 }
833
834 #[tokio::test]
835 async fn organization_batch_rejects_conflict_without_partial_write() {
836 let dir = TempDir::new().unwrap();
837 let store = ConfessionStore::at(dir.path());
838 let first = sample("first", "rule");
839 let second = sample("second", "rule");
840 store.append(first.clone()).await.unwrap();
841 store.append(second.clone()).await.unwrap();
842 let make = |id, base_revision| ConfessionChange::Organized {
843 id,
844 base_revision,
845 category: "group".into(),
846 related_ids: Vec::new(),
847 changed_at: chrono::Utc::now(),
848 };
849 let result = store
850 .organize(vec![make(first.id.clone(), 0), make(second.id.clone(), 1)])
851 .await;
852 assert!(result.is_err());
853 assert!(store.history(&first.id).await.unwrap().is_empty());
854 store
855 .organize(vec![make(first.id.clone(), 0), make(second.id.clone(), 0)])
856 .await
857 .unwrap();
858 let rows = store.list_with_meta(false).await.unwrap();
859 assert!(
860 rows.iter()
861 .all(|view| view.category.as_deref() == Some("group"))
862 );
863 assert_eq!(
864 read_lines::<ChangeLogEntry>(&store.changes_path)
865 .unwrap()
866 .len(),
867 1
868 );
869 }
870
871 #[tokio::test]
872 async fn revision_replaces_fts_and_anchor_projection() {
873 let dir = TempDir::new().unwrap();
874 let index = Arc::new(AnchorIndex::open_project(dir.path()).unwrap());
875 let store = ConfessionStore::at(dir.path().join("confessions")).with_index(index.clone());
876 let mut original = sample("old trigger", "rule");
877 original.anchors.push("turn:one".into());
878 store.append(original.clone()).await.unwrap();
879 let mut fields = ConfessionFields::from(&original);
880 fields.trigger = "new trigger".into();
881 store.revise(original.id.clone(), 0, fields).await.unwrap();
882 let conn = index.conn();
883 let fts_old: i64 = conn
884 .query_row(
885 "SELECT COUNT(*) FROM confessions_fts WHERE confessions_fts MATCH 'old'",
886 [],
887 |row| row.get(0),
888 )
889 .unwrap();
890 let fts_new: i64 = conn
891 .query_row(
892 "SELECT COUNT(*) FROM confessions_fts WHERE confessions_fts MATCH 'new'",
893 [],
894 |row| row.get(0),
895 )
896 .unwrap();
897 let anchors: i64 = conn
898 .query_row(
899 "SELECT COUNT(*) FROM anchors WHERE subject_kind = 'confession' AND subject_id = ?",
900 rusqlite::params![original.id.to_string()],
901 |row| row.get(0),
902 )
903 .unwrap();
904 assert_eq!((fts_old, fts_new, anchors), (0, 1, 1));
905 }
906
907 #[tokio::test]
908 async fn archived_record_remains_in_history_but_leaves_search_index() {
909 let dir = TempDir::new().unwrap();
910 let index = Arc::new(AnchorIndex::open_project(dir.path()).unwrap());
911 let store = ConfessionStore::at(dir.path().join("confessions")).with_index(index.clone());
912 let original = sample("old trigger", "rule");
913 store.append(original.clone()).await.unwrap();
914 store
915 .archive(original.id.clone(), 0, "superseded".into())
916 .await
917 .unwrap();
918 assert!(store.list().await.unwrap().is_empty());
919 assert_eq!(store.list_with_meta(true).await.unwrap().len(), 1);
920 assert!(matches!(
921 store.history(&original.id).await.unwrap().as_slice(),
922 [ConfessionChange::Archived { reason, .. }] if reason == "superseded"
923 ));
924 let count: i64 = index
925 .conn()
926 .query_row("SELECT COUNT(*) FROM confessions", [], |row| row.get(0))
927 .unwrap();
928 assert_eq!(count, 0);
929 }
930
931 #[tokio::test]
932 async fn find_by_trigger_fts_returns_none_without_index() {
933 let dir = TempDir::new().unwrap();
934 let store = ConfessionStore::at(dir.path());
935 assert!(store.find_by_trigger_fts("x").await.unwrap().is_none());
936 }
937
938 #[tokio::test]
939 async fn find_by_trigger_fts_returns_matching_rows() {
940 let dir = TempDir::new().unwrap();
941 let index = Arc::new(AnchorIndex::open_project(dir.path()).unwrap());
942 let store = ConfessionStore::at(dir.path()).with_index(index);
943 store
944 .append(sample("boot flow crash", "no-panic-in-boot"))
945 .await
946 .unwrap();
947 store
948 .append(sample("type safety again", "no-as-any"))
949 .await
950 .unwrap();
951 let hits = store.find_by_trigger_fts("boot").await.unwrap().unwrap();
952 assert_eq!(hits.len(), 1);
953 assert_eq!(hits[0].rule_violated, "no-panic-in-boot");
954 }
955
956 #[tokio::test]
957 async fn append_writes_md_body_alongside_index() {
958 let dir = TempDir::new().unwrap();
959 let store = ConfessionStore::at(dir.path());
960 store
961 .append(sample("comment discipline again", "no-narrative-comments"))
962 .await
963 .unwrap();
964 let mut md_files = tokio::fs::read_dir(dir.path()).await.unwrap();
965 let mut found_md = false;
966 while let Some(entry) = md_files.next_entry().await.unwrap() {
967 let name = entry.file_name();
968 if name.to_string_lossy().ends_with(".md") {
969 found_md = true;
970 let body = tokio::fs::read_to_string(entry.path()).await.unwrap();
971 assert!(body.starts_with("# comment discipline again"));
972 assert!(body.contains("no-narrative-comments"));
973 }
974 }
975 assert!(found_md, "expected a `.md` body file next to the index");
976 }
977}