Skip to main content

convergio_knowledge/
seed_parsers.rs

1//! Content parsers for the knowledge seed pipeline.
2//!
3//! Extracts structured entries from markdown files:
4//! AGENTS.md learnings, ADR decisions,
5//! and CONSTITUTION.md rules.
6
7use std::path::Path;
8
9use tracing::warn;
10
11pub type Entry = (String, String); // (content, source_type)
12
13/// AGENTS.md "Key learnings" section.
14pub fn parse_learnings(content: &str) -> Vec<Entry> {
15    let mut out = Vec::new();
16    let mut active = false;
17    for line in content.lines() {
18        if line.contains("Key learnings") && line.starts_with('#') {
19            active = true;
20            continue;
21        }
22        if active && line.starts_with('#') {
23            break;
24        }
25        if active {
26            if let Some(t) = line.trim().strip_prefix("- ") {
27                if !t.is_empty() {
28                    out.push((t.replace("**", ""), "learning".into()));
29                }
30            }
31        }
32    }
33    out
34}
35
36/// ADR decision files from docs/adr/.
37pub fn parse_adr_files(root: &Path) -> Vec<Entry> {
38    let Ok(dir) = std::fs::read_dir(root.join("docs/adr")) else {
39        warn!("seed: docs/adr not found, skipping ADRs");
40        return vec![];
41    };
42    let mut out = Vec::new();
43    for entry in dir.flatten() {
44        let fname = entry.file_name().to_string_lossy().to_string();
45        if !fname.starts_with("ADR-") || !fname.ends_with(".md") {
46            continue;
47        }
48        let Ok(raw) = std::fs::read_to_string(entry.path()) else {
49            continue;
50        };
51        let title = raw
52            .lines()
53            .find(|l| l.starts_with("# "))
54            .map(|l| l.trim_start_matches("# ").to_string())
55            .unwrap_or_else(|| fname.clone());
56        let mut secs = String::new();
57        let mut cap = false;
58        for line in raw.lines() {
59            if line.starts_with("## Decision") || line.starts_with("## Consequences") {
60                cap = true;
61            } else if cap && line.starts_with("## ") {
62                cap = false;
63            }
64            if cap {
65                secs.push_str(line);
66                secs.push('\n');
67            }
68        }
69        let text = if secs.is_empty() {
70            format!("{title}\n\n{}", &raw[..raw.len().min(500)])
71        } else {
72            format!("{title}\n\n{secs}")
73        };
74        out.push((text, "decision".into()));
75    }
76    out
77}
78
79/// Numbered sections (phases, sessions, learnings).
80pub fn parse_phases(content: &str) -> Vec<Entry> {
81    let mut out = Vec::new();
82    let mut heading: Option<String> = None;
83    let mut buf = String::new();
84    let is_section = |line: &str| {
85        line.starts_with("## ") && line.len() > 4 && line.as_bytes()[3].is_ascii_digit()
86    };
87    for line in content.lines() {
88        if is_section(line) || (line.starts_with("## ") && heading.is_some()) {
89            if let Some(h) = heading.take() {
90                if !buf.trim().is_empty() {
91                    out.push((format!("{h}\n{}", buf.trim()), "learning".into()));
92                }
93            }
94            buf.clear();
95            if is_section(line) {
96                heading = Some(line.to_string());
97            }
98            continue;
99        }
100        if heading.is_some() {
101            buf.push_str(line);
102            buf.push('\n');
103        }
104    }
105    if let Some(h) = heading {
106        if !buf.trim().is_empty() {
107            out.push((format!("{h}\n{}", buf.trim()), "learning".into()));
108        }
109    }
110    out
111}
112
113/// CONSTITUTION.md rules (numbered list items).
114pub fn parse_constitution(content: &str) -> Vec<Entry> {
115    let mut out = Vec::new();
116    let mut rule = String::new();
117    for line in content.lines() {
118        let t = line.trim();
119        let numbered = t.len() > 2 && t.as_bytes()[0].is_ascii_digit() && t.contains(". ");
120        if numbered {
121            if !rule.is_empty() {
122                out.push((rule.clone(), "decision".into()));
123            }
124            rule = t.to_string();
125        } else if !rule.is_empty() && !t.is_empty() && !t.starts_with('#') {
126            rule.push(' ');
127            rule.push_str(t);
128        } else if t.starts_with('#') && !rule.is_empty() {
129            out.push((rule.clone(), "decision".into()));
130            rule.clear();
131        }
132    }
133    if !rule.is_empty() {
134        out.push((rule, "decision".into()));
135    }
136    out
137}
138
139#[cfg(test)]
140mod tests {
141    use super::*;
142
143    #[test]
144    fn parse_learnings_extracts_and_strips_bold() {
145        let md = "### Key learnings\n- First (#1)\n- **Bold (#2)**\n## Next";
146        let items = parse_learnings(md);
147        assert_eq!(items.len(), 2);
148        assert_eq!(items[0].1, "learning");
149        assert!(!items[1].0.contains("**"));
150    }
151
152    #[test]
153    fn parse_phases_extracts() {
154        let md = "# Top\n## 1. First\nOne\n## 2. Second\nTwo\n## Other\n";
155        let items = parse_phases(md);
156        assert_eq!(items.len(), 2);
157        assert!(items[0].0.contains("1. First"));
158    }
159
160    #[test]
161    fn parse_constitution_numbered() {
162        let md = "# Title\n1. First rule\n2. Second rule\nwith more\n";
163        let items = parse_constitution(md);
164        assert_eq!(items.len(), 2);
165        assert!(items[1].0.contains("with more"));
166    }
167}