#[cfg(test)]
use std::path::Path;
use rmcp::schemars;
use serde::Serialize;
use crate::mcp::RepoContext;
const CANONICAL_SECTIONS: &[&str] = &[
"Conflict events",
"Where agents got stuck",
"Recovery cycles",
"Permission patterns",
];
#[derive(Debug, Clone, Serialize, schemars::JsonSchema, PartialEq, Eq)]
pub struct LearningSection {
pub category: String,
pub entries: Vec<String>,
}
#[must_use]
pub fn learnings(ctx: &RepoContext) -> Vec<LearningSection> {
let path = ctx
.git_paw_dir
.as_ref()
.map(|d| d.join("session-learnings.md"));
match path
.as_deref()
.and_then(|p| std::fs::read_to_string(p).ok())
{
Some(content) => parse(&content),
None => empty_sections(),
}
}
fn empty_sections() -> Vec<LearningSection> {
CANONICAL_SECTIONS
.iter()
.map(|c| LearningSection {
category: (*c).to_string(),
entries: Vec::new(),
})
.collect()
}
fn parse(content: &str) -> Vec<LearningSection> {
let mut order: Vec<String> = CANONICAL_SECTIONS
.iter()
.map(|s| (*s).to_string())
.collect();
let mut map: std::collections::HashMap<String, Vec<String>> =
order.iter().map(|c| (c.clone(), Vec::new())).collect();
let mut current: Option<String> = None;
for line in content.lines() {
if let Some(heading) = line.strip_prefix("### ") {
let heading = heading.trim().to_string();
if !map.contains_key(&heading) {
map.insert(heading.clone(), Vec::new());
order.push(heading.clone());
}
current = Some(heading);
continue;
}
if line.starts_with("## ") {
current = None;
continue;
}
if let Some(section) = current.as_ref() {
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
let entry = trimmed
.strip_prefix("- ")
.or_else(|| trimmed.strip_prefix("* "))
.unwrap_or(trimmed)
.to_string();
map.get_mut(section).expect("section present").push(entry);
}
}
order
.into_iter()
.map(|category| {
let entries = map.remove(&category).unwrap_or_default();
LearningSection { category, entries }
})
.collect()
}
#[must_use]
pub fn learnings_path(ctx: &RepoContext) -> Option<std::path::PathBuf> {
ctx.git_paw_dir
.as_ref()
.map(|d| d.join("session-learnings.md"))
}
#[cfg(test)]
mod tests {
use super::*;
fn ctx_with(dir: Option<&Path>) -> RepoContext {
RepoContext {
root: dir.map_or_else(|| std::path::PathBuf::from("/tmp"), Path::to_path_buf),
git_paw_dir: dir.map(Path::to_path_buf),
broker_url: None,
server_name: "git-paw".to_string(),
}
}
#[test]
fn missing_file_returns_canonical_empty_sections() {
let sections = learnings(&ctx_with(None));
assert_eq!(sections.len(), 4);
assert_eq!(sections[0].category, "Conflict events");
assert!(sections.iter().all(|s| s.entries.is_empty()));
}
#[test]
fn parses_sections_and_entries() {
let md = "## Session Learnings — 2026-01-01\n\n\
### Conflict events\n- forward overlap on src/a.rs\n\n\
### Permission patterns\n- approved `cargo test`\n- approved `just check`\n";
let sections = parse(md);
let conflict = sections
.iter()
.find(|s| s.category == "Conflict events")
.unwrap();
assert_eq!(conflict.entries, vec!["forward overlap on src/a.rs"]);
let perms = sections
.iter()
.find(|s| s.category == "Permission patterns")
.unwrap();
assert_eq!(perms.entries.len(), 2);
assert!(sections.iter().any(|s| s.category == "Recovery cycles"));
}
#[test]
fn non_canonical_qualitative_section_is_included() {
let md = "### Documentation gaps\n- AGENTS.md missing MCP dep note\n";
let sections = parse(md);
let doc = sections.iter().find(|s| s.category == "Documentation gaps");
assert!(doc.is_some(), "qualitative sections should be parsed too");
assert_eq!(doc.unwrap().entries.len(), 1);
}
#[test]
fn reads_from_git_paw_dir() {
let tmp = tempfile::tempdir().unwrap();
std::fs::write(
tmp.path().join("session-learnings.md"),
"### Conflict events\n- something\n",
)
.unwrap();
let sections = learnings(&ctx_with(Some(tmp.path())));
let conflict = sections
.iter()
.find(|s| s.category == "Conflict events")
.unwrap();
assert_eq!(conflict.entries, vec!["something"]);
}
}