Skip to main content

rac_engine/
parse.rs

1//! File -> parsed artifact: the oracle's `decided.core.markdown.parse` /
2//! `parse_file` composition of the markdown body walk (`markdown.rs`) with
3//! the frontmatter envelope (`frontmatter.rs`), per PORT-CONTRACT.d/04 §0
4//! and PORT-CONTRACT.d/09 §1.7.
5//!
6//! The markdown module owns the body surfaces (sections, requirements,
7//! titles, parse budget issues) and the raw read path; this module attaches
8//! `product.metadata` / `product.metadata_issues` exactly as the oracle's
9//! `parse` does:
10//!
11//! ```python
12//! split = split_frontmatter(text)
13//! if split.raw is not None:
14//!     metadata, metadata_issues = parse_frontmatter(split.raw)
15//! elif split.unterminated:
16//!     metadata_issues = [malformed-frontmatter "never closed"]
17//! ```
18//!
19//! The unterminated case is already emitted by `markdown::parse`; the
20//! `raw is not None` case is completed here.
21
22use crate::frontmatter::{parse_frontmatter, ArtifactMetadata};
23use crate::markdown::{self, Product};
24
25/// One validation/parse finding, unified across the frontmatter, markdown,
26/// and validation subsystems. Field order mirrors the Python `Issue`
27/// dataclass (`severity, code, message, line`) — `asdict` emission order.
28#[derive(Debug, Clone, PartialEq)]
29pub struct Issue {
30    pub severity: &'static str,
31    pub code: String,
32    pub message: String,
33    pub line: Option<i64>,
34}
35
36impl Issue {
37    pub fn new(severity: &'static str, code: &str, message: String, line: Option<i64>) -> Issue {
38        Issue {
39            severity,
40            code: code.to_string(),
41            message,
42            line,
43        }
44    }
45}
46
47fn from_markdown(i: &markdown::Issue) -> Issue {
48    Issue {
49        severity: i.severity,
50        code: i.code.to_string(),
51        message: i.message.clone(),
52        line: i.line,
53    }
54}
55
56fn from_frontmatter(i: crate::frontmatter::Issue) -> Issue {
57    Issue {
58        severity: i.severity,
59        code: i.code,
60        message: i.message,
61        line: i.line,
62    }
63}
64
65/// A parsed artifact: the markdown `Product` plus the frontmatter metadata
66/// the oracle's `Product.metadata` carries.
67#[derive(Debug, Clone)]
68pub struct Artifact {
69    pub product: Product,
70    /// `product.metadata` — `None` for legacy (no-frontmatter) documents and
71    /// for envelope-fatal frontmatter.
72    pub metadata: Option<ArtifactMetadata>,
73    /// `product.metadata_issues` in oracle order.
74    pub metadata_issues: Vec<Issue>,
75    /// `product.parse_issues` in oracle order.
76    pub parse_issues: Vec<Issue>,
77}
78
79impl Artifact {
80    /// `product.sections.get(key)` — the insertion-ordered section map.
81    pub fn section(&self, key: &str) -> Option<&str> {
82        self.product
83            .sections
84            .iter()
85            .find(|(h, _)| h == key)
86            .map(|(_, b)| b.as_str())
87    }
88
89    /// `key in product.sections`.
90    pub fn has_section(&self, key: &str) -> bool {
91        self.product.sections.iter().any(|(h, _)| h == key)
92    }
93}
94
95fn attach_metadata(product: Product) -> Artifact {
96    // markdown::parse populated metadata_issues only for the unterminated
97    // (`raw is None`) case; complete the `raw is not None` arm here.
98    let mut metadata_issues: Vec<Issue> =
99        product.metadata_issues.iter().map(from_markdown).collect();
100    let mut metadata = None;
101    if let Some(raw) = &product.frontmatter_raw {
102        let (meta, issues) = parse_frontmatter(raw);
103        metadata = meta;
104        metadata_issues.extend(issues.into_iter().map(from_frontmatter));
105    }
106    let parse_issues = product.parse_issues.iter().map(from_markdown).collect();
107    Artifact {
108        product,
109        metadata,
110        metadata_issues,
111        parse_issues,
112    }
113}
114
115/// `decided.core.markdown.parse(text, source_path)` with metadata attached.
116pub fn parse_text(text: &str, source_path: &str) -> Artifact {
117    attach_metadata(markdown::parse(text, source_path))
118}
119
120/// `decided.core.markdown.parse_file(path)` with metadata attached.
121pub fn parse_file(path: &str) -> Artifact {
122    attach_metadata(markdown::parse_file(path))
123}