cartulary 0.3.0-alpha.1

The knowledge layer of your project — decisions, issues, docs, all in one place.
Documentation
//! GFM-style section-marker alerts inside a record body.
//!
//! Per DDR-01861F1CBBFDD, a blockquote whose first line is exactly
//! `[!MARKER]` carries a semantic section that consumers (the
//! `cartu decisions` brief, the site renderer) extract from the body.
//! This module owns the pure parser shared by every consumer.

use pulldown_cmark::{Event, Parser, Tag, TagEnd};

use super::body::Body;

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Alert {
    /// Upper-case ASCII marker name, e.g. `DECISION`. Stored without
    /// the `[!` and `]` delimiters.
    pub marker: String,
    /// Inner Markdown content with the `[!MARKER]` line removed and
    /// the `>` blockquote prefixes stripped from every line.
    pub content: String,
}

/// Walk `body` and return every GFM-style alert in source order.
///
/// Blockquotes that do not start with a valid `[!MARKER]` line are
/// silently skipped (they are ordinary blockquotes). Nested blockquotes
/// are not inspected — only top-level ones can carry a marker.
pub fn extract_alerts(body: &Body) -> Vec<Alert> {
    let source = body.as_str();
    let mut alerts = Vec::new();
    let mut depth: usize = 0;
    let mut start: Option<usize> = None;
    for (event, range) in Parser::new(source).into_offset_iter() {
        match event {
            Event::Start(Tag::BlockQuote(_)) => {
                if depth == 0 {
                    start = Some(range.start);
                }
                depth += 1;
            }
            Event::End(TagEnd::BlockQuote) => {
                depth = depth.saturating_sub(1);
                if depth == 0 {
                    if let Some(s) = start.take() {
                        if let Some(alert) = parse_alert(&source[s..range.end]) {
                            alerts.push(alert);
                        }
                    }
                }
            }
            _ => {}
        }
    }
    alerts
}

fn parse_alert(slice: &str) -> Option<Alert> {
    let mut lines: Vec<&str> = Vec::new();
    for raw in slice.lines() {
        let trimmed = raw.trim_start();
        let inner = trimmed.strip_prefix('>')?;
        let inner = inner.strip_prefix(' ').unwrap_or(inner);
        lines.push(inner);
    }
    let first = lines.first()?.trim_end();
    let marker = parse_marker(first)?;
    let content = lines
        .get(1..)
        .map(|rest| rest.join("\n").trim_end().to_string())
        .unwrap_or_default();
    Some(Alert { marker, content })
}

fn parse_marker(line: &str) -> Option<String> {
    let inner = line.strip_prefix("[!")?.strip_suffix(']')?;
    if inner.is_empty() || !inner.chars().all(|c| c.is_ascii_uppercase()) {
        return None;
    }
    Some(inner.to_string())
}

#[cfg(test)]
pub mod strategy {
    use super::*;
    use proptest::prelude::*;

    pub fn arb_marker() -> impl Strategy<Value = String> {
        "[A-Z]{1,12}".prop_map(|s| s.to_string())
    }

    pub fn arb_alert() -> impl Strategy<Value = Alert> {
        (arb_marker(), "[a-z0-9 .,!?\n]{0,80}").prop_map(|(marker, content)| Alert {
            marker,
            content: content.trim_end().to_string(),
        })
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use proptest::prelude::*;

    fn body(s: &str) -> Body {
        Body::new(s)
    }

    #[test]
    fn empty_body_yields_no_alerts() {
        assert!(extract_alerts(&body("")).is_empty());
    }

    #[test]
    fn body_without_blockquote_yields_no_alerts() {
        assert!(extract_alerts(&body("# title\n\nplain prose")).is_empty());
    }

    #[test]
    fn plain_blockquote_without_marker_is_ignored() {
        assert!(extract_alerts(&body("> just a quote\n> with two lines")).is_empty());
    }

    #[test]
    fn marker_alone_yields_alert_with_empty_content() {
        let alerts = extract_alerts(&body("> [!DECISION]"));
        assert_eq!(alerts.len(), 1);
        assert_eq!(alerts[0].marker, "DECISION");
        assert_eq!(alerts[0].content, "");
    }

    #[test]
    fn marker_with_one_line_of_content() {
        let alerts = extract_alerts(&body("> [!DECISION]\n> the chosen path"));
        assert_eq!(alerts.len(), 1);
        assert_eq!(alerts[0].marker, "DECISION");
        assert_eq!(alerts[0].content, "the chosen path");
    }

    #[test]
    fn marker_preserves_inner_markdown() {
        let alerts = extract_alerts(&body(
            "> [!DECISION]\n> use **GFM** alerts; see `[!MARKER]`.",
        ));
        assert_eq!(alerts.len(), 1);
        assert_eq!(alerts[0].content, "use **GFM** alerts; see `[!MARKER]`.");
    }

    #[test]
    fn marker_preserves_blank_lines_inside() {
        let src = "> [!DECISION]\n> first paragraph.\n>\n> second paragraph.";
        let alerts = extract_alerts(&body(src));
        assert_eq!(alerts.len(), 1);
        assert_eq!(alerts[0].content, "first paragraph.\n\nsecond paragraph.");
    }

    #[test]
    fn lowercase_marker_is_not_an_alert() {
        assert!(extract_alerts(&body("> [!decision]\n> body")).is_empty());
    }

    #[test]
    fn empty_marker_brackets_are_not_an_alert() {
        assert!(extract_alerts(&body("> [!]\n> body")).is_empty());
    }

    #[test]
    fn marker_line_with_trailing_text_is_not_an_alert() {
        assert!(extract_alerts(&body("> [!DECISION] note\n> body")).is_empty());
    }

    #[test]
    fn marker_must_be_first_line_of_blockquote() {
        // A regular line, then the marker — pulldown-cmark sees one
        // blockquote, the first line is "preface", not a marker.
        assert!(extract_alerts(&body("> preface\n> [!DECISION]\n> body")).is_empty());
    }

    #[test]
    fn multiple_alerts_are_returned_in_order() {
        let src = "> [!DECISION]\n> first\n\nsome prose\n\n> [!CONTEXT]\n> second";
        let alerts = extract_alerts(&body(src));
        assert_eq!(alerts.len(), 2);
        assert_eq!(alerts[0].marker, "DECISION");
        assert_eq!(alerts[0].content, "first");
        assert_eq!(alerts[1].marker, "CONTEXT");
        assert_eq!(alerts[1].content, "second");
    }

    #[test]
    fn duplicate_marker_returns_both_blocks() {
        // Per DDR: extractor returns every alert; the consumer (or
        // `cartu check`) decides what to do with duplicates.
        let src = "> [!DECISION]\n> first\n\n> [!DECISION]\n> second";
        let alerts = extract_alerts(&body(src));
        assert_eq!(alerts.len(), 2);
        assert!(alerts.iter().all(|a| a.marker == "DECISION"));
    }

    #[test]
    fn unknown_marker_is_returned_too() {
        // The DDR reserves only DECISION/CONTEXT/CONSEQUENCES/REFERENCES,
        // but the parser is dumb — filtering belongs to the consumer.
        let alerts = extract_alerts(&body("> [!FOOBAR]\n> something"));
        assert_eq!(alerts.len(), 1);
        assert_eq!(alerts[0].marker, "FOOBAR");
    }

    #[test]
    fn nested_blockquote_inside_alert_is_part_of_content() {
        let src = "> [!DECISION]\n> outer\n> > inner quote";
        let alerts = extract_alerts(&body(src));
        assert_eq!(alerts.len(), 1);
        assert_eq!(alerts[0].content, "outer\n> inner quote");
    }

    proptest! {
        #[test]
        fn prop_marker_is_uppercase_ascii(name in "[A-Z]{1,8}", content in "[a-z .]{0,40}") {
            let src = format!("> [!{name}]\n> {content}");
            let alerts = extract_alerts(&Body::new(&src));
            prop_assert_eq!(alerts.len(), 1);
            prop_assert_eq!(&alerts[0].marker, &name);
            prop_assert!(alerts[0].marker.chars().all(|c| c.is_ascii_uppercase()));
        }

        #[test]
        fn prop_lowercase_marker_never_extracts(name in "[a-z]{1,8}", content in "[a-z .]{0,40}") {
            let src = format!("> [!{name}]\n> {content}");
            let alerts = extract_alerts(&Body::new(&src));
            prop_assert!(alerts.is_empty());
        }
    }
}