1use serde::{Deserialize, Serialize};
12use std::fs;
13use std::path::{Path, PathBuf};
14
15use crate::analyzer::Lesson;
16
17#[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#[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 pub occurrences: u64,
37}
38
39const CLAUDE_MD_SECTION_START: &str = "<!-- edda:lessons:start -->";
41const CLAUDE_MD_SECTION_END: &str = "<!-- edda:lessons:end -->";
42
43impl LessonsStore {
44 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 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 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 pub fn load_project(project_id: &str) -> Self {
67 Self::load(&Self::project_path(project_id))
68 }
69
70 pub fn save_project(&self, project_id: &str) -> anyhow::Result<()> {
72 self.save(&Self::project_path(project_id))
73 }
74
75 pub fn add_lessons(&mut self, lessons: &[Lesson], session_id: &str) {
78 let now = now_rfc3339();
79 for lesson in lessons {
80 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(); 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 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 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 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 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 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 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}