rto_render/okf/read.rs
1//! Read an Open Knowledge Format bundle back into graph facts (issue #706).
2//!
3//! # Why the reader lives beside the writer
4//!
5//! `rto-render` is the renderer, and a parser is the other direction — so this
6//! module is here on purpose rather than in `rto-spec`, which already hosts
7//! `import_graphify` and `import_lat` and would be the obvious home.
8//!
9//! The reason is the naming rule. A concept's *identity in a bundle is its
10//! path*, and [`super::slug`], [`super::section_for`] and the collision digest
11//! are what turn a graph key into one. A reader in another crate would need all
12//! three, so either they become public API or the rule is written down twice —
13//! and [`super::assemble`]'s own documentation already says why a second copy is
14//! wrong: "any rule that turns a key into a path on its own is guessing". The
15//! writer is the specification of what this reads, and a specification and its
16//! parser drift the moment they are in different crates.
17//!
18//! So: OKF read and OKF write move together. If `rto-render` is ever split, they
19//! go to the same place.
20//!
21//! # What this reads, and what it refuses
22//!
23//! A Roteiro bundle round-trips, and that is the floor rather than the goal —
24//! ADR-0021 adopted OKF because it is **vendor-neutral**, so a bundle written by
25//! something else has to be readable too. OKF v0.2's only hard requirement is a
26//! non-empty `type`, and §11 tells consumers not to reject a document for a
27//! missing or unrecognised optional field. The rules that follow are chosen
28//! against that, one at a time:
29//!
30//! | situation | what happens | why |
31//! | --- | --- | --- |
32//! | unrecognised `type` | **imported**, as [`NodeKind::Other`] | the spec leaves `type` open; refusing would reject conformant bundles |
33//! | missing or empty `type` | file **skipped**, reason reported | the one thing the spec does require |
34//! | no frontmatter, or an unterminated block | file **skipped**, reason reported | a document with no frontmatter is not a concept |
35//! | no `verified` key | imported as `external-inferred` | absence of `verified` **is** the unverified tier (§5.3), not missing data |
36//! | link to a concept the bundle does not contain | edge dropped, counted | the store requires both endpoints; a dangling edge would be pruned anyway |
37//! | *every* concept file skipped | the whole read **fails** | a directory in which nothing parsed is not a bundle we read badly, it is not a bundle |
38//!
39//! Nothing is dropped silently. Every skip carries a path and a reason into
40//! [`OkfReport`], and the CLI prints them: a bundle that is *partly* readable
41//! has to say what it left behind, because the alternative is a graph quietly
42//! missing concepts nobody knows to look for.
43//!
44//! # This reader was checked against an independent implementation
45//!
46//! Reading back one's own output proves a round trip, not interoperability, so
47//! the trust tiers this module derives (§5.3) were compared against a second,
48//! unrelated OKF v0.2 implementation over inputs neither project wrote.
49//!
50//! **What was compared, so the claim can be re-tested rather than believed:**
51//!
52//! - **Oracle:** [`W4G1/okf`](https://github.com/W4G1/okf) `okf-core` /
53//! `okf-validator` **0.2.6** (2026-08-27), Apache-2.0 — a pure-Rust v0.2
54//! toolkit. Its `okf trust <bundle>` prints a tier per concept and
55//! `okf validate <bundle>` reports conformance.
56//! - **Inputs:** all four bundles published in the specification's own
57//! repository at commit `ad30107` — `acme_retail`, `ga4`, `stackoverflow`,
58//! `crypto_bitcoin` — plus Roteiro's own `render okf` output for this
59//! repository.
60//! - **Result, 2026-09-01:** exact agreement on every bundle. Concept counts
61//! 9 / 9 / 26 / 9, and tiers matching one-for-one — `acme_retail` as 8
62//! human-reviewed + 1 unverified (our `external-authored` / `external-inferred`),
63//! the other three entirely unverified. Our rendered bundle validated with
64//! **0 conformance errors across 9,029 concepts**.
65//!
66//! The oracle is **not** a dependency, of this crate or of the test suite: it
67//! was run as a separate binary and the agreement was then frozen into
68//! `tests/okf_interop.rs`, which pins the same expectations against vendored
69//! copies of two of those bundles. That is what survives the oracle's absence —
70//! a foreign bundle in the test suite, which is the thing phase 1 never had.
71//!
72//! To re-run the comparison: `cargo install okf`, then `okf trust <bundle>`
73//! against `crates/rto-render/tests/fixtures/okf-upstream/*` and
74//! `roteiro import --from okf <bundle> --trust --json`.
75//!
76//! Worth knowing if adopting it is ever considered: `okf-core` has **zero
77//! dependencies** — no `serde`, no `serde_yaml`, no `regex`, no `chrono` — and
78//! carries its own YAML-subset parser. `okf-validator` is the heavy one, adding
79//! 94 transitive crates (a JavaScript, Python and SQL parser, plus `syn`) to
80//! syntax-check fenced code blocks.
81//!
82//! # Relationships come from the `## Relationships` section, and nowhere else
83//!
84//! §6 says a plain markdown link asserts a relationship. Read at its widest that
85//! would make every link in every sentence an edge, so a paragraph citing a
86//! neighbouring concept would manufacture one. Roteiro's own writer puts
87//! relationships under a `## Relationships` heading and prose everywhere else,
88//! and that is the line taken here: links under that heading are edges, links
89//! outside it are citations and are counted rather than imported
90//! ([`OkfReport::links_outside_relationships`]).
91//!
92//! A `←` link is the *same* edge seen from its other end — [`super::render_concept`]
93//! writes both directions into both documents — so only `→` (and unmarked) links
94//! become edges. Taking both would not duplicate anything (edges are a set), but
95//! it would reverse half of them.
96
97use std::collections::BTreeMap;
98
99use rto_graph::{Edge, EdgeKind, FactSet, Node, NodeKind, Provenance};
100use yaml_rust2::Yaml;
101
102use super::{Actor, INDEX_FILE, LOG_FILE, Origin, section_for, short_digest, slug};
103
104/// The `src_ref` prefix every OKF import layer is persisted under.
105///
106/// One ref **per bundle**, not one for all of them: `apply_import_layer` is
107/// authoritative per ref, so a single shared ref would make importing a second
108/// peer's bundle delete the first peer's concepts. The same reasoning that gave
109/// `import:links` and `import:links/authored` separate refs.
110///
111/// # It sorts after `import:links`, and that is load-bearing
112///
113/// `Store::reapply_imports` re-upserts every layer's nodes in **`src_ref`
114/// order**, so when two layers name one node the last one wins. A filled
115/// `extref:` placeholder is exactly that case: `import:links` contributes the
116/// bare stub, and this layer contributes the same key with the peer's content.
117/// `"import:okf/…"` sorting after `"import:links…"` is what stops a rebuild from
118/// resetting the fill back to an empty placeholder.
119///
120/// **Every rebuild**, not only an explicit `sync`: a read command refreshes the
121/// graph before answering, so renaming this prefix to anything sorting earlier
122/// would undo the fill before the very next `roteiro query` — verified by
123/// injection, which is how the reach of it was established rather than guessed.
124/// A real dependency on the string, then, and not a coincidence worth leaving
125/// unstated. `an_imported_concept_fills_a_cross_repo_placeholder` is the guard.
126pub const OKF_REF_PREFIX: &str = "import:okf/";
127
128/// The node-key namespace for an imported concept that does not fill an
129/// [`rto_graph::external_ref_key`] stub.
130pub const OKF_KEY_PREFIX: &str = "okf:";
131
132/// The `src_ref` an import from `peer` is persisted under.
133#[must_use]
134pub fn import_ref(peer: &str) -> String {
135 format!("{OKF_REF_PREFIX}{peer}")
136}
137
138/// How much of a peer's claim is adopted on import.
139///
140/// The set is closed by the decision in issue #706 rather than by us, and is
141/// deliberately **not `#[non_exhaustive]`**: it enumerates the answers to a
142/// consent question — adopt their confirmations, or take their information
143/// without them — and *ignore*, the third answer, is not a mode of importing but
144/// the decision not to. A fourth would be a new answer to that question, and a
145/// caller matching on this enum should stop compiling until someone has looked
146/// at what it means, rather than absorbing it into a wildcard arm.
147#[derive(Debug, Clone, Copy, PartialEq, Eq)]
148pub enum Trust {
149 /// Import at `external-<the peer's tier>`, preserving what they claimed.
150 Trust,
151 /// Import at `external-inferred` **regardless** of the peer's claimed tier:
152 /// their information without their confirmation.
153 Acknowledge,
154}
155
156impl Trust {
157 /// The stable CLI/report token.
158 #[must_use]
159 pub fn as_str(self) -> &'static str {
160 match self {
161 Self::Trust => "trust",
162 Self::Acknowledge => "acknowledge",
163 }
164 }
165}
166
167/// Why a file in the bundle directory did not become a concept.
168///
169/// `#[non_exhaustive]` because this names ways a *document* can be malformed,
170/// and unlike [`Trust`] that set is open: it grows with every real bundle that
171/// arrives shaped in a way nobody predicted. A caller should be able to report a
172/// new one without this becoming a breaking change.
173#[derive(Debug, Clone, Copy, PartialEq, Eq)]
174#[non_exhaustive]
175pub enum SkipReason {
176 /// The file does not open with a `---` frontmatter fence.
177 NoFrontmatter,
178 /// It opens one and never closes it.
179 UnterminatedFrontmatter,
180 /// The block is delimited correctly but is not parseable YAML.
181 ///
182 /// Distinct from [`Self::MissingType`] on purpose. Both end with no `type`,
183 /// but they send a producer to different places: one means *add a key*, the
184 /// other means *the block does not parse at all* — and reporting broken YAML
185 /// as a missing field is how someone spends an afternoon staring at a `type`
186 /// that was there all along.
187 UnparsableFrontmatter,
188 /// The frontmatter carries no `type`, or an empty one — OKF's only hard
189 /// requirement (§4).
190 MissingType,
191}
192
193impl SkipReason {
194 /// A one-line explanation, for the report and the CLI.
195 #[must_use]
196 pub fn as_str(self) -> &'static str {
197 match self {
198 Self::NoFrontmatter => "no YAML frontmatter block",
199 Self::UnterminatedFrontmatter => "frontmatter block is never closed",
200 Self::UnparsableFrontmatter => "frontmatter block is not parseable YAML",
201 Self::MissingType => "no non-empty `type` (OKF's one required key)",
202 }
203 }
204}
205
206/// A file the reader declined, and why.
207#[derive(Debug, Clone, PartialEq, Eq)]
208pub struct Skipped {
209 /// The bundle-relative path, as given.
210 pub path: String,
211 /// Why it was not imported.
212 pub reason: SkipReason,
213}
214
215/// An auditable summary of reading a bundle.
216#[derive(Debug, Clone, Default, serde::Serialize)]
217pub struct OkfReport {
218 /// The bundle's declared `okf_version`, when its root index carried one.
219 pub okf_version: Option<String>,
220 /// Markdown files offered to the reader.
221 pub files_total: usize,
222 /// Reserved files (`index.md`, `log.md`) passed over, per §8/§9.
223 pub reserved_skipped: usize,
224 /// Concepts imported.
225 pub concepts_read: usize,
226 /// Imported concepts by their declared `type`.
227 pub concepts_by_type: BTreeMap<String, usize>,
228 /// Imported concepts by the provenance they landed at.
229 pub concepts_by_provenance: BTreeMap<String, usize>,
230 /// Files that were not concepts, each with its reason.
231 pub skipped: Vec<SkippedRow>,
232 /// Links found under a `## Relationships` heading.
233 pub links_total: usize,
234 /// Relationship links that became edges.
235 pub edges_read: usize,
236 /// `←` links: the same edge seen from its other end, captured there.
237 pub links_reciprocal: usize,
238 /// Relationship links whose target is not a concept in this bundle.
239 pub links_unresolved: usize,
240 /// Markdown links outside the relationships section — citations, not
241 /// asserted relationships. Counted so the choice is visible rather than
242 /// silent.
243 pub links_outside_relationships: usize,
244 /// `extref:` placeholders this import filled, as `(stub key, bundle path)`.
245 pub extrefs_filled: Vec<(String, String)>,
246 /// Placeholders left alone because the correspondence was not one-to-one.
247 /// A wrong fill attaches a peer's content to the wrong node, which is worse
248 /// than an unfilled stub, so an ambiguous match fills nothing.
249 pub extrefs_ambiguous: Vec<String>,
250}
251
252/// A [`Skipped`] flattened for JSON output.
253#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
254pub struct SkippedRow {
255 /// The bundle-relative path.
256 pub path: String,
257 /// The reason, as its stable token.
258 pub reason: String,
259}
260
261/// The facts to apply, and what was read to produce them.
262#[derive(Debug, Clone)]
263pub struct OkfImport {
264 /// Nodes and `external-*` edges to apply to the store.
265 pub facts: FactSet,
266 /// A summary of what was imported and what was not.
267 pub report: OkfReport,
268}
269
270/// Errors raised while reading a bundle.
271///
272/// `#[non_exhaustive]`, on [`SkipReason`]'s reasoning and for the same subject:
273/// these name ways a *directory somebody else produced* fails to be a bundle,
274/// and that set is open by construction — OKF is a vendor-neutral format, so the
275/// producers are not ours to enumerate. A caller wants the message, not an
276/// exhaustive match; adding a way to fail should not be a breaking change.
277#[derive(Debug, thiserror::Error)]
278#[non_exhaustive]
279pub enum OkfError {
280 /// The directory holds no markdown at all.
281 #[error("no markdown files under {0}: an OKF bundle is a directory of concept documents")]
282 Empty(String),
283 /// Markdown was found, and none of it was a concept.
284 ///
285 /// Deliberately fatal where a *single* bad document is only skipped: one
286 /// unreadable file in a readable bundle is the case §11 asks consumers to
287 /// tolerate, but a directory in which **nothing** parsed is not a bundle
288 /// read badly — it is not a bundle, and importing zero concepts while
289 /// exiting zero would report success for having done nothing.
290 #[error(
291 "{path} holds {files} markdown file(s) and no readable concept among them, so it is \
292 not an OKF bundle. First failures: {detail}"
293 )]
294 NoConcepts {
295 /// The bundle root, as given.
296 path: String,
297 /// How many markdown files were considered.
298 files: usize,
299 /// Up to three `path: reason` pairs.
300 detail: String,
301 },
302}
303
304/// A concept document's parsed frontmatter, in the subset this reads.
305///
306/// Separate from [`super::Frontmatter`], which is a *render* input: that one
307/// holds an [`Origin`] the renderer will split into `generated`/`verified`, and
308/// this one holds what those two keys actually said, which is not the same
309/// question. Notably a document may carry `verified` and no `generated`.
310#[derive(Debug, Clone, Default, PartialEq, Eq)]
311struct ParsedFrontmatter {
312 type_: String,
313 title: Option<String>,
314 description: Option<String>,
315 resource: Option<String>,
316 status: Option<String>,
317 tags: Vec<String>,
318 sources: Vec<String>,
319 generated: Option<(String, String)>,
320 verified: Vec<(String, String)>,
321}
322
323impl ParsedFrontmatter {
324 /// The trust tier this document claims, as the *local* provenance that
325 /// would have produced it — the exact inverse of ADR-0021's mapping table.
326 ///
327 /// **The absence of `verified` is a claim, not a gap.** §5.3 derives the
328 /// unverified tier from exactly that absence, and ADR-0021's table renders
329 /// `Inferred` as "`generated:` alone" for the same reason: a producer that
330 /// confirmed something says so. So a concept with no `verified` key is
331 /// `Inferred`, not "unknown, assume the best".
332 ///
333 /// §7 makes the `human:` prefix the only thing separating human-reviewed
334 /// from machine-confirmed, so it is the only thing consulted here.
335 fn claimed_tier(&self) -> Provenance {
336 match self.verified.first() {
337 None => Provenance::Inferred,
338 Some((by, _)) if by.starts_with("human:") => Provenance::Authored,
339 Some(_) => Provenance::Derived,
340 }
341 }
342
343 /// The origin to re-emit for this concept: whoever the bundle named, with
344 /// the timestamp it gave. `confirms` is the *effective* confirmation, which
345 /// [`Trust::Acknowledge`] clears — under acknowledge we deliberately did not
346 /// adopt the peer's confirmation, so re-emitting it would put it back.
347 fn effective_origin(&self, trust: Trust) -> Option<Origin> {
348 let confirmed = self.verified.first();
349 let (by, at) = confirmed.or(self.generated.as_ref())?;
350 Some(Origin {
351 by: parse_actor(by),
352 at: at.clone(),
353 confirms: confirmed.is_some() && trust == Trust::Trust,
354 })
355 }
356}
357
358/// An OKF actor token (§7) as an [`Actor`].
359///
360/// The inverse of [`Actor::as_token`], and lossy in one direction on purpose: a
361/// token that is none of the three forms becomes [`Actor::Process`], because §7
362/// names exactly three and an unrecognised one is a producer this graph has not
363/// met rather than a reason to drop the attribution. Losing *who* confirmed
364/// something is the one thing a trust model must not do.
365fn parse_actor(token: &str) -> Actor {
366 if let Some(id) = token.strip_prefix("human:") {
367 return Actor::Human(id.to_owned());
368 }
369 if let Some(id) = token.strip_prefix("process:") {
370 return Actor::Process(id.to_owned());
371 }
372 match token.split_once('/') {
373 Some((producer, version)) => Actor::Tool(producer.to_owned(), version.to_owned()),
374 None => Actor::Process(token.to_owned()),
375 }
376}
377
378/// The [`Origin`] recorded on an imported node by [`read_bundle`], or `None` for
379/// a node this graph produced itself.
380///
381/// `render okf` prefers this over [`super::origin_for`] so an imported concept
382/// leaves carrying the attribution it arrived with. Without it the round trip
383/// re-tiers every external fact to *unverified* on the way out — the laundering
384/// the tier-carrying provenance exists to prevent, arriving one step later.
385#[must_use]
386pub fn peer_origin(meta: &serde_json::Value) -> Option<Origin> {
387 let origin = meta.get("okf")?.get("origin")?;
388 Some(Origin {
389 by: parse_actor(origin.get("by")?.as_str()?),
390 at: origin.get("at")?.as_str()?.to_owned(),
391 confirms: origin
392 .get("confirms")
393 .and_then(serde_json::Value::as_bool)
394 .unwrap_or(false),
395 })
396}
397
398/// Split a document into its frontmatter block and its body.
399///
400/// The opening fence must be the **first** bytes of the file, per §4. A `---`
401/// further down is a horizontal rule, and a reader that went looking for one
402/// would turn an ordinary markdown document into a concept whose "frontmatter"
403/// is its opening prose.
404fn split_frontmatter(text: &str) -> Result<(&str, &str), SkipReason> {
405 let rest = text
406 .strip_prefix("---\n")
407 .or_else(|| text.strip_prefix("---\r\n"))
408 .ok_or(SkipReason::NoFrontmatter)?;
409 let mut offset = 0usize;
410 for line in rest.split_inclusive('\n') {
411 if line.trim_end_matches(['\r', '\n']) == "---" {
412 let body = rest[offset + line.len()..].trim_start_matches(['\r', '\n']);
413 return Ok((&rest[..offset], body));
414 }
415 offset += line.len();
416 }
417 Err(SkipReason::UnterminatedFrontmatter)
418}
419
420/// Parse a frontmatter block with a real YAML parser.
421///
422/// # Why not a line scanner
423///
424/// This reader originally hand-parsed a line-oriented subset shaped like the
425/// bundles Roteiro itself writes. That is enough for a round trip and wrong for
426/// everybody else's bundles, which is the opposite of what an interchange format
427/// is for. Measured against Google's own published bundles (`bundles/ga4`,
428/// `bundles/acme_retail` in the specification's repository), the subset silently
429/// lost:
430///
431/// - **flow mappings** — `generated: { by: agent/1.0, at: … }`, the form the
432/// specification's own examples use throughout, so `generated` and `verified`
433/// both vanished and every concept read as *unverified*;
434/// - **flow sequences** — `tags: [finance, revenue]`;
435/// - **block sequences whose items sit at the key's own indentation**, which is
436/// what `PyYAML` emits by default, so `tags` and `sources` vanished;
437/// - **multi-line scalars**, where a folded `description:` was silently
438/// *truncated* at its first line rather than dropped.
439///
440/// All four are ordinary YAML, and all four were silent: nothing was skipped and
441/// nothing was reported. The trust loss is the serious one — a concept a human
442/// signed off read as unverified, so `import --from okf --trust` adopted nothing
443/// while reporting success. `a_google_bundle_keeps_its_human_verifiers` is the
444/// guard.
445///
446/// `yaml-rust2` is already a non-optional dependency of `rto-graph`, so this
447/// costs a declared edge and no new crate in the lockfile.
448///
449/// Unknown top-level keys are ignored rather than rejected: §11 tells a consumer
450/// not to reject a document for a field it does not know, and a producer with
451/// its own extensions is the case a vendor-neutral format exists to allow.
452fn parse_frontmatter(block: &str) -> Result<ParsedFrontmatter, SkipReason> {
453 let mut fm = ParsedFrontmatter::default();
454 let docs = yaml_rust2::YamlLoader::load_from_str(block)
455 .map_err(|_| SkipReason::UnparsableFrontmatter)?;
456 let Some(first) = docs.first() else {
457 // An empty block parses to no documents. That is well-formed YAML
458 // carrying no keys, so it is a missing `type`, not a parse failure.
459 return Ok(fm);
460 };
461 let Some(map) = first.as_hash() else {
462 // A block that parses to a scalar or a sequence is legal YAML and has
463 // no keys to read, so again: no `type`, rather than unparsable.
464 return Ok(fm);
465 };
466 let get = |key: &str| map.get(&Yaml::String(key.to_owned()));
467
468 if let Some(v) = get("type").and_then(scalar_text) {
469 fm.type_ = v;
470 }
471 fm.title = get("title").and_then(scalar_text);
472 fm.description = get("description").and_then(scalar_text);
473 fm.resource = get("resource").and_then(scalar_text);
474 fm.status = get("status").and_then(scalar_text);
475
476 if let Some(tags) = get("tags") {
477 match tags {
478 Yaml::Array(items) => fm.tags.extend(items.iter().filter_map(scalar_text)),
479 // §4.1 asks for a list, and a bare string is not one — but it is a
480 // shape that really occurs: Google's published `stackoverflow`
481 // bundle writes `tags: stackoverflow, posts, deprecated` in seven
482 // documents.
483 //
484 // Kept **whole**, not split on commas. Splitting would recover the
485 // intent in this bundle and invent a convention the specification
486 // does not have, which is how a reader starts disagreeing with
487 // every other reader about what a document says. Keeping the string
488 // loses nothing and lets a consumer see exactly what was written —
489 // the alternative, dropping it, is the silent loss this whole
490 // parser was rewritten to stop.
491 other => fm.tags.extend(scalar_text(other)),
492 }
493 }
494
495 // §5.1 shapes `sources` as a list of entries. A producer who wrote a single
496 // entry without the list dash is tolerated, mirroring the shorthand §5.2
497 // *does* sanction for `verified` — the shapes are analogous and the slip is
498 // the same one.
499 //
500 // A bare scalar is deliberately **not** tolerated here, unlike for `tags`
501 // above. `tags: a, b` is attested — Google's own `stackoverflow` bundle
502 // writes it in seven documents — whereas no published bundle writes a
503 // scalar `sources`, and there would be no way to tell `sources: foo` from a
504 // typo that happened to land on a key. Accepting it would invent a
505 // provenance record rather than read one, and provenance is the one field
506 // where guessing is worse than reporting nothing.
507 match get("sources") {
508 Some(Yaml::Array(items)) => {
509 for item in items {
510 fm.sources.extend(source_resource(item));
511 }
512 }
513 Some(single @ Yaml::Hash(_)) => fm.sources.extend(source_resource(single)),
514 _ => {}
515 }
516
517 fm.generated = get("generated").and_then(by_at);
518 fm.verified = get("verified").map(verified_entries).unwrap_or_default();
519 Ok(fm)
520}
521
522/// One `sources` entry's `resource` (§5.1), which is REQUIRED within an entry.
523///
524/// An entry carrying no `resource` names nothing a consumer could follow, so it
525/// yields `None` rather than an empty string: a source that resolves to `""` is
526/// worse than one that is absent, because it looks like a record.
527fn source_resource(entry: &Yaml) -> Option<String> {
528 entry
529 .as_hash()?
530 .get(&Yaml::String("resource".to_owned()))
531 .and_then(scalar_text)
532 .filter(|r| !r.trim().is_empty())
533}
534
535/// A YAML scalar as a plain string; containers yield `None`.
536///
537/// `Real` keeps its own source text, so a timestamp survives unretyped rather
538/// than being reformatted through a float.
539fn scalar_text(v: &Yaml) -> Option<String> {
540 match v {
541 Yaml::String(s) | Yaml::Real(s) => Some(s.clone()),
542 Yaml::Integer(i) => Some(i.to_string()),
543 Yaml::Boolean(b) => Some(b.to_string()),
544 _ => None,
545 }
546}
547
548/// One `{ by, at }` mapping (§5.2).
549///
550/// A pair with no `at` keeps an empty timestamp rather than being dropped:
551/// **who** confirmed something is the load-bearing half, and §7 is about the
552/// actor. A mapping with no `by` names nobody, and is dropped.
553fn by_at(node: &Yaml) -> Option<(String, String)> {
554 let map = node.as_hash()?;
555 let by = map
556 .get(&Yaml::String("by".to_owned()))
557 .and_then(scalar_text)?;
558 if by.trim().is_empty() {
559 return None;
560 }
561 let at = map
562 .get(&Yaml::String("at".to_owned()))
563 .and_then(scalar_text)
564 .unwrap_or_default();
565 Some((by, at))
566}
567
568/// The `verified` field as a list of verification events (§5.2).
569///
570/// §5.2 is explicit that *"a single verifier MAY be written as one `{ by, at }`
571/// mapping without the list dash"* and that consumers **MUST** treat a bare
572/// mapping as a one-element list. That MUST is discharged here, in the one place
573/// that can tell the two shapes apart.
574fn verified_entries(node: &Yaml) -> Vec<(String, String)> {
575 match node {
576 Yaml::Array(items) => items.iter().filter_map(by_at).collect(),
577 other => by_at(other).into_iter().collect(),
578 }
579}
580
581/// One link found in a concept's relationships section.
582struct RelLink {
583 kind: String,
584 target: String,
585 reciprocal: bool,
586}
587
588/// The relationship links in a concept body, plus how many markdown links sat
589/// outside the relationships section.
590fn parse_relationships(body: &str) -> (Vec<RelLink>, usize) {
591 let mut links = Vec::new();
592 let mut outside = 0usize;
593 let mut in_section = false;
594 let mut kind = EdgeKind::Related.as_str().to_owned();
595 for line in body.lines() {
596 let trimmed = line.trim();
597 if let Some(heading) = trimmed.strip_prefix("## ") {
598 in_section = heading.trim().eq_ignore_ascii_case("relationships");
599 EdgeKind::Related.as_str().clone_into(&mut kind);
600 continue;
601 }
602 if trimmed.starts_with("# ") {
603 in_section = false;
604 continue;
605 }
606 if let Some(heading) = trimmed.strip_prefix("### ")
607 && in_section
608 {
609 heading.trim().clone_into(&mut kind);
610 continue;
611 }
612 for target in markdown_link_targets(trimmed) {
613 if in_section {
614 links.push(RelLink {
615 kind: kind.clone(),
616 // `→` and `←` are what `render_concept` writes; an unmarked
617 // link (another producer's) reads as outgoing.
618 reciprocal: trimmed.contains('\u{2190}'),
619 target,
620 });
621 } else {
622 outside += 1;
623 }
624 }
625 }
626 (links, outside)
627}
628
629/// Every `[text](target)` target on one line.
630///
631/// Hand-rolled rather than run through the markdown parser this crate already
632/// has: `pulldown-cmark` would give the same answer for a well-formed line and a
633/// *different* one for a malformed bundle, because it recovers. Here a link that
634/// does not close is not a link, which is the reading that cannot invent an edge
635/// out of stray punctuation.
636fn markdown_link_targets(line: &str) -> Vec<String> {
637 let mut out = Vec::new();
638 let bytes = line.as_bytes();
639 let mut i = 0;
640 while i < bytes.len() {
641 if bytes[i] != b'[' {
642 i += 1;
643 continue;
644 }
645 let Some(close) = line[i..].find("](") else {
646 break;
647 };
648 let after = i + close + 2;
649 let Some(end) = line[after..].find(')') else {
650 break;
651 };
652 let target = line[after..after + end].trim();
653 if !target.is_empty() {
654 out.push(target.to_owned());
655 }
656 i = after + end + 1;
657 }
658 out
659}
660
661/// Resolve a link target to a bundle-relative path, as §6's absolute form or as
662/// a path relative to `from`'s own directory.
663fn resolve_target(from: &str, target: &str) -> Option<String> {
664 // A URL or an anchor is not a concept in this bundle.
665 if target.contains("://") || target.starts_with('#') {
666 return None;
667 }
668 let target = target.split('#').next().unwrap_or(target);
669 if target.is_empty() {
670 return None;
671 }
672 if target.starts_with('/') {
673 return Some(normalise(target));
674 }
675 let dir = from.rsplit_once('/').map_or("", |(d, _)| d);
676 Some(normalise(&format!("{dir}/{target}")))
677}
678
679/// Collapse `.`/`..` segments and guarantee a single leading `/`.
680fn normalise(path: &str) -> String {
681 let mut parts: Vec<&str> = Vec::new();
682 for seg in path.split('/') {
683 match seg {
684 "" | "." => {}
685 ".." => {
686 parts.pop();
687 }
688 other => parts.push(other),
689 }
690 }
691 format!("/{}", parts.join("/"))
692}
693
694/// The bundle-relative path of a file, always `/`-separated and leading-slashed.
695fn bundle_path(raw: &str) -> String {
696 normalise(&raw.replace('\\', "/"))
697}
698
699/// Whether a bundle path is one of the reserved index/log files (§8, §9).
700fn is_reserved(path: &str) -> bool {
701 let name = path.rsplit('/').next().unwrap_or(path);
702 name == INDEX_FILE || name == LOG_FILE
703}
704
705/// A concept read out of the bundle, before keys are assigned.
706struct Concept {
707 path: String,
708 fm: ParsedFrontmatter,
709 body: String,
710 links: Vec<RelLink>,
711}
712
713/// Options for [`read_bundle`].
714pub struct ReadOptions<'a> {
715 /// How much of the peer's claim to adopt.
716 pub trust: Trust,
717 /// The peer's name, used for the node-key namespace and the `src_ref`.
718 pub peer: &'a str,
719 /// Keys of `extref:` placeholders already in this graph, which an imported
720 /// concept may fill (ADR-0009). Pass an empty slice to fill none.
721 pub extref_keys: &'a [String],
722}
723
724/// Read an OKF bundle from `(path, content)` pairs into graph facts.
725///
726/// `files` is every markdown file under the bundle root, each keyed by its
727/// bundle-relative path. Taking the file set rather than a directory keeps the
728/// whole rule testable without a filesystem, on `import_lat`'s precedent.
729///
730/// # Errors
731/// Returns [`OkfError::Empty`] when there is no markdown at all, and
732/// [`OkfError::NoConcepts`] when there is markdown and none of it parsed — see
733/// that variant for why one bad document is tolerated and a bundle of them is
734/// not.
735pub fn read_bundle(
736 root: &str,
737 files: &[(String, String)],
738 opts: &ReadOptions<'_>,
739) -> Result<OkfImport, OkfError> {
740 let mut report = OkfReport {
741 files_total: files.len(),
742 ..OkfReport::default()
743 };
744 if files.is_empty() {
745 return Err(OkfError::Empty(root.to_owned()));
746 }
747
748 let (concepts, skipped) = collect_concepts(files, &mut report);
749
750 if concepts.is_empty() {
751 let considered = files.len() - report.reserved_skipped;
752 if considered == 0 {
753 return Err(OkfError::Empty(root.to_owned()));
754 }
755 let detail = skipped
756 .iter()
757 .take(3)
758 .map(|s| format!("{} ({})", s.path, s.reason.as_str()))
759 .collect::<Vec<_>>()
760 .join("; ");
761 return Err(OkfError::NoConcepts {
762 path: root.to_owned(),
763 files: considered,
764 detail,
765 });
766 }
767
768 report.skipped = skipped
769 .into_iter()
770 .map(|s| SkippedRow {
771 path: s.path,
772 reason: s.reason.as_str().to_owned(),
773 })
774 .collect();
775
776 // Assign a key to every concept, filling an `extref:` stub where exactly one
777 // corresponds. `stub_for` is `bundle path -> stub key`.
778 let (stub_for, ambiguous) = extref_fills(&concepts, opts.extref_keys);
779 report.extrefs_ambiguous = ambiguous;
780 let keys: BTreeMap<&str, String> = concepts
781 .iter()
782 .map(|c| {
783 let key = stub_for.get(c.path.as_str()).cloned().unwrap_or_else(|| {
784 format!(
785 "{OKF_KEY_PREFIX}{peer}{path}",
786 peer = opts.peer,
787 path = c.path
788 )
789 });
790 (c.path.as_str(), key)
791 })
792 .collect();
793 for (path, key) in &stub_for {
794 report
795 .extrefs_filled
796 .push((key.clone(), (*path).to_owned()));
797 }
798 report.extrefs_filled.sort();
799
800 let src_ref = import_ref(opts.peer);
801 let mut facts = FactSet::new();
802 for c in &concepts {
803 push_concept(c, opts, &src_ref, &keys, &stub_for, &mut facts, &mut report);
804 }
805
806 Ok(OkfImport { facts, report })
807}
808
809/// Read every markdown file into a concept, or into a reason it is not one.
810///
811/// Both results come back sorted by bundle path, **at this boundary rather than
812/// at the caller's**. [`read_bundle`] is public and takes a slice, so the order
813/// is whatever a caller happened to build; the CLI's directory walk sorts, but
814/// that is one caller's habit and not a property of the reader.
815///
816/// Three things depend on it, and only one of them is cosmetic:
817/// [`OkfReport::skipped`]'s order, the first-three failures named in
818/// [`OkfError::NoConcepts`], and — the one that matters — the order of
819/// `facts.nodes`, which is serialized verbatim into the persisted import layer.
820/// Without this, one unchanged bundle read twice could store two different
821/// layer blobs. [`super::assemble`] sorts on the write side for the same reason.
822fn collect_concepts(
823 files: &[(String, String)],
824 report: &mut OkfReport,
825) -> (Vec<Concept>, Vec<Skipped>) {
826 let mut concepts: Vec<Concept> = Vec::new();
827 let mut skipped: Vec<Skipped> = Vec::new();
828 for (raw_path, content) in files {
829 let path = bundle_path(raw_path);
830 if is_reserved(&path) {
831 report.reserved_skipped += 1;
832 if path == format!("/{INDEX_FILE}") {
833 report.okf_version = root_okf_version(content);
834 }
835 continue;
836 }
837 match split_frontmatter(content) {
838 Err(reason) => skipped.push(Skipped { path, reason }),
839 Ok((block, body)) => {
840 let fm = match parse_frontmatter(block) {
841 Ok(fm) => fm,
842 Err(reason) => {
843 skipped.push(Skipped { path, reason });
844 continue;
845 }
846 };
847 if fm.type_.trim().is_empty() {
848 skipped.push(Skipped {
849 path,
850 reason: SkipReason::MissingType,
851 });
852 continue;
853 }
854 let (links, outside) = parse_relationships(body);
855 report.links_outside_relationships += outside;
856 concepts.push(Concept {
857 path,
858 fm,
859 body: body.to_owned(),
860 links,
861 });
862 }
863 }
864 }
865 concepts.sort_by(|a, b| a.path.cmp(&b.path));
866 skipped.sort_by(|a, b| a.path.cmp(&b.path));
867 (concepts, skipped)
868}
869
870/// Turn one concept into a node and its outgoing edges.
871fn push_concept(
872 c: &Concept,
873 opts: &ReadOptions<'_>,
874 src_ref: &str,
875 keys: &BTreeMap<&str, String>,
876 stub_for: &BTreeMap<&str, String>,
877 facts: &mut FactSet,
878 report: &mut OkfReport,
879) {
880 let key = &keys[c.path.as_str()];
881 let provenance = match opts.trust {
882 Trust::Trust => c.fm.claimed_tier().externalise(),
883 // Their information without their confirmation. `externalise` is
884 // deliberately **not** used here: the tier is *replaced*, not carried.
885 Trust::Acknowledge => Provenance::ExternalInferred,
886 };
887 let name =
888 c.fm.title
889 .clone()
890 .filter(|t| !t.trim().is_empty())
891 .unwrap_or_else(|| {
892 c.path
893 .rsplit('/')
894 .next()
895 .unwrap_or(&c.path)
896 .trim_end_matches(".md")
897 .to_owned()
898 });
899 let mut node =
900 Node::new(key.clone(), NodeKind::from_token(&c.fm.type_), name).with_provenance(provenance);
901 node.meta = concept_meta(
902 c,
903 opts,
904 src_ref,
905 stub_for.get(c.path.as_str()).map(String::as_str),
906 );
907 facts.nodes.push(node);
908 *report
909 .concepts_by_type
910 .entry(c.fm.type_.clone())
911 .or_default() += 1;
912 *report
913 .concepts_by_provenance
914 .entry(provenance.as_str().to_owned())
915 .or_default() += 1;
916 report.concepts_read += 1;
917
918 for link in &c.links {
919 report.links_total += 1;
920 if link.reciprocal {
921 report.links_reciprocal += 1;
922 continue;
923 }
924 let target =
925 resolve_target(&c.path, &link.target).and_then(|t| keys.get(t.as_str()).cloned());
926 let Some(dst) = target else {
927 report.links_unresolved += 1;
928 continue;
929 };
930 let mut edge = Edge::derived(key.clone(), dst, EdgeKind::from_token(&link.kind));
931 // No confidence, ever: OKF carries none for a relationship, so there is
932 // no number to adopt and inventing one would fabricate precision. The
933 // store's `CHECK` and `Edge::is_valid` both say the same thing.
934 edge.provenance = provenance;
935 edge.src_ref = Some(src_ref.to_owned());
936 facts.edges.push(edge);
937 report.edges_read += 1;
938 }
939}
940
941/// The `okf_version` a bundle root's `index.md` declares (§10).
942fn root_okf_version(content: &str) -> Option<String> {
943 let (block, _) = split_frontmatter(content).ok()?;
944 yaml_rust2::YamlLoader::load_from_str(block)
945 .ok()?
946 .first()?
947 .as_hash()?
948 .get(&Yaml::String("okf_version".to_owned()))
949 .and_then(scalar_text)
950 .map(|v| v.trim().to_owned())
951}
952
953/// The `meta` an imported concept carries.
954///
955/// `okf.origin` is what `render okf` re-emits (see [`peer_origin`]);
956/// `okf.claimed` records what the bundle actually said, so an *acknowledge*
957/// import still knows what it declined to adopt and can be re-run as *trust*
958/// without re-reading the bundle. Keeping the peer's claim as data while the
959/// provenance carries only what we accepted is the whole distinction between
960/// the two modes.
961fn concept_meta(
962 c: &Concept,
963 opts: &ReadOptions<'_>,
964 src_ref: &str,
965 fills: Option<&str>,
966) -> serde_json::Value {
967 let mut meta = serde_json::json!({
968 "okf": {
969 "source": src_ref,
970 "peer": opts.peer,
971 "path": c.path,
972 "type": c.fm.type_,
973 "trust": opts.trust.as_str(),
974 "claimed": {
975 "tier": c.fm.claimed_tier().as_str(),
976 "verified": !c.fm.verified.is_empty(),
977 },
978 "resource": c.fm.resource,
979 "status": c.fm.status,
980 "tags": c.fm.tags,
981 "sources": c.fm.sources,
982 },
983 });
984 if let Some(origin) = c.fm.effective_origin(opts.trust) {
985 meta["okf"]["origin"] = serde_json::json!({
986 "by": origin.by.as_token(),
987 "at": origin.at,
988 "confirms": origin.confirms,
989 });
990 }
991 if let Some(desc) = &c.fm.description {
992 meta["okf"]["description"] = serde_json::Value::from(desc.clone());
993 }
994 // The prose, on the same budget the derived and authored layers use — a
995 // second cap here would let the store grow by whichever number was written
996 // down last.
997 let content = rto_graph::cap_content(&c.body);
998 if !content.is_empty() {
999 meta["content"] = serde_json::Value::from(content);
1000 }
1001 // A filled placeholder keeps `qualified` at the top level, because that is
1002 // where `rto_graph::external_ref_target` reads it and the workspace resolver
1003 // follows it across repos (ADR-0009). Filling a stub adds content to it; it
1004 // must not stop it being a stub, or the cross-repo link this whole import
1005 // exists to improve stops resolving at all.
1006 if let Some(qualified) = fills.and_then(|stub| stub.strip_prefix("extref:")) {
1007 meta["qualified"] = serde_json::Value::from(qualified);
1008 }
1009 meta
1010}
1011
1012/// Which `extref:` placeholder each imported concept fills, and which
1013/// placeholders were left alone because the correspondence was ambiguous.
1014///
1015/// # The correspondence is computed forwards, because it cannot be inverted
1016///
1017/// A bundle does **not** carry the producer's node key. The only trace of it is
1018/// the filename, and [`super::slug`] is lossy: it lowercases, collapses every
1019/// run of non-alphanumerics to one `-`, and truncates past 200 characters. So
1020/// `file:src/a.rs` and `file:src-a.rs` both slug to `file-src-a-rs`, and no
1021/// inverse exists. Inverting it is the natural-looking route and it is wrong.
1022///
1023/// What *is* sound is the forward direction: this graph knows its own
1024/// placeholder keys, so it can compute the filename each one **would** have had
1025/// in the peer's bundle — `slug(bare)`, or `slug(bare)-<digest>` when
1026/// [`super::assemble`] had to disambiguate — and compare. That is the writer's
1027/// own rule applied to our keys, not a guess about theirs.
1028///
1029/// It can still be ambiguous, because two of *our* placeholder keys can slug
1030/// alike even though the peer's bundle contained no collision. A concept
1031/// matching more than one placeholder, or a placeholder matching more than one
1032/// concept, fills **nothing** and is reported: a wrong fill attaches a peer's
1033/// content to the wrong node, which is strictly worse than a stub that stayed a
1034/// stub.
1035///
1036/// A bundle from another producer simply does not match, because its filenames
1037/// were not produced by this rule. That is the honest outcome — the concepts are
1038/// still imported, they just do not resolve a placeholder — and it is why this
1039/// is an enhancement rather than the import's purpose.
1040fn extref_fills<'a>(
1041 concepts: &'a [Concept],
1042 extref_keys: &[String],
1043) -> (BTreeMap<&'a str, String>, Vec<String>) {
1044 // stub key -> the concept paths it could name.
1045 let mut by_stub: BTreeMap<&str, Vec<&str>> = BTreeMap::new();
1046 // concept path -> the stub keys that could name it.
1047 let mut by_path: BTreeMap<&str, Vec<&str>> = BTreeMap::new();
1048
1049 for stub in extref_keys {
1050 let Some(qualified) = stub.strip_prefix("extref:") else {
1051 continue;
1052 };
1053 let Some((_project, bare)) = rto_graph::parse_qualified(qualified) else {
1054 continue;
1055 };
1056 let bare_slug = slug(bare);
1057 let with_digest = format!("{bare_slug}-{}", short_digest(bare));
1058 for c in concepts {
1059 let (dir, file) = c.path.rsplit_once('/').unwrap_or(("", &c.path));
1060 let name = file.trim_end_matches(".md");
1061 let section = dir.rsplit('/').next().unwrap_or("");
1062 if section != section_for(&c.fm.type_) {
1063 continue;
1064 }
1065 if name == bare_slug || name == with_digest {
1066 by_stub.entry(stub).or_default().push(&c.path);
1067 by_path.entry(&c.path).or_default().push(stub);
1068 }
1069 }
1070 }
1071
1072 let mut fills = BTreeMap::new();
1073 let mut ambiguous: Vec<String> = Vec::new();
1074 for (stub, paths) in &by_stub {
1075 match paths.as_slice() {
1076 [only] if by_path.get(*only).is_some_and(|s| s.len() == 1) => {
1077 fills.insert(*only, (*stub).to_owned());
1078 }
1079 _ => ambiguous.push((*stub).to_owned()),
1080 }
1081 }
1082 ambiguous.sort();
1083 ambiguous.dedup();
1084 (fills, ambiguous)
1085}
1086
1087#[cfg(test)]
1088mod tests {
1089 use super::*;
1090 use crate::okf::{Concept as RenderConcept, Frontmatter, OKF_VERSION, assemble, origin_for};
1091 use rto_graph::{EdgeRef, Explanation, NodeSummary};
1092
1093 fn opts(trust: Trust, extref_keys: &[String]) -> ReadOptions<'_> {
1094 ReadOptions {
1095 trust,
1096 peer: "acme",
1097 extref_keys,
1098 }
1099 }
1100
1101 fn read(files: &[(&str, &str)], trust: Trust) -> OkfImport {
1102 let owned: Vec<(String, String)> = files
1103 .iter()
1104 .map(|(p, c)| ((*p).to_owned(), (*c).to_owned()))
1105 .collect();
1106 read_bundle("okf/", &owned, &opts(trust, &[])).expect("read")
1107 }
1108
1109 fn node_named<'a>(import: &'a OkfImport, key: &str) -> &'a rto_graph::Node {
1110 import
1111 .facts
1112 .nodes
1113 .iter()
1114 .find(|n| n.key == key)
1115 .unwrap_or_else(|| panic!("no node {key} in {:?}", keys(import)))
1116 }
1117
1118 fn keys(import: &OkfImport) -> Vec<&str> {
1119 import.facts.nodes.iter().map(|n| n.key.as_str()).collect()
1120 }
1121
1122 fn summary(key: &str, kind: &str, name: &str) -> NodeSummary {
1123 NodeSummary {
1124 key: key.to_owned(),
1125 kind: kind.to_owned(),
1126 name: name.to_owned(),
1127 path: None,
1128 lang: None,
1129 }
1130 }
1131
1132 fn explanation(
1133 key: &str,
1134 kind: &str,
1135 name: &str,
1136 out: Vec<EdgeRef>,
1137 inc: Vec<EdgeRef>,
1138 ) -> Explanation {
1139 Explanation {
1140 schema: rto_graph::SCHEMA,
1141 node: summary(key, kind, name),
1142 meta: serde_json::Value::Null,
1143 outgoing: out,
1144 incoming: inc,
1145 }
1146 }
1147
1148 fn edge_ref(to: &str) -> EdgeRef {
1149 EdgeRef {
1150 kind: "references".to_owned(),
1151 provenance: "authored",
1152 confidence: None,
1153 node: to.to_owned(),
1154 }
1155 }
1156
1157 /// A Roteiro bundle round-trips: what [`assemble`] wrote, this reads, and
1158 /// each concept comes back at the **external** tier matching the one it went
1159 /// out at.
1160 ///
1161 /// The write side is the specification, so the fixture is produced by
1162 /// rendering rather than written by hand: a hand-written fixture keeps
1163 /// passing after the renderer changes shape, which is the one failure a
1164 /// round-trip test exists to catch.
1165 #[test]
1166 fn a_roteiro_bundle_round_trips_at_the_external_tier() {
1167 let at = "2026-09-01T10:00:00Z";
1168 let tool = Actor::Tool("roteiro".to_owned(), "5.0.0".to_owned());
1169 let alice = Actor::Human("alice".to_owned());
1170
1171 // One relationship, written into **both** documents by the renderer:
1172 // outgoing from the ADR, incoming on the file. Reading both would
1173 // reverse half the graph, so the fixture has to contain both halves.
1174 let adr = explanation(
1175 "adr:0021",
1176 "adr",
1177 "OKF bundle",
1178 vec![edge_ref("file:src/lib.rs")],
1179 Vec::new(),
1180 );
1181 let file = explanation(
1182 "file:src/lib.rs",
1183 "file",
1184 "lib.rs",
1185 Vec::new(),
1186 vec![edge_ref("adr:0021")],
1187 );
1188 let guess = explanation("sym:rust:src/lib.rs#f", "fn", "f", Vec::new(), Vec::new());
1189
1190 let rendered = assemble(
1191 vec![
1192 RenderConcept {
1193 explanation: &adr,
1194 frontmatter: Frontmatter {
1195 type_: "adr".to_owned(),
1196 title: Some("OKF bundle".to_owned()),
1197 origin: Some(origin_for(Provenance::Authored, at, &tool, Some(&alice))),
1198 ..Frontmatter::default()
1199 },
1200 body: Some("The decision text.".to_owned()),
1201 member: None,
1202 },
1203 RenderConcept {
1204 explanation: &file,
1205 frontmatter: Frontmatter {
1206 type_: "file".to_owned(),
1207 title: Some("lib.rs".to_owned()),
1208 origin: Some(origin_for(Provenance::Derived, at, &tool, None)),
1209 ..Frontmatter::default()
1210 },
1211 body: None,
1212 member: None,
1213 },
1214 RenderConcept {
1215 explanation: &guess,
1216 frontmatter: Frontmatter {
1217 type_: "fn".to_owned(),
1218 title: Some("f".to_owned()),
1219 origin: Some(origin_for(Provenance::Inferred, at, &tool, None)),
1220 ..Frontmatter::default()
1221 },
1222 body: None,
1223 member: None,
1224 },
1225 ],
1226 "acme",
1227 &[],
1228 );
1229
1230 let files: Vec<(String, String)> = rendered
1231 .iter()
1232 .map(|f| (f.path.clone(), f.content.clone()))
1233 .collect();
1234 let import = read_bundle("okf/", &files, &opts(Trust::Trust, &[])).expect("read");
1235 assert_round_trip(&import, at);
1236 }
1237
1238 /// The assertions of [`a_roteiro_bundle_round_trips_at_the_external_tier`],
1239 /// split out so the fixture that renders the bundle and the claims made
1240 /// about reading it back stay separately readable.
1241 fn assert_round_trip(import: &OkfImport, at: &str) {
1242 assert_eq!(import.report.concepts_read, 3, "{:?}", keys(import));
1243 assert_eq!(import.report.okf_version.as_deref(), Some(OKF_VERSION));
1244
1245 // Each tier survived the round trip, carried rather than flattened.
1246 let by_prov: BTreeMap<&str, &str> = import
1247 .facts
1248 .nodes
1249 .iter()
1250 .map(|n| (n.key.as_str(), n.provenance.as_str()))
1251 .collect();
1252 assert_eq!(
1253 by_prov,
1254 BTreeMap::from([
1255 ("okf:acme/decisions/adr-0021.md", "external-authored"),
1256 ("okf:acme/files/file-src-lib-rs.md", "external-derived"),
1257 (
1258 "okf:acme/symbols/sym-rust-src-lib-rs-f.md",
1259 "external-inferred"
1260 ),
1261 ]),
1262 "a flat `External` would collapse these three into one"
1263 );
1264
1265 // The relationship came back, once, pointing the same way.
1266 assert_eq!(import.facts.edges.len(), 1);
1267 let e = &import.facts.edges[0];
1268 assert_eq!(e.src, "okf:acme/decisions/adr-0021.md");
1269 assert_eq!(e.dst, "okf:acme/files/file-src-lib-rs.md");
1270 assert_eq!(e.kind.as_str(), "references");
1271 assert_eq!(e.provenance, Provenance::ExternalAuthored);
1272 assert_eq!(
1273 e.confidence, None,
1274 "an imported edge carries no confidence this graph never computed"
1275 );
1276 assert!(e.is_valid(), "and must still satisfy the store's invariant");
1277 assert_eq!(
1278 import.report.links_reciprocal, 1,
1279 "the `left-arrow` half of the same edge is skipped, not reversed"
1280 );
1281
1282 // Title, body and the peer's own attribution came with it.
1283 let adr_node = node_named(import, "okf:acme/decisions/adr-0021.md");
1284 assert_eq!(adr_node.name, "OKF bundle");
1285 assert_eq!(adr_node.kind.as_str(), "adr");
1286 assert!(
1287 adr_node.meta["content"]
1288 .as_str()
1289 .expect("content")
1290 .contains("The decision text."),
1291 "{:?}",
1292 adr_node.meta["content"]
1293 );
1294 assert_eq!(
1295 peer_origin(&adr_node.meta),
1296 Some(Origin {
1297 by: Actor::Human("alice".to_owned()),
1298 at: at.to_owned(),
1299 confirms: true,
1300 }),
1301 "Alice's confirmation is re-emitted naming Alice, not re-tiered"
1302 );
1303 }
1304
1305 const AUTHORED: &str = "---\ntype: \"adr\"\ntitle: \"A decision\"\ngenerated:\n by: \"human:alice\"\n at: \"2026-09-01T10:00:00Z\"\nverified:\n - by: \"human:alice\"\n at: \"2026-09-01T10:00:00Z\"\n---\n\n# A decision\n\nBody.\n";
1306
1307 #[test]
1308 fn trust_preserves_the_peers_tier_and_acknowledge_replaces_it() {
1309 let trusted = read(&[("/decisions/a.md", AUTHORED)], Trust::Trust);
1310 let node = &trusted.facts.nodes[0];
1311 assert_eq!(node.provenance, Provenance::ExternalAuthored);
1312 assert!(peer_origin(&node.meta).expect("origin").confirms);
1313
1314 let acked = read(&[("/decisions/a.md", AUTHORED)], Trust::Acknowledge);
1315 let node = &acked.facts.nodes[0];
1316 assert_eq!(
1317 node.provenance,
1318 Provenance::ExternalInferred,
1319 "acknowledge takes their information without their confirmation"
1320 );
1321 // What they claimed is still recorded — as data, not as provenance — so
1322 // the import can be re-run as `trust` without re-reading the bundle.
1323 assert_eq!(node.meta["okf"]["claimed"]["tier"], "authored");
1324 assert_eq!(node.meta["okf"]["trust"], "acknowledge");
1325 assert!(
1326 !peer_origin(&node.meta).expect("origin").confirms,
1327 "and re-rendering must not put the confirmation back"
1328 );
1329 }
1330
1331 /// Section 5.3 derives *unverified* from the absence of `verified`. So the
1332 /// absence is a claim, not missing data, and a concept without one is
1333 /// `external-inferred` even under **trust** — the mode that preserves what
1334 /// the peer said.
1335 #[test]
1336 fn a_concept_with_no_verified_key_is_unverified_not_unknown() {
1337 let doc = "---\ntype: \"doc\"\ngenerated:\n by: \"roteiro/5.0.0\"\n at: \"2026-09-01T00:00:00Z\"\n---\n\n# D\n";
1338 let import = read(&[("/docs/d.md", doc)], Trust::Trust);
1339 assert_eq!(
1340 import.facts.nodes[0].provenance,
1341 Provenance::ExternalInferred
1342 );
1343 }
1344
1345 /// A non-`human:` verifier is machine-confirmed, per section 7 — the
1346 /// `human:` prefix is the only thing separating the two tiers.
1347 #[test]
1348 fn a_tool_verifier_is_machine_confirmed() {
1349 let doc = "---\ntype: \"file\"\nverified:\n - by: \"roteiro/5.0.0\"\n at: \"2026-09-01T00:00:00Z\"\n---\n\n# F\n";
1350 let import = read(&[("/files/f.md", doc)], Trust::Trust);
1351 assert_eq!(
1352 import.facts.nodes[0].provenance,
1353 Provenance::ExternalDerived
1354 );
1355 }
1356
1357 /// The spec's only hard requirement is a non-empty `type`, and it leaves the
1358 /// *value* open. So an unknown one is imported rather than refused —
1359 /// refusing would reject conformant bundles from the very producers a
1360 /// vendor-neutral format exists to interoperate with.
1361 #[test]
1362 fn an_unrecognised_type_is_imported_as_an_other_kind() {
1363 let doc = "---\ntype: \"dataset\"\ntitle: \"Sales\"\n---\n\n# Sales\n";
1364 let import = read(&[("/things/s.md", doc)], Trust::Trust);
1365 assert_eq!(
1366 import.facts.nodes[0].kind,
1367 NodeKind::Other("dataset".to_owned())
1368 );
1369 assert_eq!(import.report.concepts_by_type["dataset"], 1);
1370 }
1371
1372 /// A bad document is skipped **with a reason**, and the readable ones still
1373 /// arrive: section 11 asks a consumer to be liberal, and a silent drop would
1374 /// leave a graph missing concepts nobody knows to look for.
1375 #[test]
1376 fn a_partly_readable_bundle_reports_what_it_skipped() {
1377 let import = read(
1378 &[
1379 ("/decisions/good.md", AUTHORED),
1380 ("/decisions/plain.md", "# Just markdown\n"),
1381 ("/decisions/open.md", "---\ntype: \"adr\"\nnever closed\n"),
1382 ("/decisions/typeless.md", "---\ntitle: \"x\"\n---\n\nBody\n"),
1383 // Delimited correctly, `type` plainly present, and still not
1384 // YAML: the flow sequence is never closed.
1385 ("/decisions/broken.md", "---\ntype: [adr\n---\n\nBody\n"),
1386 ],
1387 Trust::Trust,
1388 );
1389 assert_eq!(import.report.concepts_read, 1);
1390 let rows: Vec<(&str, &str)> = import
1391 .report
1392 .skipped
1393 .iter()
1394 .map(|s| (s.path.as_str(), s.reason.as_str()))
1395 .collect();
1396 // Sorted by path, not by the order the files were handed over — see
1397 // `collect_concepts`. Asserting the *whole* list in a fixed order is the
1398 // point: a report that named the same three skips in a different order
1399 // each run would be a report nobody could diff.
1400 assert_eq!(
1401 rows,
1402 vec![
1403 (
1404 "/decisions/broken.md",
1405 "frontmatter block is not parseable YAML"
1406 ),
1407 ("/decisions/open.md", "frontmatter block is never closed"),
1408 ("/decisions/plain.md", "no YAML frontmatter block"),
1409 (
1410 "/decisions/typeless.md",
1411 "no non-empty `type` (OKF's one required key)"
1412 ),
1413 ],
1414 "unparseable YAML and a missing `type` are separate reasons: both end \
1415 with no type, but one means *add a key* and the other means *the \
1416 block does not parse*"
1417 );
1418 }
1419
1420 /// The shapes a real producer writes that §4.1 and §5.1 do not describe.
1421 ///
1422 /// Each choice here is a judgement about *liberality*, and they deliberately
1423 /// do not all go the same way — so they are asserted together, where the
1424 /// asymmetry is visible and has to be defended rather than drifted into.
1425 #[test]
1426 fn an_off_spec_shape_is_read_where_a_real_producer_writes_one() {
1427 // Attested: Google's `stackoverflow` bundle writes exactly this in seven
1428 // documents. Kept whole rather than split on commas, because splitting
1429 // invents a convention no other reader would share.
1430 let bare_tags = "---\ntype: \"adr\"\ntags: stackoverflow, posts, deprecated\n---\n\nB\n";
1431 // Not attested anywhere, but analogous to the single-mapping shorthand
1432 // §5.2 explicitly sanctions for `verified`.
1433 let one_source =
1434 "---\ntype: \"adr\"\nsources:\n resource: \"/tables/orders.md\"\n---\n\nB\n";
1435 // Refused: a scalar `sources` is indistinguishable from a typo, and a
1436 // guessed provenance record is worse than none.
1437 let scalar_source = "---\ntype: \"adr\"\nsources: \"/tables/orders.md\"\n---\n\nB\n";
1438 // Refused: §5.1 makes `resource` REQUIRED within an entry, so an entry
1439 // without one names nothing to follow.
1440 let no_resource =
1441 "---\ntype: \"adr\"\nsources:\n - id: \"x\"\n title: \"T\"\n---\n\nB\n";
1442
1443 let tags_of = |doc: &str| {
1444 let (block, _) = split_frontmatter(doc).expect("split");
1445 parse_frontmatter(block).expect("parse").tags
1446 };
1447 let sources_of = |doc: &str| {
1448 let (block, _) = split_frontmatter(doc).expect("split");
1449 parse_frontmatter(block).expect("parse").sources
1450 };
1451
1452 assert_eq!(
1453 tags_of(bare_tags),
1454 vec!["stackoverflow, posts, deprecated".to_owned()],
1455 "a bare `tags` string is kept verbatim as one tag: nothing is lost, \
1456 and no comma convention is invented"
1457 );
1458 assert_eq!(
1459 sources_of(one_source),
1460 vec!["/tables/orders.md".to_owned()],
1461 "a single `sources` entry written without the list dash is read, \
1462 mirroring the shorthand §5.2 sanctions for `verified`"
1463 );
1464 assert_eq!(
1465 sources_of(scalar_source),
1466 Vec::<String>::new(),
1467 "a scalar `sources` is not read: it cannot be told from a typo, and \
1468 provenance is the one field where a guess is worse than silence"
1469 );
1470 assert_eq!(
1471 sources_of(no_resource),
1472 Vec::<String>::new(),
1473 "§5.1 makes `resource` REQUIRED within an entry; an entry without \
1474 one names nothing a consumer could follow"
1475 );
1476 }
1477
1478 /// One unreadable document is tolerated; a directory of them is not a bundle
1479 /// read badly, it is not a bundle — and importing zero concepts while
1480 /// exiting zero would report success for having done nothing.
1481 #[test]
1482 fn a_directory_with_no_readable_concept_is_refused_whole() {
1483 let files = vec![
1484 ("okf/a.md".to_owned(), "# no frontmatter\n".to_owned()),
1485 ("okf/b.md".to_owned(), "plain text\n".to_owned()),
1486 ];
1487 let err = read_bundle("okf/", &files, &opts(Trust::Trust, &[])).expect_err("refuse");
1488 assert_eq!(
1489 err.to_string(),
1490 "okf/ holds 2 markdown file(s) and no readable concept among them, so it is not \
1491 an OKF bundle. First failures: /okf/a.md (no YAML frontmatter block); \
1492 /okf/b.md (no YAML frontmatter block)",
1493 );
1494 }
1495
1496 #[test]
1497 fn an_empty_directory_is_refused_by_name() {
1498 let err = read_bundle("okf/", &[], &opts(Trust::Trust, &[])).expect_err("refuse");
1499 assert_eq!(
1500 err.to_string(),
1501 "no markdown files under okf/: an OKF bundle is a directory of concept documents",
1502 );
1503 }
1504
1505 /// A bundle of nothing but reserved files is *empty*, not unreadable: there
1506 /// were no concept documents to fail on, so the message must not accuse the
1507 /// index of being malformed.
1508 #[test]
1509 fn a_bundle_of_only_reserved_files_is_empty_rather_than_unreadable() {
1510 let files = vec![
1511 (
1512 format!("/{INDEX_FILE}"),
1513 format!("---\nokf_version: \"{OKF_VERSION}\"\n---\n\n# Index\n"),
1514 ),
1515 (format!("/{LOG_FILE}"), "# Log\n".to_owned()),
1516 ];
1517 let err = read_bundle("okf/", &files, &opts(Trust::Trust, &[])).expect_err("refuse");
1518 assert_eq!(
1519 err.to_string(),
1520 "no markdown files under okf/: an OKF bundle is a directory of concept documents",
1521 );
1522 }
1523
1524 const A_DOC: &str = "---\ntype: \"doc\"\n---\n\n# A\n\nSee [B](/docs/b.md) in prose.\n\n## Relationships\n\n### references\n\n* \u{2192} [b](/docs/b.md)\n* \u{2192} [gone](/docs/gone.md)\n* \u{2190} [c](/docs/c.md)\n";
1525
1526 #[test]
1527 fn only_links_under_relationships_become_edges() {
1528 let import = read(
1529 &[
1530 ("/docs/a.md", A_DOC),
1531 ("/docs/b.md", "---\ntype: \"doc\"\n---\n\n# B\n"),
1532 ("/docs/c.md", "---\ntype: \"doc\"\n---\n\n# C\n"),
1533 ],
1534 Trust::Trust,
1535 );
1536 assert_eq!(import.facts.edges.len(), 1, "{:?}", import.facts.edges);
1537 assert_eq!(import.facts.edges[0].dst, "okf:acme/docs/b.md");
1538 assert_eq!(
1539 import.report.links_outside_relationships, 1,
1540 "the prose citation is counted, not imported as a relationship"
1541 );
1542 assert_eq!(
1543 import.report.links_unresolved, 1,
1544 "an edge to a concept the bundle does not contain is dropped and said so"
1545 );
1546 assert_eq!(import.report.links_reciprocal, 1);
1547 }
1548
1549 /// `yaml_scalar` escapes a newline, a quote and every control character, and
1550 /// a reader that did not undo exactly that would hand back a different
1551 /// string while looking fine. The fixture is produced by the writer, so the
1552 /// two cannot drift apart.
1553 #[test]
1554 fn a_scalar_round_trips_through_the_writers_escaper() {
1555 let hostile = "line one\nkey: forged\t\"quoted\" \\ back \u{1}";
1556 let fm = Frontmatter {
1557 type_: "doc".to_owned(),
1558 title: Some(hostile.to_owned()),
1559 ..Frontmatter::default()
1560 };
1561 let doc = format!("{}\n# x\n", fm.render());
1562 let (block, _) = split_frontmatter(&doc).expect("split");
1563 let fm = parse_frontmatter(block).expect("the writer emits parseable YAML");
1564 assert_eq!(fm.title.as_deref(), Some(hostile));
1565 }
1566
1567 #[test]
1568 fn an_imported_concept_fills_the_matching_placeholder() {
1569 let stub = rto_graph::external_ref_key("acme::adr:0021");
1570 let stubs = vec![stub.clone()];
1571 let files = vec![("/decisions/adr-0021.md".to_owned(), AUTHORED.to_owned())];
1572 let import = read_bundle("okf/", &files, &opts(Trust::Trust, &stubs)).expect("read");
1573
1574 assert_eq!(keys(&import), vec![stub.as_str()]);
1575 let node = node_named(&import, &stub);
1576 assert_eq!(node.name, "A decision");
1577 assert_eq!(node.provenance, Provenance::ExternalAuthored);
1578 assert!(node.meta.get("content").is_some(), "a stub gained content");
1579 // Filling it must not stop it being a placeholder: the workspace
1580 // resolver follows `meta.qualified` across repos (ADR-0009).
1581 assert_eq!(node.meta["qualified"], "acme::adr:0021");
1582 assert_eq!(
1583 import.report.extrefs_filled,
1584 vec![(stub, "/decisions/adr-0021.md".to_owned())]
1585 );
1586 }
1587
1588 /// `slug` is **not invertible**: it lowercases and collapses every run of
1589 /// non-alphanumerics, so two different keys can produce one filename. When
1590 /// they do, nothing is filled — a wrong fill attaches a peer's content to
1591 /// the wrong node, which is worse than a stub that stayed a stub.
1592 #[test]
1593 fn an_ambiguous_correspondence_fills_nothing_and_says_so() {
1594 // Both slug to `adr-0021`, which is the whole point of the fixture.
1595 assert_eq!(slug("adr:0021"), slug("adr/0021"));
1596 let a = rto_graph::external_ref_key("acme::adr:0021");
1597 let b = rto_graph::external_ref_key("acme::adr/0021");
1598 let stubs = vec![a.clone(), b.clone()];
1599 let files = vec![("/decisions/adr-0021.md".to_owned(), AUTHORED.to_owned())];
1600 let import = read_bundle("okf/", &files, &opts(Trust::Trust, &stubs)).expect("read");
1601
1602 assert_eq!(
1603 keys(&import),
1604 vec!["okf:acme/decisions/adr-0021.md"],
1605 "the concept is still imported, just not attached to a placeholder"
1606 );
1607 assert!(import.report.extrefs_filled.is_empty());
1608 assert_eq!(import.report.extrefs_ambiguous, vec![b, a]);
1609 }
1610
1611 /// The section check is what establishes that the **bundle was written by
1612 /// the placement rule the filename comparison assumes**, and it is not
1613 /// decoration: a concept sitting somewhere `section_for` would never have
1614 /// put it came from a producer with its own layout, so its filename was not
1615 /// produced by [`slug`] either and a name that happens to match means
1616 /// nothing.
1617 ///
1618 /// Here the filename is exactly right and the directory is not, which is
1619 /// precisely the case a filename-only comparison would fill wrongly.
1620 #[test]
1621 fn a_concept_outside_the_layout_the_naming_rule_assumes_is_not_a_match() {
1622 let stubs = vec![rto_graph::external_ref_key("acme::adr:0021")];
1623 assert_eq!(section_for("adr"), "decisions");
1624 let files = vec![(
1625 "/notes/adr-0021.md".to_owned(),
1626 "---\ntype: \"adr\"\n---\n\n# x\n".to_owned(),
1627 )];
1628 let import = read_bundle("okf/", &files, &opts(Trust::Trust, &stubs)).expect("read");
1629 assert!(import.report.extrefs_filled.is_empty());
1630 assert!(import.report.extrefs_ambiguous.is_empty());
1631 assert_eq!(keys(&import), vec!["okf:acme/notes/adr-0021.md"]);
1632 }
1633
1634 /// Reading one bundle twice gives the same answer, whatever order the files
1635 /// arrive in.
1636 ///
1637 /// `read_bundle` takes a slice, so the order is the caller's — and the CLI's
1638 /// directory walk sorting is that caller's habit, not the reader's contract.
1639 /// The stake is not tidiness: `facts.nodes` is serialized verbatim into the
1640 /// persisted import layer, so an unsorted caller would make one unchanged
1641 /// bundle store a different blob on each read.
1642 #[test]
1643 fn the_answer_does_not_depend_on_the_order_the_files_arrive_in() {
1644 let files: Vec<(String, String)> = vec![
1645 ("/decisions/a.md", AUTHORED),
1646 ("/docs/b.md", "---\ntype: \"doc\"\n---\n\n# B\n"),
1647 ("/docs/plain.md", "# no frontmatter\n"),
1648 ("/docs/typeless.md", "---\ntitle: \"x\"\n---\n\nB\n"),
1649 ("/symbols/c.md", "---\ntype: \"fn\"\n---\n\n# C\n"),
1650 ]
1651 .into_iter()
1652 .map(|(p, c)| (p.to_owned(), c.to_owned()))
1653 .collect();
1654
1655 let forwards = read_bundle("okf/", &files, &opts(Trust::Trust, &[])).expect("read");
1656 let mut backwards_input = files;
1657 backwards_input.reverse();
1658 let backwards =
1659 read_bundle("okf/", &backwards_input, &opts(Trust::Trust, &[])).expect("read");
1660
1661 assert_eq!(
1662 keys(&forwards),
1663 keys(&backwards),
1664 "node order is what reaches the persisted import layer"
1665 );
1666 assert_eq!(
1667 forwards
1668 .report
1669 .skipped
1670 .iter()
1671 .map(|s| s.path.as_str())
1672 .collect::<Vec<_>>(),
1673 backwards
1674 .report
1675 .skipped
1676 .iter()
1677 .map(|s| s.path.as_str())
1678 .collect::<Vec<_>>(),
1679 );
1680 // And the whole fact set, byte for byte, which is the property the store
1681 // actually depends on.
1682 assert_eq!(
1683 serde_json::to_string(&forwards.facts).expect("json"),
1684 serde_json::to_string(&backwards.facts).expect("json"),
1685 );
1686 }
1687
1688 /// Every field `concept_meta` writes, asserted once.
1689 ///
1690 /// The fields are the peer's own record of what they published, and most of
1691 /// them had no test at all: `tags`, `sources`, `resource`, `status`, `peer`
1692 /// and `path` were constructed and never read back, so any of them could
1693 /// have been dropped, renamed or crossed with its neighbour and every other
1694 /// test would still have passed.
1695 ///
1696 /// Written as **one whole-value comparison** rather than a field at a time,
1697 /// so a field added to `concept_meta` without a decision about it fails here
1698 /// instead of arriving unnoticed. The two halves that vary per import —
1699 /// `origin` and `content` — are checked separately below.
1700 #[test]
1701 fn the_peers_own_record_survives_the_import_intact() {
1702 let doc = "---\ntype: \"adr\"\ntitle: \"A decision\"\ndescription: \"One sentence.\"\nresource: \"https://example.test/blob/abc/docs/adr/0001.md\"\nstatus: \"Accepted\"\ntags:\n - \"architecture\"\n - \"storage\"\nverified:\n - by: \"human:alice\"\n at: \"2026-09-01T10:00:00Z\"\nsources:\n - resource: \"/docs/adr/0001.md\"\n---\n\n# A decision\n\nThe prose.\n";
1703 let import = read(&[("/decisions/a.md", doc)], Trust::Trust);
1704 let meta = &import.facts.nodes[0].meta;
1705
1706 let mut okf = meta["okf"].clone();
1707 // Checked on their own terms just below; removed so the comparison
1708 // covers everything else exhaustively.
1709 let origin = okf["origin"].take();
1710 assert_eq!(
1711 okf,
1712 serde_json::json!({
1713 "source": "import:okf/acme",
1714 "peer": "acme",
1715 "path": "/decisions/a.md",
1716 "type": "adr",
1717 "trust": "trust",
1718 "claimed": { "tier": "authored", "verified": true },
1719 "resource": "https://example.test/blob/abc/docs/adr/0001.md",
1720 "status": "Accepted",
1721 "tags": ["architecture", "storage"],
1722 "sources": ["/docs/adr/0001.md"],
1723 "description": "One sentence.",
1724 "origin": serde_json::Value::Null,
1725 }),
1726 );
1727 assert_eq!(
1728 origin,
1729 serde_json::json!({
1730 "by": "human:alice",
1731 "at": "2026-09-01T10:00:00Z",
1732 "confirms": true,
1733 }),
1734 );
1735 assert_eq!(meta["content"], "# A decision The prose.");
1736 // Not a placeholder, so no `qualified` — that key is what
1737 // `external_ref_target` reads, and writing it on a node that stands in
1738 // for nothing would make the workspace resolver chase an empty target.
1739 assert_eq!(meta.get("qualified"), None);
1740 }
1741
1742 #[test]
1743 fn a_relative_link_resolves_against_its_own_directory() {
1744 assert_eq!(
1745 resolve_target("/a/b/c.md", "../d/e.md").as_deref(),
1746 Some("/a/d/e.md")
1747 );
1748 assert_eq!(
1749 resolve_target("/a/b/c.md", "/x/y.md").as_deref(),
1750 Some("/x/y.md")
1751 );
1752 assert_eq!(resolve_target("/a/b/c.md", "https://x/y").as_deref(), None);
1753 assert_eq!(resolve_target("/a/b/c.md", "#anchor").as_deref(), None);
1754 }
1755
1756 #[test]
1757 fn an_actor_token_round_trips_and_never_loses_the_attribution() {
1758 for token in ["human:alice", "roteiro/5.0.0", "process:sync"] {
1759 assert_eq!(parse_actor(token).as_token(), token);
1760 }
1761 // An unrecognised form keeps the attribution rather than dropping it.
1762 assert_eq!(parse_actor("mystery").as_token(), "process:mystery");
1763 }
1764
1765 #[test]
1766 fn an_unknown_frontmatter_key_takes_its_children_with_it() {
1767 let block = "type: \"doc\"\nvendor_thing:\n by: \"not-an-actor\"\n nested:\n - x\ntitle: \"kept\"\n";
1768 let fm = parse_frontmatter(block).expect("parseable YAML");
1769 assert_eq!(fm.type_, "doc");
1770 assert_eq!(fm.title.as_deref(), Some("kept"));
1771 assert_eq!(
1772 fm.generated, None,
1773 "a `by:` nested under an unknown key is not the document's origin"
1774 );
1775 }
1776}