use super::model::{Gotcha, GotchaStore};
pub struct Learning {
pub category: String,
pub trigger: String,
pub resolution: String,
pub confidence: f32,
pub occurrences: u32,
pub sessions: usize,
}
impl std::fmt::Display for Learning {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"[{cat}] {trigger} → {res} (confidence: {conf:.0}%, seen {occ}x across {sess} sessions)",
cat = self.category,
trigger = self.trigger,
res = self.resolution,
conf = self.confidence * 100.0,
occ = self.occurrences,
sess = self.sessions,
)
}
}
const MIN_CONFIDENCE: f32 = 0.5;
const MIN_OCCURRENCES: u32 = 2;
pub fn extract_learnings(store: &GotchaStore) -> Vec<Learning> {
store
.gotchas
.iter()
.filter(|g| g.confidence >= MIN_CONFIDENCE && g.occurrences >= MIN_OCCURRENCES)
.map(gotcha_to_learning)
.collect()
}
fn gotcha_to_learning(g: &Gotcha) -> Learning {
Learning {
category: g.category.short_label().to_string(),
trigger: g.trigger.clone(),
resolution: g.resolution.clone(),
confidence: g.confidence,
occurrences: g.occurrences,
sessions: g.session_ids.len(),
}
}
const AGENTS_MARKER_START: &str = "<!-- lean-ctx-learn-start -->";
const AGENTS_MARKER_END: &str = "<!-- lean-ctx-learn-end -->";
pub fn format_agents_section(learnings: &[Learning]) -> String {
if learnings.is_empty() {
return String::new();
}
let mut out = String::new();
out.push_str(AGENTS_MARKER_START);
out.push('\n');
out.push_str("## Learned Gotchas (auto-generated by `lean-ctx learn`)\n\n");
out.push_str("Do NOT edit this section manually — it is overwritten on each `lean-ctx learn --apply`.\n\n");
for l in learnings {
out.push_str(&format!(
"- **[{cat}]** {trigger}\n → {res}\n",
cat = l.category,
trigger = l.trigger,
res = l.resolution,
));
}
out.push_str(AGENTS_MARKER_END);
out.push('\n');
out
}
pub fn apply_to_agents_md(project_root: &str, learnings: &[Learning]) -> Result<String, String> {
let agents_path = std::path::Path::new(project_root).join("AGENTS.md");
let existing = if agents_path.exists() {
std::fs::read_to_string(&agents_path)
.map_err(|e| format!("Failed to read AGENTS.md: {e}"))?
} else {
String::new()
};
let section = format_agents_section(learnings);
if section.is_empty() {
return Ok("No learnings to write (need >=2 occurrences with >=50% confidence).".into());
}
let updated = if existing.contains(AGENTS_MARKER_START) {
let before = existing
.split(AGENTS_MARKER_START)
.next()
.unwrap_or(&existing);
let after = existing.split(AGENTS_MARKER_END).nth(1).unwrap_or("");
format!(
"{}{}{}",
before.trim_end(),
"\n\n",
section.trim_end().to_owned() + after
)
} else if existing.is_empty() {
format!("# AGENTS.md\n\n{section}")
} else {
format!("{}\n\n{section}", existing.trim_end())
};
std::fs::write(&agents_path, &updated)
.map_err(|e| format!("Failed to write AGENTS.md: {e}"))?;
Ok(format!(
"Wrote {} learnings to {}",
learnings.len(),
agents_path.display()
))
}