Skip to main content

git_paw/mcp/query/
learnings.rs

1//! Parses `.git-paw/session-learnings.md` into structured sections.
2//!
3//! The learnings file (produced by the v0.5.0 learnings aggregator) is a
4//! Markdown document with `### <section>` headings under timestamped
5//! `## Session Learnings — <ts>` blocks. We flatten every `### ` section into
6//! a `category` + `entries` record. When the file is absent we return the four
7//! canonical v0.5.0 sections as empty arrays so the client sees a stable shape.
8
9#[cfg(test)]
10use std::path::Path;
11
12use rmcp::schemars;
13use serde::Serialize;
14
15use crate::mcp::RepoContext;
16
17/// The four canonical v0.5.0 learning sections, in display order.
18const CANONICAL_SECTIONS: &[&str] = &[
19    "Conflict events",
20    "Where agents got stuck",
21    "Recovery cycles",
22    "Permission patterns",
23];
24
25/// One parsed learnings section.
26#[derive(Debug, Clone, Serialize, schemars::JsonSchema, PartialEq, Eq)]
27pub struct LearningSection {
28    /// Section heading (e.g. "Conflict events").
29    pub category: String,
30    /// Body entries — non-empty content lines with leading list markers
31    /// stripped.
32    pub entries: Vec<String>,
33}
34
35/// Reads and parses the repository's session-learnings file, or returns the
36/// canonical empty sections when no file exists.
37#[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
62/// Parses learnings Markdown into sections, merging duplicate `### ` headings
63/// across timestamped blocks. Always includes the canonical sections (empty if
64/// absent in the file) so the shape is stable.
65fn parse(content: &str) -> Vec<LearningSection> {
66    // Preserve first-seen order of headings while merging entries.
67    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        // A new timestamped block resets the current section.
86        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/// Resolves the path that [`learnings`] would read (for callers wanting to
114/// report it). Returns `None` when the repo has no `.git-paw/` dir.
115#[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        // Canonical sections still present even when absent from the file.
160        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}