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
}
fn merge_marker_section(existing: &str, section: &str, title: &str) -> String {
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!(
"{}\n\n{}",
before.trim_end(),
section.trim_end().to_owned() + after
)
} else if existing.is_empty() {
format!("# {title}\n\n{section}")
} else {
format!("{}\n\n{section}", existing.trim_end())
}
}
fn apply_to_memory_file(
path: &std::path::Path,
section: &str,
create_if_missing: bool,
) -> Result<bool, String> {
let exists = path.exists();
if !exists && !create_if_missing {
return Ok(false);
}
let existing = if exists {
std::fs::read_to_string(path)
.map_err(|e| format!("Failed to read {}: {e}", path.display()))?
} else {
String::new()
};
let title = path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("AGENTS.md");
let updated = merge_marker_section(&existing, section, title);
crate::config_io::write_atomic(path, &updated)
.map_err(|e| format!("Failed to write {}: {e}", path.display()))?;
Ok(true)
}
pub fn apply_learnings(project_root: &str, learnings: &[Learning]) -> Result<Vec<String>, String> {
let section = format_agents_section(learnings);
if section.is_empty() {
return Ok(Vec::new());
}
let root = std::path::Path::new(project_root);
let mut written = Vec::new();
if apply_to_memory_file(&root.join("AGENTS.md"), §ion, true)? {
written.push("AGENTS.md".to_string());
}
if apply_to_memory_file(&root.join("CLAUDE.local.md"), §ion, false)? {
written.push("CLAUDE.local.md".to_string());
}
Ok(written)
}
pub fn apply_to_agents_md(project_root: &str, learnings: &[Learning]) -> Result<String, String> {
let section = format_agents_section(learnings);
if section.is_empty() {
return Ok("No learnings to write (need >=2 occurrences with >=50% confidence).".into());
}
let path = std::path::Path::new(project_root).join("AGENTS.md");
apply_to_memory_file(&path, §ion, true)?;
Ok(format!(
"Wrote {} learnings to {}",
learnings.len(),
path.display()
))
}
#[cfg(test)]
mod tests {
use super::*;
fn sample() -> Vec<Learning> {
vec![Learning {
category: "Build".into(),
trigger: "cargo E0507".into(),
resolution: "clone before the move".into(),
confidence: 0.9,
occurrences: 3,
sessions: 2,
}]
}
#[test]
fn apply_learnings_creates_agents_and_is_idempotent() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path().to_string_lossy().to_string();
let written = apply_learnings(&root, &sample()).unwrap();
assert_eq!(written, vec!["AGENTS.md".to_string()]);
let agents = dir.path().join("AGENTS.md");
let body = std::fs::read_to_string(&agents).unwrap();
assert!(body.contains("cargo E0507"));
assert!(body.contains(AGENTS_MARKER_START));
apply_learnings(&root, &sample()).unwrap();
let body2 = std::fs::read_to_string(&agents).unwrap();
assert_eq!(
body2.matches(AGENTS_MARKER_START).count(),
1,
"the marker section must be replaced, never duplicated"
);
}
#[test]
fn apply_learnings_updates_claude_local_only_when_present() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path().to_string_lossy().to_string();
let claude = dir.path().join("CLAUDE.local.md");
std::fs::write(&claude, "# My notes\n\nkeep this line\n").unwrap();
let written = apply_learnings(&root, &sample()).unwrap();
assert!(written.contains(&"AGENTS.md".to_string()));
assert!(written.contains(&"CLAUDE.local.md".to_string()));
let body = std::fs::read_to_string(&claude).unwrap();
assert!(body.contains("keep this line"), "user content is preserved");
assert!(body.contains("cargo E0507"), "learnings are injected");
}
#[test]
fn apply_learnings_skips_absent_claude_local() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path().to_string_lossy().to_string();
let written = apply_learnings(&root, &sample()).unwrap();
assert_eq!(
written,
vec!["AGENTS.md".to_string()],
"CLAUDE.local.md is never created unsolicited"
);
assert!(!dir.path().join("CLAUDE.local.md").exists());
}
#[test]
fn apply_learnings_empty_writes_nothing() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path().to_string_lossy().to_string();
let written = apply_learnings(&root, &[]).unwrap();
assert!(written.is_empty());
assert!(!dir.path().join("AGENTS.md").exists());
}
}