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}