Skip to main content

edda_postmortem/
lessons.rs

1//! Lesson management: persist lessons and maintain CLAUDE.md integration.
2//!
3//! Output hierarchy:
4//!   - Rules: Hook (block/auto-run), 100% compliance
5//!   - **Lessons**: CLAUDE.md auto-maintained paragraph, ~90% compliance
6//!   - Observations: `edda ask` on-demand, no enforcement
7//!
8//! Lessons are stored per-project in state/lessons.json and optionally
9//! synced to a managed section in the project's CLAUDE.md.
10
11use serde::{Deserialize, Serialize};
12use std::fs;
13use std::path::{Path, PathBuf};
14
15use crate::analyzer::Lesson;
16
17/// Persistent store for lessons.
18#[derive(Debug, Clone, Default, Serialize, Deserialize)]
19pub struct LessonsStore {
20    pub lessons: Vec<StoredLesson>,
21    #[serde(default)]
22    pub last_updated: Option<String>,
23}
24
25/// A lesson with persistence metadata.
26#[derive(Debug, Clone, Serialize, Deserialize)]
27pub struct StoredLesson {
28    pub id: String,
29    pub text: String,
30    pub severity: String,
31    pub tags: Vec<String>,
32    pub source_session: String,
33    pub source_trigger: String,
34    pub created_at: String,
35    /// Number of times this lesson pattern was seen.
36    pub occurrences: u64,
37}
38
39/// CLAUDE.md managed section markers.
40const CLAUDE_MD_SECTION_START: &str = "<!-- edda:lessons:start -->";
41const CLAUDE_MD_SECTION_END: &str = "<!-- edda:lessons:end -->";
42
43impl LessonsStore {
44    /// Load from disk, returning default if not found.
45    pub fn load(path: &Path) -> Self {
46        match fs::read_to_string(path) {
47            Ok(content) => serde_json::from_str(&content).unwrap_or_default(),
48            Err(_) => Self::default(),
49        }
50    }
51
52    /// Persist to disk atomically.
53    pub fn save(&self, path: &Path) -> anyhow::Result<()> {
54        let json = serde_json::to_string_pretty(self)?;
55        edda_store::write_atomic(path, json.as_bytes())
56    }
57
58    /// Resolve the lessons path for a project.
59    pub fn project_path(project_id: &str) -> PathBuf {
60        edda_store::project_dir(project_id)
61            .join("state")
62            .join("lessons.json")
63    }
64
65    /// Load project-scoped lessons.
66    pub fn load_project(project_id: &str) -> Self {
67        Self::load(&Self::project_path(project_id))
68    }
69
70    /// Save project-scoped lessons.
71    pub fn save_project(&self, project_id: &str) -> anyhow::Result<()> {
72        self.save(&Self::project_path(project_id))
73    }
74
75    /// Add lessons from a post-mortem analysis.
76    /// Deduplicates by checking if a similar lesson already exists (same tags + trigger).
77    pub fn add_lessons(&mut self, lessons: &[Lesson], session_id: &str) {
78        let now = now_rfc3339();
79        for lesson in lessons {
80            // Check for duplicate: same trigger + similar tags
81            if let Some(existing) = self
82                .lessons
83                .iter_mut()
84                .find(|l| l.source_trigger == lesson.source_trigger && l.tags == lesson.tags)
85            {
86                existing.occurrences += 1;
87                existing.text = lesson.text.clone(); // Update text with latest
88                continue;
89            }
90
91            self.lessons.push(StoredLesson {
92                id: lesson.id.clone(),
93                text: lesson.text.clone(),
94                severity: format!("{:?}", lesson.severity).to_lowercase(),
95                tags: lesson.tags.clone(),
96                source_session: session_id.to_string(),
97                source_trigger: lesson.source_trigger.clone(),
98                created_at: now.clone(),
99                occurrences: 1,
100            });
101        }
102        self.last_updated = Some(now);
103    }
104
105    /// Get the top N lessons by occurrence count, for CLAUDE.md sync.
106    pub fn top_lessons(&self, n: usize) -> Vec<&StoredLesson> {
107        let mut sorted: Vec<&StoredLesson> = self.lessons.iter().collect();
108        sorted.sort_by_key(|lesson| std::cmp::Reverse(lesson.occurrences));
109        sorted.truncate(n);
110        sorted
111    }
112
113    /// Render lessons as a markdown section for CLAUDE.md.
114    pub fn render_claude_md_section(&self, max_lessons: usize) -> String {
115        let top = self.top_lessons(max_lessons);
116        if top.is_empty() {
117            return String::new();
118        }
119
120        let mut lines = vec![
121            CLAUDE_MD_SECTION_START.to_string(),
122            "## Learned Lessons (edda L3)".to_string(),
123            String::new(),
124        ];
125
126        for lesson in &top {
127            let severity_prefix = match lesson.severity.as_str() {
128                "high" => "[HIGH] ",
129                "medium" => "[MED] ",
130                _ => "",
131            };
132            lines.push(format!(
133                "- {severity_prefix}{} (seen {}x)",
134                lesson.text, lesson.occurrences
135            ));
136        }
137
138        lines.push(String::new());
139        lines.push(CLAUDE_MD_SECTION_END.to_string());
140        lines.join("\n")
141    }
142
143    /// Sync lessons to a CLAUDE.md file, replacing the managed section.
144    ///
145    /// If the file doesn't have the managed section markers, appends them.
146    /// Returns true if the file was modified.
147    pub fn sync_to_claude_md(
148        &self,
149        claude_md_path: &Path,
150        max_lessons: usize,
151    ) -> anyhow::Result<bool> {
152        let section = self.render_claude_md_section(max_lessons);
153        if section.is_empty() {
154            return Ok(false);
155        }
156
157        let content = fs::read_to_string(claude_md_path).unwrap_or_default();
158
159        let new_content = if let (Some(start_pos), Some(end_pos)) = (
160            content.find(CLAUDE_MD_SECTION_START),
161            content.find(CLAUDE_MD_SECTION_END),
162        ) {
163            // Replace existing section
164            let end_pos = end_pos + CLAUDE_MD_SECTION_END.len();
165            format!(
166                "{}{}{}",
167                &content[..start_pos],
168                section,
169                &content[end_pos..]
170            )
171        } else {
172            // Append new section
173            if content.is_empty() {
174                section
175            } else {
176                format!("{}\n\n{}", content.trim_end(), section)
177            }
178        };
179
180        if new_content == content {
181            return Ok(false);
182        }
183
184        edda_store::write_atomic(claude_md_path, new_content.as_bytes())?;
185        Ok(true)
186    }
187}
188
189fn now_rfc3339() -> String {
190    let now = time::OffsetDateTime::now_utc();
191    now.format(&time::format_description::well_known::Rfc3339)
192        .expect("RFC3339 formatting should not fail")
193}
194
195#[cfg(test)]
196mod tests {
197    use super::*;
198    use crate::analyzer::{Lesson, LessonSeverity};
199
200    fn make_lesson(text: &str, trigger: &str, tags: &[&str]) -> Lesson {
201        Lesson {
202            id: format!("lesson_{}", text.len()),
203            text: text.to_string(),
204            severity: LessonSeverity::Medium,
205            tags: tags.iter().map(|t| t.to_string()).collect(),
206            source_trigger: trigger.to_string(),
207        }
208    }
209
210    #[test]
211    fn add_lessons_deduplicates() {
212        let mut store = LessonsStore::default();
213        let lessons = vec![make_lesson("Test lesson", "session_failures", &["failure"])];
214
215        store.add_lessons(&lessons, "s1");
216        assert_eq!(store.lessons.len(), 1);
217        assert_eq!(store.lessons[0].occurrences, 1);
218
219        // Add same lesson again -> increment occurrences
220        store.add_lessons(&lessons, "s2");
221        assert_eq!(store.lessons.len(), 1);
222        assert_eq!(store.lessons[0].occurrences, 2);
223    }
224
225    #[test]
226    fn different_lessons_not_deduplicated() {
227        let mut store = LessonsStore::default();
228        store.add_lessons(&[make_lesson("Lesson A", "trigger_a", &["tag_a"])], "s1");
229        store.add_lessons(&[make_lesson("Lesson B", "trigger_b", &["tag_b"])], "s2");
230        assert_eq!(store.lessons.len(), 2);
231    }
232
233    #[test]
234    fn top_lessons_sorted_by_occurrence() {
235        let mut store = LessonsStore::default();
236        store.lessons.push(StoredLesson {
237            id: "l1".into(),
238            text: "Low".into(),
239            severity: "low".into(),
240            tags: vec![],
241            source_session: "s1".into(),
242            source_trigger: "t1".into(),
243            created_at: "2026-01-01T00:00:00Z".into(),
244            occurrences: 1,
245        });
246        store.lessons.push(StoredLesson {
247            id: "l2".into(),
248            text: "High".into(),
249            severity: "high".into(),
250            tags: vec![],
251            source_session: "s2".into(),
252            source_trigger: "t2".into(),
253            created_at: "2026-01-01T00:00:00Z".into(),
254            occurrences: 5,
255        });
256
257        let top = store.top_lessons(1);
258        assert_eq!(top.len(), 1);
259        assert_eq!(top[0].text, "High");
260    }
261
262    #[test]
263    fn render_claude_md_section_empty_when_no_lessons() {
264        let store = LessonsStore::default();
265        assert!(store.render_claude_md_section(5).is_empty());
266    }
267
268    #[test]
269    fn render_claude_md_section_with_lessons() {
270        let mut store = LessonsStore::default();
271        store.lessons.push(StoredLesson {
272            id: "l1".into(),
273            text: "Always run tests".into(),
274            severity: "high".into(),
275            tags: vec![],
276            source_session: "s1".into(),
277            source_trigger: "t1".into(),
278            created_at: "2026-01-01T00:00:00Z".into(),
279            occurrences: 3,
280        });
281
282        let section = store.render_claude_md_section(5);
283        assert!(section.contains(CLAUDE_MD_SECTION_START));
284        assert!(section.contains(CLAUDE_MD_SECTION_END));
285        assert!(section.contains("Always run tests"));
286        assert!(section.contains("3x"));
287    }
288
289    #[test]
290    fn sync_to_claude_md_appends_if_no_section() {
291        let tmp = tempfile::tempdir().unwrap();
292        let path = tmp.path().join("CLAUDE.md");
293        fs::write(&path, "# My Project\n\nSome content.\n").unwrap();
294
295        let mut store = LessonsStore::default();
296        store.lessons.push(StoredLesson {
297            id: "l1".into(),
298            text: "Test lesson".into(),
299            severity: "medium".into(),
300            tags: vec![],
301            source_session: "s1".into(),
302            source_trigger: "t1".into(),
303            created_at: "2026-01-01T00:00:00Z".into(),
304            occurrences: 1,
305        });
306
307        let modified = store.sync_to_claude_md(&path, 5).unwrap();
308        assert!(modified);
309
310        let content = fs::read_to_string(&path).unwrap();
311        assert!(content.contains("# My Project"));
312        assert!(content.contains(CLAUDE_MD_SECTION_START));
313        assert!(content.contains("Test lesson"));
314    }
315
316    #[test]
317    fn sync_to_claude_md_replaces_existing_section() {
318        let tmp = tempfile::tempdir().unwrap();
319        let path = tmp.path().join("CLAUDE.md");
320        let initial = format!(
321            "# Project\n\n{}\nOld lessons\n{}\n\nMore content.",
322            CLAUDE_MD_SECTION_START, CLAUDE_MD_SECTION_END
323        );
324        fs::write(&path, &initial).unwrap();
325
326        let mut store = LessonsStore::default();
327        store.lessons.push(StoredLesson {
328            id: "l1".into(),
329            text: "New lesson".into(),
330            severity: "medium".into(),
331            tags: vec![],
332            source_session: "s1".into(),
333            source_trigger: "t1".into(),
334            created_at: "2026-01-01T00:00:00Z".into(),
335            occurrences: 2,
336        });
337
338        let modified = store.sync_to_claude_md(&path, 5).unwrap();
339        assert!(modified);
340
341        let content = fs::read_to_string(&path).unwrap();
342        assert!(!content.contains("Old lessons"));
343        assert!(content.contains("New lesson"));
344        assert!(content.contains("More content."));
345    }
346
347    #[test]
348    fn store_round_trip() {
349        let tmp = tempfile::tempdir().unwrap();
350        let path = tmp.path().join("lessons.json");
351
352        let mut store = LessonsStore::default();
353        store.add_lessons(&[make_lesson("Test", "trigger", &["tag"])], "s1");
354        store.save(&path).unwrap();
355
356        let loaded = LessonsStore::load(&path);
357        assert_eq!(loaded.lessons.len(), 1);
358    }
359}