use crate::timeline::FrameKind;
const BRACKET_PREFIX: &str = "[project:";
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct CardHeader {
pub project: Option<String>,
pub agent: Option<String>,
pub date: Option<String>,
pub frame_kind: Option<FrameKind>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum HeaderForm<'a> {
Bracket {
fields: &'a str,
body: &'a str,
text: &'a str,
},
Frontmatter {
header: CardHeader,
body: &'a str,
text: &'a str,
},
}
impl<'a> HeaderForm<'a> {
pub fn text(&self) -> &'a str {
match self {
Self::Bracket { text, .. } | Self::Frontmatter { text, .. } => text,
}
}
pub fn is_bracket(&self) -> bool {
matches!(self, Self::Bracket { .. })
}
pub fn is_frontmatter(&self) -> bool {
matches!(self, Self::Frontmatter { .. })
}
}
pub fn is_bracket_header_line(line: &str) -> bool {
line.trim_start().starts_with(BRACKET_PREFIX)
}
pub fn parse_card_header(text: &str) -> Option<CardHeader> {
match header_form(text)? {
HeaderForm::Bracket { fields, .. } => Some(parse_bracket_fields(fields)),
HeaderForm::Frontmatter { header, .. } => Some(header),
}
}
pub fn card_body(text: &str) -> &str {
match header_form(text) {
Some(HeaderForm::Bracket { body, .. }) => body,
Some(HeaderForm::Frontmatter { body, .. }) => body,
None => text,
}
}
pub fn header_form(text: &str) -> Option<HeaderForm<'_>> {
if let Some(rest) = text.strip_prefix(BRACKET_PREFIX) {
let (fields, body, header_text) = match rest.split_once('\n') {
Some((fields, body)) => {
let header_len = BRACKET_PREFIX.len() + fields.len();
(
fields,
body.trim_start_matches(['\r', '\n']),
&text[..header_len],
)
}
None => (rest, "", text),
};
return Some(HeaderForm::Bracket {
fields,
body,
text: header_text,
});
}
frontmatter_form(text)
}
fn parse_bracket_fields(fields: &str) -> CardHeader {
let inner = fields.trim_end();
let inner = inner.strip_suffix(']').unwrap_or(inner);
let mut header = CardHeader::default();
for (idx, segment) in inner.split('|').enumerate() {
let segment = segment.trim();
if idx == 0 {
header.project = non_empty(segment);
continue;
}
let Some((key, value)) = segment.split_once(':') else {
continue;
};
let value = value.trim();
match key.trim() {
"agent" => header.agent = non_empty(value),
"date" => header.date = non_empty(value),
"frame_kind" => header.frame_kind = FrameKind::parse(value),
_ => {}
}
}
header
}
fn frontmatter_form(text: &str) -> Option<HeaderForm<'_>> {
let after_open = text
.strip_prefix("---\n")
.or_else(|| text.strip_prefix("---\r\n"))?;
let end = after_open.find("\n---")?;
let block = &after_open[..end];
let rest = &after_open[end + 4..];
let rest = rest.strip_prefix('\r').unwrap_or(rest);
if !(rest.is_empty() || rest.starts_with('\n')) {
return None;
}
let header = parse_frontmatter_card_fields(block)?;
let body = rest.trim_start_matches(['\r', '\n']);
let header_text = text[..text.len() - body.len()].trim_end_matches(['\r', '\n']);
Some(HeaderForm::Frontmatter {
header,
body,
text: header_text,
})
}
fn parse_frontmatter_card_fields(block: &str) -> Option<CardHeader> {
let mut header = CardHeader::default();
let mut saw_card_field = false;
for raw_line in block.lines() {
let line = raw_line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
let Some((key, value)) = line.split_once(':') else {
continue;
};
let value = value.trim().trim_matches(|ch| matches!(ch, '"' | '\''));
match key.trim() {
"project" => {
header.project = non_empty(value);
saw_card_field = true;
}
"agent" => {
header.agent = non_empty(value);
saw_card_field = true;
}
"date" => {
header.date = non_empty(value);
saw_card_field = true;
}
"frame_kind" => {
header.frame_kind = FrameKind::parse(value);
saw_card_field = true;
}
_ => {}
}
}
saw_card_field.then_some(header)
}
fn non_empty(value: &str) -> Option<String> {
let value = value.trim();
(!value.is_empty()).then(|| value.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn bracket_and_frontmatter_forms_parse_to_same_card_header() {
let bracket = "[project: Loctree/aicx | agent: claude | date: 2026-07-02 | frame_kind: user_msg]\n\nbody";
let frontmatter = "---\nproject: Loctree/aicx\nagent: claude\ndate: 2026-07-02\nframe_kind: user_msg\n---\n\nbody";
let from_bracket = parse_card_header(bracket).expect("bracket header parses");
let from_frontmatter = parse_card_header(frontmatter).expect("frontmatter header parses");
assert_eq!(from_bracket, from_frontmatter);
assert_eq!(from_bracket.project.as_deref(), Some("Loctree/aicx"));
assert_eq!(from_bracket.agent.as_deref(), Some("claude"));
assert_eq!(from_bracket.date.as_deref(), Some("2026-07-02"));
assert_eq!(from_bracket.frame_kind, Some(FrameKind::UserMsg));
assert_eq!(card_body(bracket), card_body(frontmatter));
assert_eq!(card_body(bracket), "body");
}
#[test]
fn bracket_minimal_project_only_header_parses() {
let header = parse_card_header("[project: x]\nbody").expect("degenerate header parses");
assert_eq!(header.project.as_deref(), Some("x"));
assert_eq!(header.agent, None);
assert_eq!(header.date, None);
assert_eq!(header.frame_kind, None);
}
#[test]
fn card_body_strips_bracket_header_and_preserves_signals_block() {
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";
let body = card_body(text);
assert!(body.starts_with("[signals]"));
assert!(body.contains("[12:00:00] user: hello"));
}
#[test]
fn card_body_of_header_only_bracket_card_is_empty() {
assert_eq!(
card_body("[project: demo | agent: codex | date: 2026-03-15]\n\n"),
""
);
assert_eq!(card_body("[project: demo]"), "");
}
#[test]
fn card_body_strips_frontmatter_block_and_preserves_body() {
let text = "---\nproject: demo\nagent: claude\ndate: 2026-07-02\n---\n\n[signals]\nDecision:\n- [decision] keep\n[/signals]\n\nreal content\n";
let body = card_body(text);
assert!(body.starts_with("[signals]"));
assert!(body.ends_with("real content\n"));
}
#[test]
fn non_card_frontmatter_and_plain_text_stay_untouched() {
let rule_block = "---\nsome: yaml\nunrelated: field\n---\nreal body";
assert_eq!(card_body(rule_block), rule_block);
assert_eq!(parse_card_header(rule_block), None);
let plain = "no header here\njust text";
assert_eq!(card_body(plain), plain);
assert_eq!(parse_card_header(plain), None);
let dashes = "---\nproject: demo\n----\nbody";
assert_eq!(card_body(dashes), dashes);
}
#[test]
fn frontmatter_frame_kind_disagreeing_with_nothing_else_still_parses() {
let text = "---\nframe_kind: agent_reply\n---\nbody";
let header = parse_card_header(text).expect("frame_kind alone is a card field");
assert_eq!(header.frame_kind, Some(FrameKind::AgentReply));
assert_eq!(card_body(text), "body");
}
#[test]
fn is_bracket_header_line_matches_only_bracket_form() {
assert!(is_bracket_header_line(
"[project: demo | agent: claude | date: 2026-07-02]"
));
assert!(is_bracket_header_line(" [project: demo]"));
assert!(!is_bracket_header_line("project: demo"));
assert!(!is_bracket_header_line("---"));
assert!(!is_bracket_header_line("[metadata] project: demo"));
}
#[test]
fn header_form_exposes_bracket_line_and_body() {
let text = "[project: demo | agent: codex]\r\n\r\nbody\n";
let form = header_form(text).expect("bracket form");
assert!(form.is_bracket());
assert_eq!(form.text(), "[project: demo | agent: codex]\r");
assert_eq!(card_body(text), "body\n");
}
#[test]
fn header_form_exposes_frontmatter_region_for_shared_readers() {
let text = "---\r\nproject: demo\r\nschema: card.v2\r\n---\r\n\r\nbody\n";
let form = header_form(text).expect("frontmatter form");
assert!(form.is_frontmatter());
assert_eq!(
form.text(),
"---\r\nproject: demo\r\nschema: card.v2\r\n---"
);
assert_eq!(card_body(text), "body\n");
}
}