Skip to main content

agent_doc_core/
topic.rs

1//! # Module: topic
2//!
3//! ## Spec
4//! - Pure text parsing of `### Re:` topic sections from an exchange component body.
5//! - Splits a component body into preamble (before the first `### Re:` heading), the per-topic
6//!   sections, and any trailing content after a managed `<!-- agent:boundary: -->` marker.
7//! - Boundary markers are managed by the binary and are never archived, so they are stripped
8//!   from the parsed output.
9//!
10//! ## Agentic Contracts
11//! - Lives in `agent-doc-core` so both the compaction layer (orchestration) and the archive
12//!   index (the SQLite crate) can parse topic sections without a crate cycle.
13//!
14//! ## Evals
15//! - parse_topic_sections_basic: `### Re:` headings split into sections
16//! - parse_topic_sections_strips_boundary_marker: boundary markers excluded from sections
17
18/// Parsed topic-section view of an exchange component body.
19#[derive(Debug, Default)]
20pub struct TopicSections {
21    /// Content before the first `### Re:` heading.
22    pub preamble: String,
23    /// One entry per `### Re:` topic heading, including the heading line.
24    pub sections: Vec<String>,
25    /// Content after a managed `<!-- agent:boundary: -->` marker.
26    pub trailing: String,
27}
28
29/// Split a component body into its preamble and per-topic sections.
30pub fn parse_topic_sections(content: &str) -> (String, Vec<String>) {
31    let parsed = parse_topic_sections_with_tail(content);
32    (parsed.preamble, parsed.sections)
33}
34
35/// Split a component body into preamble, per-topic sections, and post-boundary trailing content.
36pub fn parse_topic_sections_with_tail(content: &str) -> TopicSections {
37    let mut preamble = String::new();
38    let mut sections: Vec<String> = Vec::new();
39    let mut current: Option<String> = None;
40    let mut found_first = false;
41    let mut after_boundary = false;
42    let mut trailing = String::new();
43
44    for line in content.lines() {
45        // Strip boundary markers — they are managed by the binary, not archived
46        if line.starts_with("<!-- agent:boundary:") {
47            after_boundary = true;
48            continue;
49        }
50        if after_boundary {
51            trailing.push_str(line);
52            trailing.push('\n');
53            continue;
54        }
55
56        if line.starts_with("### Re:") || line.starts_with("#### Re:") || line.starts_with("## Re:")
57        {
58            if let Some(prev) = current.take() {
59                sections.push(prev);
60            }
61            found_first = true;
62            current = Some(format!("{}\n", line));
63        } else if found_first {
64            let section = current.get_or_insert_with(String::new);
65            section.push_str(line);
66            section.push('\n');
67        } else {
68            preamble.push_str(line);
69            preamble.push('\n');
70        }
71    }
72
73    if let Some(last) = current {
74        sections.push(last);
75    }
76
77    TopicSections {
78        preamble,
79        sections,
80        trailing,
81    }
82}
83
84#[cfg(test)]
85mod tests {
86    use super::*;
87
88    #[test]
89    fn parse_topic_sections_basic() {
90        let content = "intro\n### Re: a\nbody a\n### Re: b\nbody b\n";
91        let (preamble, sections) = parse_topic_sections(content);
92        assert_eq!(preamble.trim(), "intro");
93        assert_eq!(sections.len(), 2);
94        assert!(sections[0].starts_with("### Re: a"));
95        assert!(sections[1].starts_with("### Re: b"));
96    }
97
98    #[test]
99    fn parse_topic_sections_strips_boundary_marker() {
100        let content = "### Re: a\nbody a\n<!-- agent:boundary:abcd1234 -->\ntail\n";
101        let parsed = parse_topic_sections_with_tail(content);
102        assert_eq!(parsed.sections.len(), 1);
103        assert!(!parsed.sections[0].contains("boundary"));
104        assert_eq!(parsed.trailing.trim(), "tail");
105    }
106
107    #[test]
108    fn parse_topic_sections_no_re_headings() {
109        let content = "just preamble\nmore preamble\n";
110        let (preamble, sections) = parse_topic_sections(content);
111        assert!(sections.is_empty());
112        assert_eq!(preamble.trim(), "just preamble\nmore preamble");
113    }
114}