use serde::{Deserialize, Serialize};
use std::fs;
use std::path::{Path, PathBuf};
use crate::analyzer::Lesson;
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct LessonsStore {
pub lessons: Vec<StoredLesson>,
#[serde(default)]
pub last_updated: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StoredLesson {
pub id: String,
pub text: String,
pub severity: String,
pub tags: Vec<String>,
pub source_session: String,
pub source_trigger: String,
pub created_at: String,
pub occurrences: u64,
}
const CLAUDE_MD_SECTION_START: &str = "<!-- edda:lessons:start -->";
const CLAUDE_MD_SECTION_END: &str = "<!-- edda:lessons:end -->";
impl LessonsStore {
pub fn load(path: &Path) -> Self {
match fs::read_to_string(path) {
Ok(content) => serde_json::from_str(&content).unwrap_or_default(),
Err(_) => Self::default(),
}
}
pub fn save(&self, path: &Path) -> anyhow::Result<()> {
let json = serde_json::to_string_pretty(self)?;
edda_store::write_atomic(path, json.as_bytes())
}
pub fn project_path(project_id: &str) -> PathBuf {
edda_store::project_dir(project_id)
.join("state")
.join("lessons.json")
}
pub fn load_project(project_id: &str) -> Self {
Self::load(&Self::project_path(project_id))
}
pub fn save_project(&self, project_id: &str) -> anyhow::Result<()> {
self.save(&Self::project_path(project_id))
}
pub fn add_lessons(&mut self, lessons: &[Lesson], session_id: &str) {
let now = now_rfc3339();
for lesson in lessons {
if let Some(existing) = self
.lessons
.iter_mut()
.find(|l| l.source_trigger == lesson.source_trigger && l.tags == lesson.tags)
{
existing.occurrences += 1;
existing.text = lesson.text.clone(); continue;
}
self.lessons.push(StoredLesson {
id: lesson.id.clone(),
text: lesson.text.clone(),
severity: format!("{:?}", lesson.severity).to_lowercase(),
tags: lesson.tags.clone(),
source_session: session_id.to_string(),
source_trigger: lesson.source_trigger.clone(),
created_at: now.clone(),
occurrences: 1,
});
}
self.last_updated = Some(now);
}
pub fn top_lessons(&self, n: usize) -> Vec<&StoredLesson> {
let mut sorted: Vec<&StoredLesson> = self.lessons.iter().collect();
sorted.sort_by_key(|lesson| std::cmp::Reverse(lesson.occurrences));
sorted.truncate(n);
sorted
}
pub fn render_claude_md_section(&self, max_lessons: usize) -> String {
let top = self.top_lessons(max_lessons);
if top.is_empty() {
return String::new();
}
let mut lines = vec![
CLAUDE_MD_SECTION_START.to_string(),
"## Learned Lessons (edda L3)".to_string(),
String::new(),
];
for lesson in &top {
let severity_prefix = match lesson.severity.as_str() {
"high" => "[HIGH] ",
"medium" => "[MED] ",
_ => "",
};
lines.push(format!(
"- {severity_prefix}{} (seen {}x)",
lesson.text, lesson.occurrences
));
}
lines.push(String::new());
lines.push(CLAUDE_MD_SECTION_END.to_string());
lines.join("\n")
}
pub fn sync_to_claude_md(
&self,
claude_md_path: &Path,
max_lessons: usize,
) -> anyhow::Result<bool> {
let section = self.render_claude_md_section(max_lessons);
if section.is_empty() {
return Ok(false);
}
let content = fs::read_to_string(claude_md_path).unwrap_or_default();
let new_content = if let (Some(start_pos), Some(end_pos)) = (
content.find(CLAUDE_MD_SECTION_START),
content.find(CLAUDE_MD_SECTION_END),
) {
let end_pos = end_pos + CLAUDE_MD_SECTION_END.len();
format!(
"{}{}{}",
&content[..start_pos],
section,
&content[end_pos..]
)
} else {
if content.is_empty() {
section
} else {
format!("{}\n\n{}", content.trim_end(), section)
}
};
if new_content == content {
return Ok(false);
}
edda_store::write_atomic(claude_md_path, new_content.as_bytes())?;
Ok(true)
}
}
fn now_rfc3339() -> String {
let now = time::OffsetDateTime::now_utc();
now.format(&time::format_description::well_known::Rfc3339)
.expect("RFC3339 formatting should not fail")
}
#[cfg(test)]
mod tests {
use super::*;
use crate::analyzer::{Lesson, LessonSeverity};
fn make_lesson(text: &str, trigger: &str, tags: &[&str]) -> Lesson {
Lesson {
id: format!("lesson_{}", text.len()),
text: text.to_string(),
severity: LessonSeverity::Medium,
tags: tags.iter().map(|t| t.to_string()).collect(),
source_trigger: trigger.to_string(),
}
}
#[test]
fn add_lessons_deduplicates() {
let mut store = LessonsStore::default();
let lessons = vec![make_lesson("Test lesson", "session_failures", &["failure"])];
store.add_lessons(&lessons, "s1");
assert_eq!(store.lessons.len(), 1);
assert_eq!(store.lessons[0].occurrences, 1);
store.add_lessons(&lessons, "s2");
assert_eq!(store.lessons.len(), 1);
assert_eq!(store.lessons[0].occurrences, 2);
}
#[test]
fn different_lessons_not_deduplicated() {
let mut store = LessonsStore::default();
store.add_lessons(&[make_lesson("Lesson A", "trigger_a", &["tag_a"])], "s1");
store.add_lessons(&[make_lesson("Lesson B", "trigger_b", &["tag_b"])], "s2");
assert_eq!(store.lessons.len(), 2);
}
#[test]
fn top_lessons_sorted_by_occurrence() {
let mut store = LessonsStore::default();
store.lessons.push(StoredLesson {
id: "l1".into(),
text: "Low".into(),
severity: "low".into(),
tags: vec![],
source_session: "s1".into(),
source_trigger: "t1".into(),
created_at: "2026-01-01T00:00:00Z".into(),
occurrences: 1,
});
store.lessons.push(StoredLesson {
id: "l2".into(),
text: "High".into(),
severity: "high".into(),
tags: vec![],
source_session: "s2".into(),
source_trigger: "t2".into(),
created_at: "2026-01-01T00:00:00Z".into(),
occurrences: 5,
});
let top = store.top_lessons(1);
assert_eq!(top.len(), 1);
assert_eq!(top[0].text, "High");
}
#[test]
fn render_claude_md_section_empty_when_no_lessons() {
let store = LessonsStore::default();
assert!(store.render_claude_md_section(5).is_empty());
}
#[test]
fn render_claude_md_section_with_lessons() {
let mut store = LessonsStore::default();
store.lessons.push(StoredLesson {
id: "l1".into(),
text: "Always run tests".into(),
severity: "high".into(),
tags: vec![],
source_session: "s1".into(),
source_trigger: "t1".into(),
created_at: "2026-01-01T00:00:00Z".into(),
occurrences: 3,
});
let section = store.render_claude_md_section(5);
assert!(section.contains(CLAUDE_MD_SECTION_START));
assert!(section.contains(CLAUDE_MD_SECTION_END));
assert!(section.contains("Always run tests"));
assert!(section.contains("3x"));
}
#[test]
fn sync_to_claude_md_appends_if_no_section() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("CLAUDE.md");
fs::write(&path, "# My Project\n\nSome content.\n").unwrap();
let mut store = LessonsStore::default();
store.lessons.push(StoredLesson {
id: "l1".into(),
text: "Test lesson".into(),
severity: "medium".into(),
tags: vec![],
source_session: "s1".into(),
source_trigger: "t1".into(),
created_at: "2026-01-01T00:00:00Z".into(),
occurrences: 1,
});
let modified = store.sync_to_claude_md(&path, 5).unwrap();
assert!(modified);
let content = fs::read_to_string(&path).unwrap();
assert!(content.contains("# My Project"));
assert!(content.contains(CLAUDE_MD_SECTION_START));
assert!(content.contains("Test lesson"));
}
#[test]
fn sync_to_claude_md_replaces_existing_section() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("CLAUDE.md");
let initial = format!(
"# Project\n\n{}\nOld lessons\n{}\n\nMore content.",
CLAUDE_MD_SECTION_START, CLAUDE_MD_SECTION_END
);
fs::write(&path, &initial).unwrap();
let mut store = LessonsStore::default();
store.lessons.push(StoredLesson {
id: "l1".into(),
text: "New lesson".into(),
severity: "medium".into(),
tags: vec![],
source_session: "s1".into(),
source_trigger: "t1".into(),
created_at: "2026-01-01T00:00:00Z".into(),
occurrences: 2,
});
let modified = store.sync_to_claude_md(&path, 5).unwrap();
assert!(modified);
let content = fs::read_to_string(&path).unwrap();
assert!(!content.contains("Old lessons"));
assert!(content.contains("New lesson"));
assert!(content.contains("More content."));
}
#[test]
fn store_round_trip() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("lessons.json");
let mut store = LessonsStore::default();
store.add_lessons(&[make_lesson("Test", "trigger", &["tag"])], "s1");
store.save(&path).unwrap();
let loaded = LessonsStore::load(&path);
assert_eq!(loaded.lessons.len(), 1);
}
}