aicx_parser/
card_header.rs1use crate::timeline::FrameKind;
12
13const BRACKET_PREFIX: &str = "[project:";
14
15#[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
54pub fn is_bracket_header_line(line: &str) -> bool {
58 line.trim_start().starts_with(BRACKET_PREFIX)
59}
60
61pub 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
71pub 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 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 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 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 if !(rest.is_empty() || rest.starts_with('\n')) {
145 return None;
146 }
147
148 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 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 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}