Skip to main content

aicx_parser/
card_header.rs

1//! Header-agnostic card header parsing.
2//!
3//! Canonical-store cards carry identity metadata in one of two header forms:
4//! the legacy single-line bracket header emitted by the v1 writer
5//! (`[project: … | agent: … | date: … | frame_kind: …]`) and the card schema
6//! v2 YAML frontmatter block (`---\nproject: …\n---`). Every in-repo reader
7//! goes through this module so neither form leaks bespoke parsing into
8//! consumers. The `.meta.json` sidecar stays authoritative — readers call
9//! `store::load_sidecar` first and fall back to [`parse_card_header`].
10
11use crate::timeline::FrameKind;
12
13const BRACKET_PREFIX: &str = "[project:";
14
15/// Card identity fields recovered from a chunk header (either form).
16#[derive(Debug, Clone, Default, PartialEq, Eq)]
17pub struct CardHeader {
18    pub project: Option<String>,
19    pub agent: Option<String>,
20    pub date: Option<String>,
21    pub frame_kind: Option<FrameKind>,
22}
23
24#[derive(Debug, Clone, PartialEq, Eq)]
25pub enum HeaderForm<'a> {
26    Bracket {
27        fields: &'a str,
28        body: &'a str,
29        text: &'a str,
30    },
31    Frontmatter {
32        header: CardHeader,
33        body: &'a str,
34        text: &'a str,
35    },
36}
37
38impl<'a> HeaderForm<'a> {
39    pub fn text(&self) -> &'a str {
40        match self {
41            Self::Bracket { text, .. } | Self::Frontmatter { text, .. } => text,
42        }
43    }
44
45    pub fn is_bracket(&self) -> bool {
46        matches!(self, Self::Bracket { .. })
47    }
48
49    pub fn is_frontmatter(&self) -> bool {
50        matches!(self, Self::Frontmatter { .. })
51    }
52}
53
54/// Single-line guard for the legacy bracket header. Line-stream filters
55/// (search previews, evidence excerpts, noise detection) use this so the
56/// bracket prefix has exactly one reader-side home.
57pub fn is_bracket_header_line(line: &str) -> bool {
58    line.trim_start().starts_with(BRACKET_PREFIX)
59}
60
61/// Parse the card header at the start of `text`, accepting both the bracket
62/// and the YAML-frontmatter form. Returns `None` when `text` carries no
63/// recognizable card header.
64pub fn parse_card_header(text: &str) -> Option<CardHeader> {
65    match header_form(text)? {
66        HeaderForm::Bracket { fields, .. } => Some(parse_bracket_fields(fields)),
67        HeaderForm::Frontmatter { header, .. } => Some(header),
68    }
69}
70
71/// Body of a card after its header (either form), with leading newlines
72/// trimmed. Text without a recognizable card header is returned unchanged.
73pub fn card_body(text: &str) -> &str {
74    match header_form(text) {
75        Some(HeaderForm::Bracket { body, .. }) => body,
76        Some(HeaderForm::Frontmatter { body, .. }) => body,
77        None => text,
78    }
79}
80
81pub fn header_form(text: &str) -> Option<HeaderForm<'_>> {
82    if let Some(rest) = text.strip_prefix(BRACKET_PREFIX) {
83        // Body semantics must stay byte-identical to the historical
84        // `chunk_body_after_header`: everything after the first line, with
85        // leading CR/LF trimmed; a header-only card has an empty body.
86        let (fields, body, header_text) = match rest.split_once('\n') {
87            Some((fields, body)) => {
88                let header_len = BRACKET_PREFIX.len() + fields.len();
89                (
90                    fields,
91                    body.trim_start_matches(['\r', '\n']),
92                    &text[..header_len],
93                )
94            }
95            None => (rest, "", text),
96        };
97        return Some(HeaderForm::Bracket {
98            fields,
99            body,
100            text: header_text,
101        });
102    }
103    frontmatter_form(text)
104}
105
106fn parse_bracket_fields(fields: &str) -> CardHeader {
107    let inner = fields.trim_end();
108    let inner = inner.strip_suffix(']').unwrap_or(inner);
109
110    let mut header = CardHeader::default();
111    for (idx, segment) in inner.split('|').enumerate() {
112        let segment = segment.trim();
113        if idx == 0 {
114            // `[project:` is already stripped; the first segment is the value.
115            header.project = non_empty(segment);
116            continue;
117        }
118        let Some((key, value)) = segment.split_once(':') else {
119            continue;
120        };
121        let value = value.trim();
122        match key.trim() {
123            "agent" => header.agent = non_empty(value),
124            "date" => header.date = non_empty(value),
125            "frame_kind" => header.frame_kind = FrameKind::parse(value),
126            _ => {}
127        }
128    }
129    header
130}
131
132fn frontmatter_form(text: &str) -> Option<HeaderForm<'_>> {
133    // The v2 writer anchors the delimiter at byte 0. This is stricter than
134    // the report-frontmatter parser on purpose: a card body must never be
135    // chopped because it merely opens with a horizontal rule.
136    let after_open = text
137        .strip_prefix("---\n")
138        .or_else(|| text.strip_prefix("---\r\n"))?;
139    let end = after_open.find("\n---")?;
140    let block = &after_open[..end];
141    let rest = &after_open[end + 4..];
142    let rest = rest.strip_prefix('\r').unwrap_or(rest);
143    // The closing delimiter must terminate its own line (`----` is content).
144    if !(rest.is_empty() || rest.starts_with('\n')) {
145        return None;
146    }
147
148    // Key gate: only a block carrying at least one card identity field is a
149    // card header; anything else stays part of the body untouched.
150    let header = parse_frontmatter_card_fields(block)?;
151    let body = rest.trim_start_matches(['\r', '\n']);
152    let header_text = text[..text.len() - body.len()].trim_end_matches(['\r', '\n']);
153    Some(HeaderForm::Frontmatter {
154        header,
155        body,
156        text: header_text,
157    })
158}
159
160fn parse_frontmatter_card_fields(block: &str) -> Option<CardHeader> {
161    let mut header = CardHeader::default();
162    let mut saw_card_field = false;
163
164    for raw_line in block.lines() {
165        let line = raw_line.trim();
166        if line.is_empty() || line.starts_with('#') {
167            continue;
168        }
169        let Some((key, value)) = line.split_once(':') else {
170            continue;
171        };
172        let value = value.trim().trim_matches(|ch| matches!(ch, '"' | '\''));
173        match key.trim() {
174            "project" => {
175                header.project = non_empty(value);
176                saw_card_field = true;
177            }
178            "agent" => {
179                header.agent = non_empty(value);
180                saw_card_field = true;
181            }
182            "date" => {
183                header.date = non_empty(value);
184                saw_card_field = true;
185            }
186            "frame_kind" => {
187                header.frame_kind = FrameKind::parse(value);
188                saw_card_field = true;
189            }
190            _ => {}
191        }
192    }
193
194    saw_card_field.then_some(header)
195}
196
197fn non_empty(value: &str) -> Option<String> {
198    let value = value.trim();
199    (!value.is_empty()).then(|| value.to_string())
200}
201
202#[cfg(test)]
203mod tests {
204    use super::*;
205
206    #[test]
207    fn bracket_and_frontmatter_forms_parse_to_same_card_header() {
208        let bracket = "[project: Loctree/aicx | agent: claude | date: 2026-07-02 | frame_kind: user_msg]\n\nbody";
209        let frontmatter = "---\nproject: Loctree/aicx\nagent: claude\ndate: 2026-07-02\nframe_kind: user_msg\n---\n\nbody";
210
211        let from_bracket = parse_card_header(bracket).expect("bracket header parses");
212        let from_frontmatter = parse_card_header(frontmatter).expect("frontmatter header parses");
213
214        assert_eq!(from_bracket, from_frontmatter);
215        assert_eq!(from_bracket.project.as_deref(), Some("Loctree/aicx"));
216        assert_eq!(from_bracket.agent.as_deref(), Some("claude"));
217        assert_eq!(from_bracket.date.as_deref(), Some("2026-07-02"));
218        assert_eq!(from_bracket.frame_kind, Some(FrameKind::UserMsg));
219        assert_eq!(card_body(bracket), card_body(frontmatter));
220        assert_eq!(card_body(bracket), "body");
221    }
222
223    #[test]
224    fn bracket_minimal_project_only_header_parses() {
225        let header = parse_card_header("[project: x]\nbody").expect("degenerate header parses");
226        assert_eq!(header.project.as_deref(), Some("x"));
227        assert_eq!(header.agent, None);
228        assert_eq!(header.date, None);
229        assert_eq!(header.frame_kind, None);
230    }
231
232    #[test]
233    fn card_body_strips_bracket_header_and_preserves_signals_block() {
234        let text = "[project: demo | agent: codex | date: 2026-03-15]\n\n[signals]\nDecision:\n- [decision] keep\n[/signals]\n\n[12:00:00] user: hello\n";
235        let body = card_body(text);
236        assert!(body.starts_with("[signals]"));
237        assert!(body.contains("[12:00:00] user: hello"));
238    }
239
240    #[test]
241    fn card_body_of_header_only_bracket_card_is_empty() {
242        assert_eq!(
243            card_body("[project: demo | agent: codex | date: 2026-03-15]\n\n"),
244            ""
245        );
246        assert_eq!(card_body("[project: demo]"), "");
247    }
248
249    #[test]
250    fn card_body_strips_frontmatter_block_and_preserves_body() {
251        let text = "---\nproject: demo\nagent: claude\ndate: 2026-07-02\n---\n\n[signals]\nDecision:\n- [decision] keep\n[/signals]\n\nreal content\n";
252        let body = card_body(text);
253        assert!(body.starts_with("[signals]"));
254        assert!(body.ends_with("real content\n"));
255    }
256
257    #[test]
258    fn non_card_frontmatter_and_plain_text_stay_untouched() {
259        // A leading horizontal-rule block without card identity keys is body
260        // content, not a header — chopping it would corrupt empty-body checks.
261        let rule_block = "---\nsome: yaml\nunrelated: field\n---\nreal body";
262        assert_eq!(card_body(rule_block), rule_block);
263        assert_eq!(parse_card_header(rule_block), None);
264
265        let plain = "no header here\njust text";
266        assert_eq!(card_body(plain), plain);
267        assert_eq!(parse_card_header(plain), None);
268
269        // `----` after the block is content, not a closing delimiter.
270        let dashes = "---\nproject: demo\n----\nbody";
271        assert_eq!(card_body(dashes), dashes);
272    }
273
274    #[test]
275    fn frontmatter_frame_kind_disagreeing_with_nothing_else_still_parses() {
276        let text = "---\nframe_kind: agent_reply\n---\nbody";
277        let header = parse_card_header(text).expect("frame_kind alone is a card field");
278        assert_eq!(header.frame_kind, Some(FrameKind::AgentReply));
279        assert_eq!(card_body(text), "body");
280    }
281
282    #[test]
283    fn is_bracket_header_line_matches_only_bracket_form() {
284        assert!(is_bracket_header_line(
285            "[project: demo | agent: claude | date: 2026-07-02]"
286        ));
287        assert!(is_bracket_header_line("  [project: demo]"));
288        assert!(!is_bracket_header_line("project: demo"));
289        assert!(!is_bracket_header_line("---"));
290        assert!(!is_bracket_header_line("[metadata] project: demo"));
291    }
292
293    #[test]
294    fn header_form_exposes_bracket_line_and_body() {
295        let text = "[project: demo | agent: codex]\r\n\r\nbody\n";
296        let form = header_form(text).expect("bracket form");
297        assert!(form.is_bracket());
298        assert_eq!(form.text(), "[project: demo | agent: codex]\r");
299        assert_eq!(card_body(text), "body\n");
300    }
301
302    #[test]
303    fn header_form_exposes_frontmatter_region_for_shared_readers() {
304        let text = "---\r\nproject: demo\r\nschema: card.v2\r\n---\r\n\r\nbody\n";
305        let form = header_form(text).expect("frontmatter form");
306        assert!(form.is_frontmatter());
307        assert_eq!(
308            form.text(),
309            "---\r\nproject: demo\r\nschema: card.v2\r\n---"
310        );
311        assert_eq!(card_body(text), "body\n");
312    }
313}