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).
103    ///
104    /// `parse` followed by `serialize` preserves frontmatter key order and the
105    /// body (modulo trailing-newline normalization), matching the reference.
106    /// Flow collections are re-emitted in block style, which is the same value
107    /// written differently.
108    #[must_use]
109    pub fn serialize(&self) -> String {
110        let fm_text = Value::Mapping(self.frontmatter.as_mapping().clone())
111            .to_yaml_string()
112            .trim_end()
113            .to_string();
114        let body = if self.body.ends_with('\n') {
115            self.body.clone()
116        } else {
117            format!("{}\n", self.body)
118        };
119        format!("{FRONTMATTER_DELIM}\n{fm_text}\n{FRONTMATTER_DELIM}\n\n{body}")
120    }
121
122    /// Validates the document: the frontmatter must carry a
123    /// non-empty `type`, and nothing else is required.
124    ///
125    /// That single check is the whole of document-level validation in v0.2, and
126    /// it matches the reference implementation's `OKFDocument.validate`. Every
127    /// other field the spec describes is a SHOULD, so a concept carrying only
128    /// `type` passes here; see [`Document::missing_recommended`] for the
129    /// producer-side checklist and
130    /// `validate_bundle` (in the okf-validator crate) for the full diagnostics.
131    ///
132    /// # Errors
133    ///
134    /// Returns [`DocumentError::MissingKeys`] listing every required key that
135    /// is absent, empty, or has the wrong shape.
136    pub fn validate(&self) -> Result<(), DocumentError> {
137        let missing: Vec<String> = REQUIRED_FRONTMATTER_KEYS
138            .iter()
139            .filter(|&&key| {
140                let Some(value) = self.frontmatter.get(key) else {
141                    return true;
142                };
143                value.is_empty_value() || (key == "type" && value.as_display_str().is_none())
144            })
145            .map(|key| (*key).to_string())
146            .collect();
147
148        if missing.is_empty() {
149            Ok(())
150        } else {
151            Err(DocumentError::MissingKeys(missing))
152        }
153    }
154
155    /// The [recommended](RECOMMENDED_FRONTMATTER_KEYS) frontmatter keys this
156    /// document leaves unset, plus `runtime` when the concept is an Attested
157    /// Computation, which the spec requires it to carry.
158    ///
159    /// None of these is a conformance failure, so [`Document::validate`]
160    /// ignores them: the spec forbids rejecting a concept for a missing optional
161    /// field. This is the checklist a *producer* wants before publishing, and
162    /// it is what `validate_bundle` (in the okf-validator crate) reports as
163    /// warnings. An empty result means the document is fully filled in.
164    ///
165    /// `generated` counts as set when a legacy v0.1 `timestamp` stands in for
166    /// it, since consumers may read one for the other.
167    #[must_use]
168    pub fn missing_recommended(&self) -> Vec<&'static str> {
169        let mut missing: Vec<&'static str> = RECOMMENDED_FRONTMATTER_KEYS
170            .iter()
171            .copied()
172            .filter(|key| match *key {
173                "generated" => !self.has("generated") && !self.has("timestamp"),
174                other => !self.has(other),
175            })
176            .collect();
177
178        if self.frontmatter.is_attested_computation() && !self.has("runtime") {
179            missing.push("runtime");
180        }
181
182        missing
183    }
184
185    /// Whether a frontmatter key is present and carries a non-empty value.
186    fn has(&self, key: &str) -> bool {
187        self.frontmatter
188            .get(key)
189            .is_some_and(|value| !value.is_empty_value())
190    }
191
192    /// Extracts all markdown links found in the body.
193    #[must_use]
194    pub fn links(&self) -> Vec<Link> {
195        links::extract_links(&self.body)
196    }
197
198    /// The non-blank lines under a top-level `# heading` in the body, up to the
199    /// next top-level heading.
200    ///
201    /// The spec gives `# Schema`, `# Examples`, and `# Computation` conventional
202    /// meaning without attaching required behaviour, so this is the primitive a
203    /// consumer needs to read any of them. A port of the reference's
204    /// `_section_content_lines`, including its details: `heading` is matched in
205    /// full (pass `"# Schema"`), only `# ` counts as a heading so `##`
206    /// subheadings stay inside the section, and each line keeps its original
207    /// indentation.
208    ///
209    /// Returns an empty vector when no such section exists. A repeated heading
210    /// contributes its lines to the same result.
211    #[must_use]
212    pub fn section(&self, heading: &str) -> Vec<&str> {
213        let mut in_section = false;
214        let mut lines = Vec::new();
215        for line in self.body.lines() {
216            let trimmed = line.trim();
217            if trimmed.starts_with("# ") {
218                in_section = trimmed == heading;
219                continue;
220            }
221            if in_section && !trimmed.is_empty() {
222                lines.push(line);
223            }
224        }
225        lines
226    }
227
228    /// Extracts the body's `[^label]` attribution markers.
229    #[must_use]
230    pub fn footnote_refs(&self) -> Vec<FootnoteRef> {
231        footnotes::extract_refs(&self.body)
232    }
233
234    /// Extracts the body's `[^label]: text` footnote definitions.
235    #[must_use]
236    pub fn footnote_definitions(&self) -> Vec<FootnoteDef> {
237        footnotes::extract_definitions(&self.body)
238    }
239
240    /// Joins the body's footnotes to the `sources` entries they name, giving
241    /// per-claim attribution.
242    ///
243    /// Labels that match no source are still returned, with
244    /// [`Attribution::source`] set to `None`.
245    #[must_use]
246    pub fn attributions(&self) -> Vec<Attribution> {
247        provenance::attributions(&self.frontmatter.sources(), &self.body)
248    }
249
250    /// The `# Computation` code block from the body, if there is one.
251    #[must_use]
252    pub fn inline_computation(&self) -> Option<InlineComputation> {
253        crate::computation::extract_inline_computation(&self.body)
254    }
255
256    /// The Attested Computation contract: the computation frontmatter
257    /// resolved against the body's `# Computation` block.
258    ///
259    /// Returns `None` unless `type` is `Attested Computation`; call
260    /// [`AttestedComputation::from_parts`] directly to read the same keys off a
261    /// concept of another type.
262    #[must_use]
263    pub fn attested_computation(&self) -> Option<AttestedComputation> {
264        self.frontmatter
265            .is_attested_computation()
266            .then(|| AttestedComputation::from_parts(&self.frontmatter, &self.body))
267    }
268
269    /// Extracts numbered entries from a legacy v0.1 `# Citations` section.
270    ///
271    /// v0.2 supersedes this with `sources` and footnote attribution;
272    /// [`Document::attributions`] is the v0.2 equivalent. Consumers MAY keep
273    /// reading `# Citations` for v0.1 documents.
274    #[must_use]
275    pub fn citations(&self) -> Vec<Citation> {
276        links::extract_citations(&self.body)
277    }
278
279    /// `true` when the body carries a legacy `# Citations` section, which a
280    /// v0.2 producer should have migrated to `sources`.
281    #[must_use]
282    pub fn has_legacy_citations(&self) -> bool {
283        !self.citations().is_empty()
284    }
285}
286
287impl std::fmt::Display for Document {
288    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
289        f.write_str(&self.serialize())
290    }
291}
292
293impl std::str::FromStr for Document {
294    type Err = DocumentError;
295    fn from_str(s: &str) -> Result<Self, Self::Err> {
296        Self::parse(s)
297    }
298}