govctl 0.11.0

Project governance CLI for RFC, ADR, and Work Item management
use crate::diagnostic::{Diagnostic, DiagnosticCode};
use crate::model::{AdrEntry, AlternativeStatus};
use pulldown_cmark::{Event, Parser, Tag, TagEnd};

const FIXED_RENDER_HEADINGS: &[&str] = &[
    "Context",
    "Decision",
    "Consequences",
    "References",
    "Alternatives Considered",
    "Options Considered",
];

pub(crate) fn validate_adr_projection_ownership(adr: &AdrEntry, file: String) -> Vec<Diagnostic> {
    let reserved = reserved_headings(adr);
    let fields = [
        ("content.context", adr.spec.content.context.as_str()),
        ("content.decision", adr.spec.content.decision.as_str()),
        (
            "content.consequences",
            adr.spec.content.consequences.as_str(),
        ),
    ];

    let mut diagnostics = Vec::new();
    for (field, markdown) in fields {
        for heading in heading_texts(markdown) {
            if reserved
                .iter()
                .any(|reserved| heading.eq_ignore_ascii_case(reserved))
            {
                diagnostics.push(Diagnostic::new(
                    DiagnosticCode::E0307AdrProjectionConflict,
                    format!(
                        "ADR {field} heading '{heading}' conflicts with renderer-owned projection structure"
                    ),
                    file.clone(),
                ));
            }
        }
    }
    diagnostics
}

fn reserved_headings(adr: &AdrEntry) -> Vec<String> {
    let mut headings = FIXED_RENDER_HEADINGS
        .iter()
        .map(|heading| (*heading).to_string())
        .collect::<Vec<_>>();
    headings.extend(heading_texts(&format!(
        "# {}: {}",
        adr.meta().id,
        adr.meta().title
    )));

    for alternative in &adr.spec.content.alternatives {
        let suffix = match alternative.status {
            AlternativeStatus::Considered => "",
            AlternativeStatus::Accepted => " (accepted)",
            AlternativeStatus::Rejected => " (rejected)",
        };
        headings.extend(heading_texts(&format!("# {}{suffix}", alternative.text)));
    }

    headings
}

fn heading_texts(markdown: &str) -> Vec<String> {
    let mut headings = Vec::new();
    let mut current = None;

    for event in Parser::new(markdown) {
        match event {
            Event::Start(Tag::Heading { .. }) => current = Some(String::new()),
            Event::End(TagEnd::Heading(_)) => {
                if let Some(heading) = current.take() {
                    headings.push(heading.trim().to_string());
                }
            }
            Event::Text(text) | Event::Code(text) => {
                if let Some(heading) = current.as_mut() {
                    heading.push_str(&text);
                }
            }
            Event::SoftBreak | Event::HardBreak => {
                if let Some(heading) = current.as_mut() {
                    heading.push(' ');
                }
            }
            _ => {}
        }
    }

    headings
}

#[cfg(test)]
mod tests {
    use super::heading_texts;

    #[test]
    fn extracts_visible_commonmark_heading_text() {
        assert_eq!(
            heading_texts("### **Options** `Considered`\n"),
            vec!["Options Considered"]
        );
        assert_eq!(heading_texts("Decision\n--------\n"), vec!["Decision"]);
    }

    #[test]
    fn ignores_heading_syntax_inside_fenced_code() {
        assert!(heading_texts("```markdown\n## Decision\n```\n").is_empty());
    }
}