git_paw/mcp/query/
learnings.rs1#[cfg(test)]
10use std::path::Path;
11
12use rmcp::schemars;
13use serde::Serialize;
14
15use crate::mcp::RepoContext;
16
17const CANONICAL_SECTIONS: &[&str] = &[
19 "Conflict events",
20 "Where agents got stuck",
21 "Recovery cycles",
22 "Permission patterns",
23];
24
25#[derive(Debug, Clone, Serialize, schemars::JsonSchema, PartialEq, Eq)]
27pub struct LearningSection {
28 pub category: String,
30 pub entries: Vec<String>,
33}
34
35#[must_use]
38pub fn learnings(ctx: &RepoContext) -> Vec<LearningSection> {
39 let path = ctx
40 .git_paw_dir
41 .as_ref()
42 .map(|d| d.join("session-learnings.md"));
43 match path
44 .as_deref()
45 .and_then(|p| std::fs::read_to_string(p).ok())
46 {
47 Some(content) => parse(&content),
48 None => empty_sections(),
49 }
50}
51
52fn empty_sections() -> Vec<LearningSection> {
53 CANONICAL_SECTIONS
54 .iter()
55 .map(|c| LearningSection {
56 category: (*c).to_string(),
57 entries: Vec::new(),
58 })
59 .collect()
60}
61
62fn parse(content: &str) -> Vec<LearningSection> {
66 let mut order: Vec<String> = CANONICAL_SECTIONS
68 .iter()
69 .map(|s| (*s).to_string())
70 .collect();
71 let mut map: std::collections::HashMap<String, Vec<String>> =
72 order.iter().map(|c| (c.clone(), Vec::new())).collect();
73
74 let mut current: Option<String> = None;
75 for line in content.lines() {
76 if let Some(heading) = line.strip_prefix("### ") {
77 let heading = heading.trim().to_string();
78 if !map.contains_key(&heading) {
79 map.insert(heading.clone(), Vec::new());
80 order.push(heading.clone());
81 }
82 current = Some(heading);
83 continue;
84 }
85 if line.starts_with("## ") {
87 current = None;
88 continue;
89 }
90 if let Some(section) = current.as_ref() {
91 let trimmed = line.trim();
92 if trimmed.is_empty() {
93 continue;
94 }
95 let entry = trimmed
96 .strip_prefix("- ")
97 .or_else(|| trimmed.strip_prefix("* "))
98 .unwrap_or(trimmed)
99 .to_string();
100 map.get_mut(section).expect("section present").push(entry);
101 }
102 }
103
104 order
105 .into_iter()
106 .map(|category| {
107 let entries = map.remove(&category).unwrap_or_default();
108 LearningSection { category, entries }
109 })
110 .collect()
111}
112
113#[must_use]
116pub fn learnings_path(ctx: &RepoContext) -> Option<std::path::PathBuf> {
117 ctx.git_paw_dir
118 .as_ref()
119 .map(|d| d.join("session-learnings.md"))
120}
121
122#[cfg(test)]
123mod tests {
124 use super::*;
125
126 fn ctx_with(dir: Option<&Path>) -> RepoContext {
127 RepoContext {
128 root: dir.map_or_else(|| std::path::PathBuf::from("/tmp"), Path::to_path_buf),
129 git_paw_dir: dir.map(Path::to_path_buf),
130 broker_url: None,
131 server_name: "git-paw".to_string(),
132 }
133 }
134
135 #[test]
136 fn missing_file_returns_canonical_empty_sections() {
137 let sections = learnings(&ctx_with(None));
138 assert_eq!(sections.len(), 4);
139 assert_eq!(sections[0].category, "Conflict events");
140 assert!(sections.iter().all(|s| s.entries.is_empty()));
141 }
142
143 #[test]
144 fn parses_sections_and_entries() {
145 let md = "## Session Learnings — 2026-01-01\n\n\
146 ### Conflict events\n- forward overlap on src/a.rs\n\n\
147 ### Permission patterns\n- approved `cargo test`\n- approved `just check`\n";
148 let sections = parse(md);
149 let conflict = sections
150 .iter()
151 .find(|s| s.category == "Conflict events")
152 .unwrap();
153 assert_eq!(conflict.entries, vec!["forward overlap on src/a.rs"]);
154 let perms = sections
155 .iter()
156 .find(|s| s.category == "Permission patterns")
157 .unwrap();
158 assert_eq!(perms.entries.len(), 2);
159 assert!(sections.iter().any(|s| s.category == "Recovery cycles"));
161 }
162
163 #[test]
164 fn non_canonical_qualitative_section_is_included() {
165 let md = "### Documentation gaps\n- AGENTS.md missing MCP dep note\n";
166 let sections = parse(md);
167 let doc = sections.iter().find(|s| s.category == "Documentation gaps");
168 assert!(doc.is_some(), "qualitative sections should be parsed too");
169 assert_eq!(doc.unwrap().entries.len(), 1);
170 }
171
172 #[test]
173 fn reads_from_git_paw_dir() {
174 let tmp = tempfile::tempdir().unwrap();
175 std::fs::write(
176 tmp.path().join("session-learnings.md"),
177 "### Conflict events\n- something\n",
178 )
179 .unwrap();
180 let sections = learnings(&ctx_with(Some(tmp.path())));
181 let conflict = sections
182 .iter()
183 .find(|s| s.category == "Conflict events")
184 .unwrap();
185 assert_eq!(conflict.entries, vec!["something"]);
186 }
187}