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}