Skip to main content

okf_core/
document.rs

1//! The OKF concept document: YAML frontmatter + markdown body.
2//!
3//! The parse, serialize, and validation behaviour is a faithful port of the
4//! reference implementation's `OKFDocument`
5//! (`okf/src/reference_agent/bundle/document.py`), so documents round-trip
6//! compatibly between the two. Ported to Rust and modified from the original
7//! Apache-2.0 Python source; see the NOTICE file.
8//!
9//! On top of parsing, [`Document`] exposes the v0.2 body conventions that pair
10//! with frontmatter: footnote attribution keyed to `sources[].id` and
11//! the `# Computation` block of an Attested Computation.
12
13use crate::computation::{AttestedComputation, InlineComputation};
14use crate::error::DocumentError;
15use crate::footnotes::{self, FootnoteDef, FootnoteRef};
16use crate::frontmatter::{Frontmatter, RECOMMENDED_FRONTMATTER_KEYS, REQUIRED_FRONTMATTER_KEYS};
17use crate::links::{self, Citation, Link};
18use crate::provenance::{self, Attribution};
19use crate::yaml::Value;
20
21const FRONTMATTER_DELIM: &str = "---";
22
23/// A parsed OKF concept document.
24#[derive(Clone, Debug, Default, PartialEq)]
25pub struct Document {
26    /// The YAML frontmatter block (empty if the file had none).
27    pub frontmatter: Frontmatter,
28    /// Everything after the frontmatter.
29    pub body: String,
30}
31
32impl Document {
33    /// Creates a document from frontmatter and a body.
34    pub fn new(frontmatter: Frontmatter, body: impl Into<String>) -> Self {
35        Self {
36            frontmatter,
37            body: body.into(),
38        }
39    }
40
41    /// Parses a document from raw file text.
42    ///
43    /// If the file does not begin with a `---` frontmatter delimiter, the
44    /// entire text is treated as the body and the frontmatter is empty
45    /// (matching the reference parser). An opened-but-unclosed frontmatter
46    /// block is an error.
47    ///
48    /// # Line endings
49    ///
50    /// The two paths handle line endings the same way the reference
51    /// implementation does, by deliberate parity:
52    /// - **No frontmatter**: the body is kept verbatim, so a file with CRLF
53    ///   line endings round-trips byte-identically. This mirrors the
54    ///   reference's `return cls(frontmatter={}, body=text)`.
55    /// - **With frontmatter**: the body is rebuilt via `lines().join("\n")`,
56    ///   which normalizes `\r\n` (and a trailing `\r`) to `\n`. This mirrors
57    ///   the reference's `text.splitlines()` + `"\n".join(...)`. Anything
58    ///   inside the frontmatter block is likewise normalized before YAML
59    ///   parsing.
60    ///
61    /// # Errors
62    ///
63    /// Returns [`DocumentError::UnterminatedFrontmatter`] if the opening `---`
64    /// has no matching close, [`DocumentError::InvalidYaml`] if the frontmatter
65    /// is not valid YAML, and [`DocumentError::FrontmatterNotMapping`] if it
66    /// parses to a scalar or sequence rather than a mapping.
67    pub fn parse(text: &str) -> Result<Self, DocumentError> {
68        let lines: Vec<&str> = text.lines().collect();
69        if lines.is_empty() || lines[0].trim() != FRONTMATTER_DELIM {
70            return Ok(Self {
71                frontmatter: Frontmatter::new(),
72                body: text.to_string(),
73            });
74        }
75
76        let mut end_idx = None;
77        for (i, line) in lines.iter().enumerate().skip(1) {
78            if line.trim() == FRONTMATTER_DELIM {
79                end_idx = Some(i);
80                break;
81            }
82        }
83        let end_idx = end_idx.ok_or(DocumentError::UnterminatedFrontmatter)?;
84
85        let fm_text = lines[1..end_idx].join("\n");
86        let value = Value::parse(&fm_text)?;
87        let frontmatter = match value {
88            Value::Null => Frontmatter::new(),
89            Value::Mapping(m) => Frontmatter::from_mapping(m),
90            _ => return Err(DocumentError::FrontmatterNotMapping),
91        };
92
93        let mut body = lines[end_idx + 1..].join("\n");
94        if let Some(stripped) = body.strip_prefix('\n') {
95            body = stripped.to_string();
96        }
97
98        Ok(Self { frontmatter, body })
99    }
100
101    /// Serializes the document back to text: frontmatter delimited by `---`,
102    /// a blank line, then the body (terminated by a newline). If the
103    /// frontmatter is empty, the delimiters are omitted and only the body is
104    /// emitted.
105    ///
106    /// `parse` followed by `serialize` preserves frontmatter key order and the
107    /// body (modulo trailing-newline normalization), matching the reference.
108    /// Flow collections are re-emitted in block style, which is the same value
109    /// written differently.
110    #[must_use]
111    pub fn serialize(&self) -> String {
112        let body = if self.body.ends_with('\n') {
113            self.body.clone()
114        } else {
115            format!("{}\n", self.body)
116        };
117        if self.frontmatter.is_empty() {
118            body
119        } else {
120            let fm_text = Value::Mapping(self.frontmatter.as_mapping().clone())
121                .to_yaml_string()
122                .trim_end()
123                .to_string();
124            format!("{FRONTMATTER_DELIM}\n{fm_text}\n{FRONTMATTER_DELIM}\n\n{body}")
125        }
126    }
127
128    /// Validates the document: the frontmatter must carry a
129    /// non-empty `type`, and nothing else is required.
130    ///
131    /// That single check is the whole of document-level validation in v0.2, and
132    /// it matches the reference implementation's `OKFDocument.validate`. Every
133    /// other field the spec describes is a SHOULD, so a concept carrying only
134    /// `type` passes here; see [`Document::missing_recommended`] for the
135    /// producer-side checklist and
136    /// `validate_bundle` (in the okf-validator crate) for the full diagnostics.
137    ///
138    /// # Errors
139    ///
140    /// Returns [`DocumentError::MissingKeys`] listing every required key that
141    /// is absent, empty, or has the wrong shape.
142    pub fn validate(&self) -> Result<(), DocumentError> {
143        let missing: Vec<String> = REQUIRED_FRONTMATTER_KEYS
144            .iter()
145            .filter(|&&key| {
146                let Some(value) = self.frontmatter.get(key) else {
147                    return true;
148                };
149                value.is_empty_value() || (key == "type" && value.as_display_str().is_none())
150            })
151            .map(|key| (*key).to_string())
152            .collect();
153
154        if missing.is_empty() {
155            Ok(())
156        } else {
157            Err(DocumentError::MissingKeys(missing))
158        }
159    }
160
161    /// The [recommended](RECOMMENDED_FRONTMATTER_KEYS) frontmatter keys this
162    /// document leaves unset, plus `runtime` when the concept is an Attested
163    /// Computation, which the spec requires it to carry.
164    ///
165    /// None of these is a conformance failure, so [`Document::validate`]
166    /// ignores them: the spec forbids rejecting a concept for a missing optional
167    /// field. This is the checklist a *producer* wants before publishing, and
168    /// it is what `validate_bundle` (in the okf-validator crate) reports as
169    /// warnings. An empty result means the document is fully filled in.
170    ///
171    /// `generated` counts as set when a legacy v0.1 `timestamp` stands in for
172    /// it, since consumers may read one for the other.
173    #[must_use]
174    pub fn missing_recommended(&self) -> Vec<&'static str> {
175        let mut missing: Vec<&'static str> = RECOMMENDED_FRONTMATTER_KEYS
176            .iter()
177            .copied()
178            .filter(|key| match *key {
179                "generated" => !self.has("generated") && !self.has("timestamp"),
180                other => !self.has(other),
181            })
182            .collect();
183
184        if self.frontmatter.is_attested_computation() && !self.has("runtime") {
185            missing.push("runtime");
186        }
187
188        missing
189    }
190
191    /// Whether a frontmatter key is present and carries a non-empty value.
192    fn has(&self, key: &str) -> bool {
193        self.frontmatter
194            .get(key)
195            .is_some_and(|value| !value.is_empty_value())
196    }
197
198    /// Extracts all markdown links found in the body.
199    #[must_use]
200    pub fn links(&self) -> Vec<Link> {
201        links::extract_links(&self.body)
202    }
203
204    /// The non-blank lines under a top-level `# heading` in the body, up to the
205    /// next top-level heading.
206    ///
207    /// The spec gives `# Schema`, `# Examples`, and `# Computation` conventional
208    /// meaning without attaching required behaviour, so this is the primitive a
209    /// consumer needs to read any of them. A port of the reference's
210    /// `_section_content_lines`, including its details: `heading` is matched in
211    /// full (pass `"# Schema"`), only `# ` counts as a heading so `##`
212    /// subheadings stay inside the section, and each line keeps its original
213    /// indentation.
214    ///
215    /// Returns an empty vector when no such section exists. A repeated heading
216    /// contributes its lines to the same result.
217    #[must_use]
218    pub fn section(&self, heading: &str) -> Vec<&str> {
219        let mut in_section = false;
220        let mut lines = Vec::new();
221        for line in self.body.lines() {
222            let trimmed = line.trim();
223            if trimmed.starts_with("# ") {
224                in_section = trimmed == heading;
225                continue;
226            }
227            if in_section && !trimmed.is_empty() {
228                lines.push(line);
229            }
230        }
231        lines
232    }
233
234    /// Extracts the body's `[^label]` attribution markers.
235    #[must_use]
236    pub fn footnote_refs(&self) -> Vec<FootnoteRef> {
237        footnotes::extract_refs(&self.body)
238    }
239
240    /// Extracts the body's `[^label]: text` footnote definitions.
241    #[must_use]
242    pub fn footnote_definitions(&self) -> Vec<FootnoteDef> {
243        footnotes::extract_definitions(&self.body)
244    }
245
246    /// Joins the body's footnotes to the `sources` entries they name, giving
247    /// per-claim attribution.
248    ///
249    /// Labels that match no source are still returned, with
250    /// [`Attribution::source`] set to `None`.
251    #[must_use]
252    pub fn attributions(&self) -> Vec<Attribution> {
253        provenance::attributions(&self.frontmatter.sources(), &self.body)
254    }
255
256    /// The `# Computation` code block from the body, if there is one.
257    #[must_use]
258    pub fn inline_computation(&self) -> Option<InlineComputation> {
259        crate::computation::extract_inline_computation(&self.body)
260    }
261
262    /// The Attested Computation contract: the computation frontmatter
263    /// resolved against the body's `# Computation` block.
264    ///
265    /// Returns `None` unless `type` is `Attested Computation`; call
266    /// [`AttestedComputation::from_parts`] directly to read the same keys off a
267    /// concept of another type.
268    #[must_use]
269    pub fn attested_computation(&self) -> Option<AttestedComputation> {
270        self.frontmatter
271            .is_attested_computation()
272            .then(|| AttestedComputation::from_parts(&self.frontmatter, &self.body))
273    }
274
275    /// Extracts numbered entries from a legacy v0.1 `# Citations` section.
276    ///
277    /// v0.2 supersedes this with `sources` and footnote attribution;
278    /// [`Document::attributions`] is the v0.2 equivalent. Consumers MAY keep
279    /// reading `# Citations` for v0.1 documents.
280    #[must_use]
281    pub fn citations(&self) -> Vec<Citation> {
282        links::extract_citations(&self.body)
283    }
284
285    /// `true` when the body carries a legacy `# Citations` section, which a
286    /// v0.2 producer should have migrated to `sources`.
287    #[must_use]
288    pub fn has_legacy_citations(&self) -> bool {
289        !self.citations().is_empty()
290    }
291}
292
293impl std::fmt::Display for Document {
294    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
295        f.write_str(&self.serialize())
296    }
297}
298
299impl std::str::FromStr for Document {
300    type Err = DocumentError;
301    fn from_str(s: &str) -> Result<Self, Self::Err> {
302        Self::parse(s)
303    }
304}