rto_render/okf.rs
1//! Render the graph as an **Open Knowledge Format** bundle (issue #663).
2//!
3//! OKF v0.2 is Google Cloud's vendor-neutral specification for the "LLM wiki"
4//! pattern: a directory of markdown concept documents carrying YAML frontmatter,
5//! reserved `index.md` and `log.md` files, and plain markdown links between
6//! concepts. The whole specification fits on a page, and its only hard
7//! requirement is that every concept document carries a non-empty `type`.
8//!
9//! <https://github.com/GoogleCloudPlatform/open-knowledge-format/blob/main/SPEC.md>
10//!
11//! # Why this replaced the Obsidian vault
12//!
13//! The vault was **one-way**: Roteiro wrote it, nothing read it back, and no
14//! tool but Obsidian could consume it. An open format with named consumers earns
15//! the same machinery better. Two concrete gains beyond that:
16//!
17//! - **The hierarchy retires a class of bug.** The vault flattened every note
18//! into one directory and appended a hash to each filename, because
19//! case-insensitive filesystems fold names that differ only in case — a defect
20//! that once cost this repository 104 notes of 8,144. OKF nests concepts in
21//! directories, so the collision the hash existed to survive does not arise.
22//! - **Provenance stops being decoration.** Obsidian had nowhere to put it but a
23//! tag. OKF has a trust model, and it is the one Roteiro already computes.
24//!
25//! # The provenance mapping, which is the point
26//!
27//! Most producers will emit `type` and little else. Roteiro's authored/derived/
28//! inferred distinction lands exactly on OKF's trust tiers (§5.3), which
29//! consumers derive from `verified`:
30//!
31//! | [`Provenance`] | frontmatter | tier |
32//! | --- | --- | --- |
33//! | `Authored` — ADR and blueprint prose | `verified: [{ by: human:<id> }]` | human-reviewed |
34//! | `Derived` — deterministic tree-sitter extraction | `verified: [{ by: roteiro/<version> }]` | machine-confirmed |
35//! | `Inferred` — heuristic, carries a confidence | `generated:` alone | unverified |
36//!
37//! `Derived` is **machine-confirmed rather than unverified** on purpose: it is
38//! reproduced deterministically from the AST at a known commit, so a consumer can
39//! re-derive it. `Inferred` is a similarity judgement with a confidence score and
40//! gets no `verified` key, because claiming otherwise would launder a guess into
41//! a confirmation — the distinction the whole graph exists to keep.
42//!
43//! §7 makes the `human:` prefix load-bearing: it is the only thing that
44//! separates human-reviewed from machine-confirmed, and producers **MUST** use it
45//! for hand-authored content. Roteiro knows which nodes those are, and resolves
46//! *which person* per document — the author of the commit that last changed that
47//! document's path. Naming one author for the whole repository would record a
48//! review that person never did, on every ADR at once.
49//!
50//! # One deliberate divergence
51//!
52//! §11 says consumers **MUST NOT** reject a bundle for broken cross-links.
53//! Roteiro treats a broken authored link as drift and fails a gate over it. Both
54//! are right — the specification asks consumers to be liberal; Roteiro is a
55//! producer that guarantees more than it must. A Roteiro bundle should not
56//! contain a broken link, and `roteiro check` is the reason.
57
58// Conformance and hygiene checking. Its own module rather than more of
59// `inspect`: `inspect` answers questions a bundle's *contents* raise, and this
60// answers whether the bundle is well-formed — a different question, and the one
61// with rules behind it.
62pub mod conform;
63pub mod inspect;
64pub mod read;
65// The viewer's model (ADR-0022). Rendering only — the HTTP layer is roteiro's
66// `okf_viewer`, behind the `okf-viewer` feature. Kept out of that gate so the
67// part with rules in it is compiled and tested by the default build.
68pub mod view;
69
70use std::collections::BTreeMap;
71use std::fmt::Write as _;
72
73use rto_graph::{Explanation, NodeSummary, Provenance};
74
75/// The specification version this renderer targets, written into the bundle
76/// root's `index.md` as `okf_version` (§10 — the one place frontmatter is
77/// permitted in an index).
78pub const OKF_VERSION: &str = "0.2";
79
80/// The reserved filename for a directory listing (§8).
81pub const INDEX_FILE: &str = "index.md";
82
83/// The reserved filename for a change log (§9).
84pub const LOG_FILE: &str = "log.md";
85
86/// The namespace a cross-repo placeholder node's key carries (ADR-0009).
87///
88/// Spelled once here and checked against the graph's own writer by
89/// `the_placeholder_prefix_is_the_graphs`, so the two cannot drift into
90/// disagreeing about what a placeholder key looks like.
91const EXTREF_PREFIX: &str = "extref:";
92
93/// One rendered file in the bundle: a bundle-relative path and its content.
94#[derive(Debug, Clone, PartialEq, Eq)]
95pub struct BundleFile {
96 /// Path relative to the bundle root, always `/`-separated.
97 pub path: String,
98 /// The file's full text, including any frontmatter block.
99 pub content: String,
100}
101
102/// Who produced or confirmed a concept, in the actor form §7 requires.
103///
104/// The three shapes are not interchangeable: a consumer classifying trust keys
105/// off the `human:` prefix, so using the wrong one silently moves a concept
106/// between tiers.
107///
108/// # Deliberately exhaustive
109///
110/// This is deliberately not `#[non_exhaustive]`, though these crates are
111/// published and a fourth variant would therefore be a breaking change. **The
112/// set is closed by the specification, not by us**: §7 defines exactly these
113/// three forms, and a
114/// fourth appearing means OKF changed. When that happens a caller matching on
115/// this enum *should* stop compiling, because a new actor form is a decision
116/// about trust that must be looked at rather than absorbed by a wildcard arm.
117///
118/// `#[non_exhaustive]` would buy version-compatibility at the price of making
119/// that change silent — which is the opposite of what the trust model needs.
120#[derive(Debug, Clone, PartialEq, Eq)]
121pub enum Actor {
122 /// A person: `human:<id>`. The only form that yields the human-reviewed tier.
123 Human(String),
124 /// A tool, as `<producer>/<version>`.
125 Tool(String, String),
126 /// An automated process: `process:<id>`.
127 Process(String),
128}
129
130impl Actor {
131 /// The wire form, exactly as §7 specifies it.
132 #[must_use]
133 pub fn as_token(&self) -> String {
134 match self {
135 Self::Human(id) => format!("human:{id}"),
136 Self::Tool(producer, version) => format!("{producer}/{version}"),
137 Self::Process(id) => format!("process:{id}"),
138 }
139 }
140}
141
142/// How a concept came to exist, rendered into `generated` / `verified`.
143#[derive(Debug, Clone, PartialEq, Eq)]
144pub struct Origin {
145 /// The actor that produced the concept.
146 pub by: Actor,
147 /// When, as an ISO 8601 instant.
148 pub at: String,
149 /// Whether this origin also *confirms* the concept.
150 ///
151 /// `Authored` and `Derived` do; `Inferred` does not. See the module doc — a
152 /// heuristic that claimed confirmation would launder a guess.
153 pub confirms: bool,
154}
155
156/// A concept document's frontmatter.
157///
158/// Only [`Self::type_`] is required by the specification; every other field is
159/// omitted entirely when absent rather than written empty, because §11 tells
160/// consumers not to reject a document for a missing optional field and an empty
161/// string is a different claim from silence.
162#[derive(Debug, Clone, Default, PartialEq, Eq)]
163pub struct Frontmatter {
164 /// `type` — the one required key. Named with a trailing underscore because
165 /// `type` is a Rust keyword; it is written as `type`.
166 pub type_: String,
167 /// `title` — human-readable display name.
168 pub title: Option<String>,
169 /// `description` — a single-sentence summary.
170 pub description: Option<String>,
171 /// `resource` — canonical URI for the underlying asset.
172 pub resource: Option<String>,
173 /// `tags` — categorisation strings.
174 pub tags: Vec<String>,
175 /// `status` — `draft` | `stable` | `deprecated`.
176 pub status: Option<String>,
177 /// The origin, split into `generated` and `verified` on render.
178 pub origin: Option<Origin>,
179 /// `sources` — where the concept derives from, each with a `resource`.
180 pub sources: Vec<String>,
181}
182
183/// Quote a scalar for YAML, always, and escape everything a double-quoted scalar
184/// cannot hold raw.
185///
186/// Quoting is unconditional rather than clever: a value that looks like a number,
187/// a date, `yes`, `no`, `null` or `~` changes type under a YAML parser when
188/// written bare, and a concept `type` of `no` becoming the boolean `false` is
189/// exactly the failure that makes a bundle non-conformant while looking fine.
190///
191/// # Control characters, because the values are not ours
192///
193/// Every scalar here comes from somewhere a person can put anything: a git author
194/// name, a document heading, a node key derived from a path. A raw newline inside
195/// a quoted scalar does not merely look wrong — YAML folds it, so the value
196/// changes; and a line of the injected text starting at column 0 with `key:` on
197/// it ends the scalar and becomes a *sibling key*. That is frontmatter injection,
198/// and in a document whose frontmatter decides a trust tier it is the one that
199/// matters: a `verified:` block forged from inside a title.
200///
201/// So `\`, `"`, and every C0 control (plus DEL) are escaped — the common three by
202/// name, the rest as `\uXXXX`, which YAML 1.2 §7.3.1 defines for exactly this.
203fn yaml_scalar(s: &str) -> String {
204 let mut out = String::with_capacity(s.len() + 2);
205 out.push('"');
206 for ch in s.chars() {
207 match ch {
208 '\\' => out.push_str("\\\\"),
209 '"' => out.push_str("\\\""),
210 '\n' => out.push_str("\\n"),
211 '\r' => out.push_str("\\r"),
212 '\t' => out.push_str("\\t"),
213 // C0 and DEL. `\uXXXX` is the general escape, used for everything
214 // without a shorter name so nothing reaches the file raw.
215 c if c.is_control() => {
216 let _ = write!(out, "\\u{:04x}", u32::from(c));
217 }
218 c => out.push(c),
219 }
220 }
221 out.push('"');
222 out
223}
224
225impl Frontmatter {
226 /// Render the frontmatter block, `---` fences included.
227 #[must_use]
228 pub fn render(&self) -> String {
229 let mut out = String::from("---\n");
230 let _ = writeln!(out, "type: {}", yaml_scalar(&self.type_));
231 for (key, value) in [
232 ("title", self.title.as_deref()),
233 ("description", self.description.as_deref()),
234 ("resource", self.resource.as_deref()),
235 ("status", self.status.as_deref()),
236 ] {
237 if let Some(v) = value {
238 let _ = writeln!(out, "{key}: {}", yaml_scalar(v));
239 }
240 }
241 if !self.tags.is_empty() {
242 out.push_str("tags:\n");
243 for t in &self.tags {
244 let _ = writeln!(out, " - {}", yaml_scalar(t));
245 }
246 }
247 if let Some(origin) = &self.origin {
248 // `generated` always: it records production, which happened whether or
249 // not anyone confirmed the result.
250 let _ = writeln!(
251 out,
252 "generated:\n by: {}\n at: {}",
253 yaml_scalar(&origin.by.as_token()),
254 yaml_scalar(&origin.at)
255 );
256 // `verified` only when the origin confirms. Its **absence** is the
257 // unverified tier, so writing an empty list here would claim a
258 // confirmation nobody made.
259 if origin.confirms {
260 let _ = writeln!(
261 out,
262 "verified:\n - by: {}\n at: {}",
263 yaml_scalar(&origin.by.as_token()),
264 yaml_scalar(&origin.at)
265 );
266 }
267 }
268 if !self.sources.is_empty() {
269 out.push_str("sources:\n");
270 for s in &self.sources {
271 let _ = writeln!(out, " - resource: {}", yaml_scalar(s));
272 }
273 }
274 out.push_str("---\n");
275 out
276 }
277}
278
279/// The bundle directory a node kind belongs in.
280///
281/// Grouping by kind is what gives the bundle its hierarchy, and with it a
282/// meaningful per-directory `index.md`. Code symbols share one directory rather
283/// than splitting `fn` from `struct`, because a reader looking for a symbol does
284/// not know which it is.
285#[must_use]
286pub fn section_for(kind: &str) -> &'static str {
287 match kind {
288 "adr" | "adr_section" => "decisions",
289 "blueprint" => "blueprints",
290 "doc" => "docs",
291 "file" => "files",
292 "marker" => "debt",
293 _ => "symbols",
294 }
295}
296
297/// The longest slug a filename may carry, before any disambiguating suffix.
298///
299/// `NAME_MAX` is 255 bytes on Linux and macOS. Real keys reach it: rendering this
300/// repository failed with `File name too long (os error 63)` on a symbol key,
301/// **after** writing part of the bundle — a unit test over short fixtures could
302/// not have found it, and did not. The headroom below covers the `-` plus an
303/// eight-character digest plus `.md`.
304const MAX_SLUG: usize = 200;
305
306/// Slug a node key into a filename that is safe on every filesystem and stable
307/// across renders.
308///
309/// Unlike the vault this replaces, the result does **not** need a hash appended:
310/// concepts live in per-kind directories, so the cross-kind collisions the vault
311/// hashed around cannot occur here. Two keys that still slug identically within
312/// one directory are disambiguated by the caller, which can see the whole set.
313#[must_use]
314pub fn slug(key: &str) -> String {
315 let mut out = String::with_capacity(key.len());
316 let mut last_dash = false;
317 for ch in key.chars() {
318 if ch.is_ascii_alphanumeric() {
319 out.push(ch.to_ascii_lowercase());
320 last_dash = false;
321 } else if !last_dash && !out.is_empty() {
322 out.push('-');
323 last_dash = true;
324 }
325 }
326 let trimmed = out.trim_end_matches('-').to_owned();
327 if trimmed.is_empty() {
328 return "concept".to_owned();
329 }
330 if trimmed.len() <= MAX_SLUG {
331 return trimmed;
332 }
333 // Truncation can *create* a collision that the full keys did not have — two
334 // long keys sharing a prefix become one name — so a shortened slug always
335 // carries a digest of the whole key. Cutting on a char boundary is free here
336 // because every retained character is ASCII.
337 let keep = MAX_SLUG - 9;
338 format!("{}-{}", &trimmed[..keep], short_digest(key))
339}
340
341/// The bundle-relative path a node takes in a single-project bundle whose slug
342/// did not collide, always beginning with `/` so it can be used as a link target
343/// verbatim (§6 — absolute, bundle-relative).
344///
345/// **Provisional, not authoritative.** [`assemble`] overwrites it, because the
346/// real path also carries the workspace member's directory and a disambiguating
347/// digest when two keys slug alike — neither of which is visible from one node.
348/// Resolving a *link* with this function is the bug it exists to make obvious:
349/// use the placement [`assemble`] passes to [`render_concept`].
350#[must_use]
351pub fn concept_path(node: &NodeSummary) -> String {
352 format!("/{}/{}.md", section_for(&node.kind), slug(&node.key))
353}
354
355/// Map a graph provenance onto an OKF origin.
356///
357/// See the module documentation for why `Derived` confirms and `Inferred` does
358/// not. `tool` is the producing tool's actor, used for everything a machine
359/// produced; `human` is the authored content's confirmer, which the caller
360/// resolves from the commit that introduced it.
361///
362/// # An imported concept re-emits the peer's own origin and does not come here
363///
364/// A fact imported from another repository's bundle (`external-*`, issue #706)
365/// keeps the `generated`/`verified` block **that bundle carried**, recovered by
366/// [`read::peer_origin`] and preferred by the caller. That is what stops the
367/// round trip from re-tiering it: the peer's `verified: [{ by: human:alice }]`
368/// goes back out naming Alice, so the next consumer learns who confirmed it
369/// instead of being told this graph did.
370///
371/// The external arms below are the **fallback** for a concept whose bundle
372/// recorded no origin at all. They confirm only when the caller supplies an
373/// actor, on exactly `Authored`'s existing rule — an unknown confirmer yields no
374/// confirmation rather than the wrong one. Naming `tool` as the confirmer would
375/// be this graph vouching for a peer's fact on the strength of having read it.
376/// The cost is honest and one-directional: an unattributed external concept
377/// renders *unverified*, understating a claim rather than inventing one.
378#[must_use]
379pub fn origin_for(prov: Provenance, at: &str, tool: &Actor, human: Option<&Actor>) -> Origin {
380 match prov {
381 // Authored prose is confirmed by the person who wrote it. Falling back to
382 // the tool when the author is unknown would move the concept from
383 // human-reviewed to machine-confirmed, so an unknown author yields no
384 // confirmation at all rather than the wrong one.
385 //
386 // Both **external** confirming tiers join this arm, including
387 // `ExternalDerived` — which is the one place the imported tiers do not
388 // simply follow their local namesake, and the difference is the reason
389 // the tier is carried rather than the variant flattened. `Derived`
390 // confirms below because *a consumer can re-derive it from the same
391 // commit*; a consumer of **our** bundle cannot re-derive a peer's fact,
392 // having neither their tree nor their extractor. What survives an import
393 // is the peer's claim, and a claim needs a claimant's name on it to
394 // confirm anything — which is exactly `Authored`'s rule, so it is
395 // `Authored`'s arm.
396 Provenance::Authored | Provenance::ExternalDerived | Provenance::ExternalAuthored => {
397 match human {
398 Some(actor) => Origin {
399 by: actor.clone(),
400 at: at.to_owned(),
401 confirms: true,
402 },
403 None => Origin {
404 by: tool.clone(),
405 at: at.to_owned(),
406 confirms: false,
407 },
408 }
409 }
410 // Deterministic extraction: a consumer can re-derive it from the same
411 // commit and get the same answer, which is what machine-confirmed means.
412 Provenance::Derived => Origin {
413 by: tool.clone(),
414 at: at.to_owned(),
415 confirms: true,
416 },
417 // A similarity judgement carrying a confidence. Unverified, and honestly
418 // so — and a peer's guess, or anything taken at *acknowledge* rather than
419 // *trust*, is unverified for the same reason.
420 Provenance::Inferred | Provenance::ExternalInferred => Origin {
421 by: tool.clone(),
422 at: at.to_owned(),
423 confirms: false,
424 },
425 }
426}
427
428/// Render one node as an OKF concept document.
429///
430/// `body` is the node's prose when it has any. Relationships become plain
431/// markdown links under a heading, which is how §6 says a relationship is
432/// asserted — the link carries the relationship, and the surrounding prose says
433/// what kind it is.
434#[must_use]
435pub fn render_concept(
436 ex: &Explanation,
437 fm: &Frontmatter,
438 body: Option<&str>,
439 resolve: &dyn Fn(&str) -> Option<String>,
440) -> BundleFile {
441 let mut content = fm.render();
442 content.push('\n');
443 let text = body.map(str::trim).filter(|t| !t.is_empty());
444 // A document that opens with its own `#` heading keeps it. Writing the title
445 // above it would give the concept two H1s saying nearly the same thing, and
446 // the document's own is the better one — it is what its author wrote.
447 let body_leads_with_heading = text.is_some_and(|t| t.starts_with("# "));
448 if !body_leads_with_heading {
449 let _ = writeln!(
450 content,
451 "# {}\n",
452 fm.title.as_deref().unwrap_or(&ex.node.name)
453 );
454 }
455 if let Some(text) = text {
456 content.push_str(text);
457 content.push_str("\n\n");
458 }
459
460 // Group by edge kind so the prose above each list can name the relationship.
461 let mut groups: BTreeMap<&str, Vec<String>> = BTreeMap::new();
462 for (edge, direction) in ex
463 .outgoing
464 .iter()
465 .map(|e| (e, "→"))
466 .chain(ex.incoming.iter().map(|e| (e, "←")))
467 {
468 if let Some(target) = resolve(&edge.node) {
469 let label = edge.node.rsplit(':').next().unwrap_or(&edge.node);
470 let confidence = edge
471 .confidence
472 .map(|c| format!(" (confidence {c:.2})"))
473 .unwrap_or_default();
474 groups
475 .entry(edge.kind.as_str())
476 .or_default()
477 .push(format!("* {direction} [{label}]({target}){confidence}"));
478 }
479 }
480 if !groups.is_empty() {
481 content.push_str("## Relationships\n\n");
482 for (kind, mut links) in groups {
483 links.sort();
484 links.dedup();
485 let _ = writeln!(content, "### {kind}\n");
486 for link in links {
487 let _ = writeln!(content, "{link}");
488 }
489 content.push('\n');
490 }
491 }
492
493 BundleFile {
494 path: concept_path(&ex.node),
495 content,
496 }
497}
498
499/// One entry in a directory listing.
500#[derive(Debug, Clone, PartialEq, Eq)]
501pub struct IndexEntry {
502 /// Display title.
503 pub title: String,
504 /// Link target, bundle-relative.
505 pub target: String,
506 /// Short description, taken from the concept's own frontmatter (§8 SHOULD).
507 pub description: Option<String>,
508}
509
510/// Render a directory `index.md` (§8).
511///
512/// Deliberately **no frontmatter**: §8 permits it only in the bundle root, and a
513/// stray block in a nested index would make the file a malformed concept rather
514/// than a valid listing.
515#[must_use]
516pub fn render_index(heading: &str, entries: &[IndexEntry]) -> String {
517 let mut out = format!("# {heading}\n\n");
518 for e in entries {
519 let desc = e
520 .description
521 .as_deref()
522 .map(|d| format!(" - {d}"))
523 .unwrap_or_default();
524 let _ = writeln!(out, "* [{}]({}){desc}", e.title, e.target);
525 }
526 out
527}
528
529/// Render the bundle-root `index.md`, the one index that carries frontmatter.
530#[must_use]
531pub fn render_root_index(heading: &str, entries: &[IndexEntry]) -> String {
532 let mut out = format!("---\nokf_version: {}\n---\n\n", yaml_scalar(OKF_VERSION));
533 out.push_str(&render_index(heading, entries));
534 out
535}
536
537/// One dated group of log entries.
538#[derive(Debug, Clone, PartialEq, Eq)]
539pub struct LogDay {
540 /// ISO 8601 `YYYY-MM-DD`. §9 requires this exact form for date headings.
541 pub date: String,
542 /// The day's entries, each already prefixed with its kind (`**Update**: …`).
543 pub entries: Vec<String>,
544}
545
546/// Render `log.md` (§9): dated groups, newest first.
547#[must_use]
548pub fn render_log(heading: &str, days: &[LogDay]) -> String {
549 let mut out = format!("# {heading}\n\n");
550 for day in days {
551 let _ = writeln!(out, "## {}\n", day.date);
552 for entry in &day.entries {
553 let _ = writeln!(out, "* {entry}");
554 }
555 out.push('\n');
556 }
557 out
558}
559
560/// A concept ready to be written: its node, its frontmatter, and its prose.
561pub struct Concept<'a> {
562 /// The graph node and its neighbourhood.
563 pub explanation: &'a Explanation,
564 /// The frontmatter to render.
565 pub frontmatter: Frontmatter,
566 /// The node's prose body, when it has one.
567 pub body: Option<String>,
568 /// The workspace member this concept came from, for a bundle spanning several
569 /// repositories (ADR-0009). `None` for a single project.
570 ///
571 /// Nesting by member is what stops two repositories' `file:README.md` landing
572 /// on one path. The vault this replaces solved the same problem by qualifying
573 /// the *key* and hashing the filename, because it had one flat directory to
574 /// work with; a bundle has directories, so the structure carries it.
575 pub member: Option<String>,
576}
577
578/// One directory's concepts, each with the path [`assemble`]'s first pass gave
579/// it — the intermediate the second pass renders from.
580struct Placed<'a> {
581 /// The workspace member these concepts came from, when the bundle spans one.
582 /// Also the scope a link resolves in: the same key in two members is two
583 /// concepts.
584 member: Option<String>,
585 /// The bundle-relative directory: `<member>/<section>`, or `<section>` alone.
586 dir: String,
587 /// Each concept and the bundle-relative path it will be written to.
588 concepts: Vec<(Concept<'a>, String)>,
589}
590
591/// Assemble a whole bundle: every concept, an `index.md` for each section
592/// directory, and the bundle-root `index.md` carrying `okf_version`.
593///
594/// A workspace **member's** directory carries no index of its own: it is a
595/// container for that member's sections, and the root index links straight
596/// through to `<member>/<section>`, so nothing is unreachable without one.
597/// `an_index_lists_a_section_and_a_member_directory_is_a_container` pins that,
598/// because the layout is documented in `docs/OKF_BUNDLE.md` and a bundle that
599/// grew member indexes would make that page wrong without failing anything.
600///
601/// # Collisions are resolved here, and only here
602///
603/// [`slug`] can map two different keys onto one filename. The Obsidian vault this
604/// replaces appended a hash to **every** note to survive that, because it wrote
605/// one flat directory on filesystems that fold case — and it still lost 104 notes
606/// of 8,144 before the hash existed. Nesting by kind removes most of the pressure,
607/// but not all of it, so the remaining collisions are settled where the whole set
608/// is visible rather than by a per-name rule that cannot see its neighbours.
609///
610/// A colliding name gets a short digest of its key appended. The **first** name in
611/// key order keeps the bare slug, so a bundle re-rendered from an unchanged graph
612/// is byte-identical: the disambiguation depends on the set, and the set is sorted.
613///
614/// Comparison is case-**insensitive** on purpose. `Foo` and `foo` are one file on
615/// macOS and Windows, and a bundle that wrote both would silently lose one — which
616/// is exactly how the vault lost notes.
617///
618/// # Links are resolved against the placement, not re-derived from the key
619///
620/// Which is why this happens in two passes. A concept's path depends on the whole
621/// set — the member directory it nests under, and whether its slug collided — so
622/// *any* rule that turns a key into a path on its own is guessing. The first pass
623/// places every concept and records `key -> path`; the second renders, resolving
624/// each relationship through that map. A key the map does not hold is not in the
625/// bundle, and its link is dropped rather than written as a path that does not
626/// exist.
627///
628/// The map is scoped **per member**: `file:README.md` is a different concept in
629/// each repository of a workspace, so a link from one member's concept resolves
630/// inside that member.
631#[must_use]
632pub fn assemble(concepts: Vec<Concept<'_>>, title: &str, log: &[LogDay]) -> Vec<BundleFile> {
633 // Group by section, in key order, so both the output and the disambiguation
634 // are deterministic.
635 let mut by_section: BTreeMap<(Option<String>, &'static str), Vec<Concept<'_>>> =
636 BTreeMap::new();
637 let mut ordered = concepts;
638 ordered.sort_by(|a, b| a.explanation.node.key.cmp(&b.explanation.node.key));
639 for c in ordered {
640 by_section
641 .entry((c.member.clone(), section_for(&c.explanation.node.kind)))
642 .or_default()
643 .push(c);
644 }
645
646 // Pass one: place every concept. Nothing is rendered yet, because a link
647 // written now could only guess at a path this pass is still deciding.
648 let mut placed: Vec<Placed<'_>> = Vec::new();
649 let mut index: BTreeMap<Option<String>, BTreeMap<String, String>> = BTreeMap::new();
650
651 for ((member, section), members) in by_section {
652 // `/<member>/<section>/` in a workspace, `/<section>/` on its own.
653 let dir = member
654 .as_deref()
655 .map_or_else(|| section.to_owned(), |m| format!("{}/{section}", slug(m)));
656 let mut taken: BTreeMap<String, usize> = BTreeMap::new();
657 let mut concepts: Vec<(Concept<'_>, String)> = Vec::with_capacity(members.len());
658 let member_index = index.entry(member.clone()).or_default();
659
660 for c in members {
661 // Case folding is already handled: `slug` lowercases, so no two slugs
662 // can differ by case alone and this comparison needs no folding of its
663 // own. An earlier version folded again here and read as the guard
664 // against case-insensitive filesystems — it was a no-op, and removing
665 // it changed no test, which is how the redundancy was found.
666 let base = slug(&c.explanation.node.key);
667 let name = match taken.get(&base) {
668 None => base.clone(),
669 Some(_) => format!("{base}-{}", short_digest(&c.explanation.node.key)),
670 };
671 *taken.entry(base).or_insert(0) += 1;
672
673 let path = format!("/{dir}/{name}.md");
674 member_index.insert(c.explanation.node.key.clone(), path.clone());
675 concepts.push((c, path));
676 }
677 placed.push(Placed {
678 member,
679 dir,
680 concepts,
681 });
682 }
683
684 let mut files = Vec::new();
685 let mut sections: Vec<IndexEntry> = Vec::new();
686
687 // Pass two: render, resolving every link through the placement above.
688 for section in placed {
689 let member_index = index.get(§ion.member);
690 let dir = §ion.dir;
691 let mut entries: Vec<IndexEntry> = Vec::with_capacity(section.concepts.len());
692
693 for (c, path) in §ion.concepts {
694 let title = c
695 .frontmatter
696 .title
697 .clone()
698 .unwrap_or_else(|| c.explanation.node.name.clone());
699 entries.push(IndexEntry {
700 title,
701 target: path.clone(),
702 description: c.frontmatter.description.clone(),
703 });
704 let mut file =
705 render_concept(c.explanation, &c.frontmatter, c.body.as_deref(), &|key| {
706 // A cross-repo reference names a concept that is *in this
707 // bundle*, one member over. Following the placeholder's own
708 // key would land the reader on the stub standing in for it
709 // (see `cross_member_target`), which is a worse answer than
710 // the one the bundle already contains.
711 cross_member_target(&index, key)
712 .or_else(|| member_index.and_then(|m| m.get(key)).cloned())
713 });
714 file.path.clone_from(path);
715 files.push(file);
716 }
717
718 files.push(BundleFile {
719 path: format!("/{dir}/{INDEX_FILE}"),
720 content: render_index(dir, &entries),
721 });
722 sections.push(IndexEntry {
723 title: dir.clone(),
724 target: format!("/{dir}/{INDEX_FILE}"),
725 description: Some(format!("{} concept(s)", section.concepts.len())),
726 });
727 }
728
729 if !log.is_empty() {
730 files.push(BundleFile {
731 path: format!("/{LOG_FILE}"),
732 content: render_log("Update Log", log),
733 });
734 }
735 files.push(BundleFile {
736 path: format!("/{INDEX_FILE}"),
737 content: render_root_index(title, §ions),
738 });
739 files.sort_by(|a, b| a.path.cmp(&b.path));
740 files
741}
742
743/// Where a **cross-repo reference** actually points, when the member it names is
744/// in this same bundle.
745///
746/// A workspace graph records a reference into another repository as an
747/// `extref:<project>::<key>` placeholder node in the *referring* member
748/// (ADR-0009): a stub standing in for a concept that member cannot see. But a
749/// workspace **bundle** contains that other member, so the concept the reference
750/// is about is right there — and linking to the stub instead would send a reader
751/// to a document whose entire content is that it is not the document they wanted.
752///
753/// Both spellings reach the same place: the placeholder node's key
754/// (`extref:<project>::<key>`, what an edge actually points at) and a bare
755/// project-qualified key. What counts as *qualified* is
756/// [`rto_graph::parse_qualified`]'s decision, not a second `::` rule invented
757/// here — the keys were produced by that rule, so the bundle must not disagree
758/// with it about where the project name ends.
759///
760/// `None` unless every part holds: the key parses as qualified, it names a member
761/// of **this** bundle, and that member really has the concept. The caller falls
762/// back to the member-scoped lookup then — which yields the placeholder, a file
763/// that exists — because a stub in the bundle beats a link to nothing.
764fn cross_member_target(
765 index: &BTreeMap<Option<String>, BTreeMap<String, String>>,
766 key: &str,
767) -> Option<String> {
768 let qualified = key.strip_prefix(EXTREF_PREFIX).unwrap_or(key);
769 let (project, bare) = rto_graph::parse_qualified(qualified)?;
770 // The membership test is what makes reading a bare key this way safe: a
771 // symbol key containing `::` splits too, but its left half is never a
772 // workspace member's name.
773 index.get(&Some(project.to_owned()))?.get(bare).cloned()
774}
775
776/// A short, stable digest of a key, for disambiguating a collided slug.
777///
778/// FNV-1a rather than a cryptographic hash: this is a filename disambiguator, not
779/// a security boundary, and it must stay identical across renders and platforms.
780///
781/// The **low 32 bits**, masked rather than sliced off the hex rendering. An
782/// earlier version wrote `format!("{h:08x}")[..8]`, which is a string operation
783/// wearing a number's clothes: `{:08x}` pads to 8 but does not truncate, so a
784/// hash above `2^32` renders 9 to 16 digits and the slice then takes a *high*
785/// window whose offset moves with the magnitude. The entropy is 32 bits either
786/// way, so no collision was ever more likely — but which 32 bits you got depended
787/// on how large the hash happened to be, and a filename rule nobody can state in
788/// one sentence is a filename rule waiting to be got wrong.
789fn short_digest(key: &str) -> String {
790 let mut h: u64 = 0xcbf2_9ce4_8422_2325;
791 for b in key.as_bytes() {
792 h ^= u64::from(*b);
793 h = h.wrapping_mul(0x0000_0100_0000_01b3);
794 }
795 // Masked to 32 bits, so `{:08x}` renders exactly eight digits and no cast is
796 // needed to say so. `MAX_SLUG`'s headroom is written against that eight.
797 format!("{:08x}", h & 0xffff_ffff)
798}
799
800#[cfg(test)]
801mod tests {
802 use super::*;
803
804 fn node(key: &str, kind: &str, name: &str) -> NodeSummary {
805 NodeSummary {
806 key: key.to_owned(),
807 kind: kind.to_owned(),
808 name: name.to_owned(),
809 path: None,
810 lang: None,
811 }
812 }
813
814 fn explanation(key: &str, kind: &str, name: &str) -> Explanation {
815 Explanation {
816 schema: rto_graph::SCHEMA,
817 node: node(key, kind, name),
818 meta: serde_json::Value::Null,
819 outgoing: Vec::new(),
820 incoming: Vec::new(),
821 }
822 }
823
824 fn concept<'a>(ex: &'a Explanation, type_: &str) -> Concept<'a> {
825 Concept {
826 explanation: ex,
827 frontmatter: Frontmatter {
828 type_: type_.to_owned(),
829 ..Frontmatter::default()
830 },
831 body: None,
832 member: None,
833 }
834 }
835
836 fn edge(to: &str) -> rto_graph::EdgeRef {
837 rto_graph::EdgeRef {
838 kind: "references".to_owned(),
839 provenance: "authored",
840 confidence: None,
841 node: to.to_owned(),
842 }
843 }
844
845 /// Every `](/…)` link in an emitted bundle, as `(containing file, target)`.
846 fn internal_links(files: &[BundleFile]) -> Vec<(String, String)> {
847 let mut out = Vec::new();
848 for f in files {
849 let mut rest = f.content.as_str();
850 while let Some(open) = rest.find("](/") {
851 rest = &rest[open + 2..];
852 let Some(close) = rest.find(')') else { break };
853 out.push((f.path.clone(), rest[..close].to_owned()));
854 rest = &rest[close..];
855 }
856 }
857 out
858 }
859
860 /// **Every internal link points at a file the bundle actually contains.**
861 ///
862 /// The conformance test above cannot make this assertion, and would not have
863 /// caught its failure: §11 tells consumers they **MUST NOT** reject a bundle
864 /// for a broken cross-link, so a bundle full of them is still conformant. It
865 /// is still wrong, and this repository promises better (ADR-0021).
866 ///
867 /// Three ways a link target can differ from a key's own slug, all present in
868 /// the fixture because a resolver that re-derives the path from the key gets
869 /// each of them wrong:
870 ///
871 /// 1. a **workspace member** prefixes the directory;
872 /// 2. a **collided slug** takes a digest suffix;
873 /// 3. a node whose **kind and key disagree** about the section —
874 /// `blueprint_section` keys begin `blueprint:` but the concept files under
875 /// `symbols`, which is how 43 links broke in a real render of this
876 /// repository.
877 #[test]
878 fn every_emitted_link_resolves_to_a_file_that_exists() {
879 // (3) key says `blueprint:`, kind says `blueprint_section` → `symbols`.
880 let section = {
881 let mut ex = explanation(
882 "blueprint:docs/blueprint/roteiro.md#1-crate-placement",
883 "blueprint_section",
884 "1 · Crate placement",
885 );
886 ex.outgoing = vec![edge("blueprint:docs/blueprint/roteiro.md")];
887 ex
888 };
889 let plan = {
890 let mut ex = explanation(
891 "blueprint:docs/blueprint/roteiro.md",
892 "blueprint",
893 "roteiro.md",
894 );
895 // (2) both collision partners, and the section above.
896 ex.outgoing = vec![
897 edge("blueprint:docs/blueprint/roteiro.md#1-crate-placement"),
898 edge("sym:rust:a/b.rs#Thing"),
899 edge("sym:rust:a-b.rs#thing"),
900 ];
901 ex
902 };
903 let thing_a = explanation("sym:rust:a/b.rs#Thing", "fn", "Thing");
904 let thing_b = explanation("sym:rust:a-b.rs#thing", "fn", "thing");
905 assert_eq!(
906 slug(&thing_a.node.key),
907 slug(&thing_b.node.key),
908 "the fixture must actually collide, or the digest suffix is never exercised"
909 );
910
911 // (1) everything nests under one workspace member.
912 let concepts: Vec<Concept<'_>> = [
913 (§ion, "blueprint_section"),
914 (&plan, "blueprint"),
915 (&thing_a, "fn"),
916 (&thing_b, "fn"),
917 ]
918 .into_iter()
919 .map(|(ex, type_)| {
920 let mut c = concept(ex, type_);
921 c.member = Some("Alpha".to_owned());
922 c
923 })
924 .collect();
925
926 let files = assemble(concepts, "Workspace", &[]);
927 let emitted: std::collections::BTreeSet<&str> =
928 files.iter().map(|f| f.path.as_str()).collect();
929
930 // The fixture is load-bearing only if the placement really did all three
931 // things. Asserted before the links, so a fixture that stopped exercising
932 // one of them fails here rather than passing vacuously below.
933 assert!(
934 emitted
935 .iter()
936 .all(|p| *p == "/index.md" || p.starts_with("/alpha/")),
937 "every concept must nest under its member: {emitted:?}"
938 );
939 assert!(
940 emitted.contains("/alpha/symbols/sym-rust-a-b-rs-thing.md"),
941 "the first collision partner keeps the bare slug: {emitted:?}"
942 );
943 assert!(
944 emitted
945 .iter()
946 .any(|p| p.starts_with("/alpha/symbols/sym-rust-a-b-rs-thing-")),
947 "the second takes a digest suffix: {emitted:?}"
948 );
949 assert!(
950 emitted.contains(
951 "/alpha/symbols/blueprint-docs-blueprint-roteiro-md-1-crate-placement.md"
952 ),
953 "a `blueprint_section` files under `symbols`, not under its key's \
954 `blueprints`: {emitted:?}"
955 );
956
957 let links = internal_links(&files);
958 // A resolver that drops what it cannot place satisfies the loop below by
959 // emitting nothing, so count first: 4 relationship links (one per edge),
960 // 4 concept entries across the two directory indexes, and 2 directory
961 // entries in the root index.
962 assert_eq!(links.len(), 4 + 4 + 2, "{links:?}");
963
964 for (from, target) in &links {
965 assert!(
966 emitted.contains(target.as_str()),
967 "{from} links to {target}, which the bundle does not contain: {emitted:?}"
968 );
969 }
970
971 // Existence is not enough, and this is the half that is easy to miss: two
972 // concepts whose slugs collided are *different files*, so a resolver that
973 // re-derives the bare slug sends both links to whichever one kept it. That
974 // target exists, so the loop above passes while the link points at the
975 // wrong concept — silently wrong rather than broken. `plan` has three
976 // distinct edge targets and must therefore emit three distinct paths.
977 let plan_path = "/alpha/blueprints/blueprint-docs-blueprint-roteiro-md.md";
978 let from_plan: std::collections::BTreeSet<&str> = links
979 .iter()
980 .filter(|(from, _)| from == plan_path)
981 .map(|(_, target)| target.as_str())
982 .collect();
983 assert_eq!(
984 from_plan.len(),
985 plan.outgoing.len(),
986 "{plan_path} has {} edges to distinct concepts but links to {} file(s): {from_plan:?}",
987 plan.outgoing.len(),
988 from_plan.len()
989 );
990 }
991
992 /// Every file the bundle emits satisfies §11's conformance criteria.
993 ///
994 /// Asserted over the *emitted set* rather than over the renderer, because the
995 /// specification is a statement about a bundle and a per-function test cannot
996 /// make it.
997 #[test]
998 fn every_emitted_bundle_is_conformant() {
999 let a = explanation("adr:0001#decision", "adr", "ADR-0001");
1000 let b = explanation("sym:rust:src/main.rs#greet", "fn", "greet");
1001 let files = assemble(
1002 vec![concept(&a, "adr"), concept(&b, "fn")],
1003 "Roteiro",
1004 &[LogDay {
1005 date: "2026-08-28".into(),
1006 entries: vec!["**Update**: rebuilt.".into()],
1007 }],
1008 );
1009
1010 for f in &files {
1011 let reserved = f.path.ends_with(INDEX_FILE) || f.path.ends_with(LOG_FILE);
1012 if reserved {
1013 continue;
1014 }
1015 // §11.1 — a parseable frontmatter block, and §11.2 a non-empty `type`.
1016 assert!(
1017 f.content.starts_with("---\n"),
1018 "{} opens with no frontmatter block",
1019 f.path
1020 );
1021 let end = f.content[4..]
1022 .find("\n---\n")
1023 .expect("frontmatter must terminate");
1024 let block = &f.content[4..4 + end];
1025 assert!(
1026 block
1027 .lines()
1028 .any(|l| l.starts_with("type: ") && l.len() > 8),
1029 "{} carries no non-empty `type`: {block}",
1030 f.path
1031 );
1032 }
1033
1034 // §8 — a nested index carries no frontmatter; only the root may.
1035 let nested = files
1036 .iter()
1037 .find(|f| f.path == "/decisions/index.md")
1038 .expect("a per-directory index");
1039 assert!(!nested.content.starts_with("---"), "{}", nested.content);
1040 let root = files
1041 .iter()
1042 .find(|f| f.path == "/index.md")
1043 .expect("a root index");
1044 assert!(
1045 root.content.contains("okf_version: \"0.2\""),
1046 "{}",
1047 root.content
1048 );
1049 }
1050
1051 /// A document that brings its own heading is not given a second one.
1052 #[test]
1053 fn a_body_with_its_own_heading_is_not_double_titled() {
1054 let ex = explanation("adr:0010", "adr", "ADR-0010");
1055 let fm = Frontmatter {
1056 type_: "adr".into(),
1057 title: Some("Explorer web app".into()),
1058 ..Frontmatter::default()
1059 };
1060 let with = render_concept(
1061 &ex,
1062 &fm,
1063 Some("# ADR-0010: Explorer web app\n\nBody."),
1064 &|_| None,
1065 );
1066 let h1s = |c: &str| c.lines().filter(|l| l.starts_with("# ")).count();
1067 assert_eq!(h1s(&with.content), 1, "exactly one H1: {}", with.content);
1068 assert!(with.content.contains("# ADR-0010: Explorer web app"));
1069 assert!(
1070 !with.content.contains("# Explorer web app\n\n# ADR-0010"),
1071 "the frontmatter title must not be stacked above the document's own"
1072 );
1073
1074 // A body with no heading still gets one, or the concept has no title at all.
1075 let without = render_concept(&ex, &fm, Some("Just prose."), &|_| None);
1076 assert!(
1077 without.content.contains("# Explorer web app"),
1078 "a headingless body still gets the title: {}",
1079 without.content
1080 );
1081 assert_eq!(h1s(&without.content), 1);
1082 }
1083
1084 /// Two members' identically-named concepts do not collide.
1085 ///
1086 /// Every repository has a `README.md`, so `file:README.md` is the same key in
1087 /// each — the case the vault this replaces had to qualify keys and hash
1088 /// filenames to survive, because it wrote one flat directory. Nesting by
1089 /// member carries it structurally instead, and the assertion is again the one
1090 /// whose failure was invisible: **both concepts are written**.
1091 #[test]
1092 fn two_members_sharing_a_key_both_survive() {
1093 let a = explanation("file:README.md", "file", "README.md");
1094 let b = explanation("file:README.md", "file", "README.md");
1095 let mut ca = concept(&a, "file");
1096 ca.member = Some("app".to_owned());
1097 let mut cb = concept(&b, "file");
1098 cb.member = Some("lib".to_owned());
1099
1100 let files = assemble(vec![ca, cb], "Workspace", &[]);
1101 let concepts: Vec<&BundleFile> = files
1102 .iter()
1103 .filter(|f| !f.path.ends_with(INDEX_FILE) && !f.path.ends_with(LOG_FILE))
1104 .collect();
1105 assert_eq!(concepts.len(), 2, "both members' README must be written");
1106 assert!(
1107 concepts.iter().any(|f| f.path.starts_with("/app/")),
1108 "one under its member: {:?}",
1109 concepts.iter().map(|f| &f.path).collect::<Vec<_>>()
1110 );
1111 assert!(concepts.iter().any(|f| f.path.starts_with("/lib/")));
1112 }
1113
1114 /// The prefix this module strips is the one the graph writes.
1115 ///
1116 /// Two crates spelling a key namespace independently is how a resolver stops
1117 /// recognising the keys it is given, silently — the link would simply stop
1118 /// crossing, and every target still exists, so nothing else would notice.
1119 #[test]
1120 fn the_placeholder_prefix_is_the_graphs() {
1121 assert_eq!(rto_graph::external_ref_key(""), EXTREF_PREFIX);
1122 }
1123
1124 /// **A cross-repo reference links to the other member's concept, not to the
1125 /// stub standing in for it.**
1126 ///
1127 /// A workspace graph records a reference into another repository as an
1128 /// `extref:<project>::<key>` placeholder in the *referring* member, because
1129 /// that member cannot see the target. A workspace **bundle** can: the other
1130 /// member is in it. Resolving the placeholder's own key — which is what a
1131 /// member-scoped lookup does — produces a link that works and teaches nothing,
1132 /// landing the reader on a document whose whole content is that it is not the
1133 /// document they wanted. An existence check cannot see that, which is why the
1134 /// assertion is the *destination* rather than that a link resolved.
1135 #[test]
1136 fn a_cross_repo_reference_reaches_the_other_members_concept() {
1137 // `app` has the real concept.
1138 let real = explanation("file:README.md", "file", "README.md");
1139 // `deploy` holds the placeholder, and a document that references it.
1140 let stub = explanation(
1141 "extref:app::file:README.md",
1142 "external_ref",
1143 "app::file:README.md",
1144 );
1145 let referrer = {
1146 let mut ex = explanation("doc:deploy.md", "doc", "deploy.md");
1147 ex.outgoing = vec![edge("extref:app::file:README.md")];
1148 ex
1149 };
1150
1151 let member = |ex, type_, name: &str| {
1152 let mut c = concept(ex, type_);
1153 c.member = Some(name.to_owned());
1154 c
1155 };
1156 let files = assemble(
1157 vec![
1158 member(&real, "file", "app"),
1159 member(&stub, "external_ref", "deploy"),
1160 member(&referrer, "doc", "deploy"),
1161 ],
1162 "Workspace",
1163 &[],
1164 );
1165
1166 let emitted: std::collections::BTreeSet<&str> =
1167 files.iter().map(|f| f.path.as_str()).collect();
1168 let target = "/app/files/file-readme-md.md";
1169 assert!(
1170 emitted.contains(target),
1171 "the fixture must place the real concept: {emitted:?}"
1172 );
1173 // The stub is still written — it is a concept of `deploy`'s graph — and
1174 // links must simply not prefer it.
1175 let stub_path = "/deploy/symbols/extref-app-file-readme-md.md";
1176 assert!(
1177 emitted.contains(stub_path),
1178 "the placeholder must still be a concept: {emitted:?}"
1179 );
1180
1181 let links = internal_links(&files);
1182 let targets: Vec<&str> = links
1183 .iter()
1184 .filter(|(from, _)| from == "/deploy/docs/doc-deploy-md.md")
1185 .map(|(_, t)| t.as_str())
1186 .collect();
1187 assert_eq!(
1188 targets,
1189 vec![target],
1190 "the reference must reach `app`'s concept rather than `deploy`'s stub"
1191 );
1192 }
1193
1194 /// **An `index.md` lists a section; a workspace member's directory is a
1195 /// container and has none.**
1196 ///
1197 /// §8 makes an index *optional* in any directory, so a missing one is not a
1198 /// conformance failure and no conformance check will ever mention it. What it
1199 /// is instead is a documented layout — `docs/OKF_BUNDLE.md`, ADR-0021 and the
1200 /// site each tell a reader which directories carry one — and prose the
1201 /// renderer can contradict without failing anything is how all three came to
1202 /// over-claim "each directory carries an `index.md`". Pinning the exact set
1203 /// means a bundle that grows member indexes has to move those pages with it.
1204 ///
1205 /// The member directory is not a dead end without one: the root index links
1206 /// straight through to `<member>/<section>`, which the second half asserts,
1207 /// because "no index here" is only defensible while nothing needs it.
1208 #[test]
1209 fn an_index_lists_a_section_and_a_member_directory_is_a_container() {
1210 let readme = explanation("file:README.md", "file", "README.md");
1211 let thing = explanation("sym:rust:a.rs#thing", "fn", "thing");
1212
1213 let member = |ex, type_, name: &str| {
1214 let mut c = concept(ex, type_);
1215 c.member = Some(name.to_owned());
1216 c
1217 };
1218 let files = assemble(
1219 vec![
1220 member(&readme, "file", "app"),
1221 member(&thing, "fn", "deploy"),
1222 ],
1223 "Workspace",
1224 &[],
1225 );
1226
1227 let emitted: std::collections::BTreeSet<&str> =
1228 files.iter().map(|f| f.path.as_str()).collect();
1229 // The fixture is load-bearing only if it really made two members whose
1230 // sections differ, so that is asserted before the set below — which an
1231 // empty bundle would otherwise satisfy by containing only a root index.
1232 assert!(
1233 emitted.contains("/app/files/file-readme-md.md")
1234 && emitted.contains("/deploy/symbols/sym-rust-a-rs-thing.md"),
1235 "the fixture must place a concept in each member: {emitted:?}"
1236 );
1237
1238 // `/{INDEX_FILE}` rather than the bare name: a concept whose slug ends
1239 // `-index` would otherwise be counted as a directory listing.
1240 let index_suffix = format!("/{INDEX_FILE}");
1241 let indexes: Vec<&str> = files
1242 .iter()
1243 .map(|f| f.path.as_str())
1244 .filter(|p| p.ends_with(&index_suffix))
1245 .collect();
1246 assert_eq!(
1247 indexes,
1248 vec![
1249 "/app/files/index.md",
1250 "/deploy/symbols/index.md",
1251 "/index.md"
1252 ],
1253 "the bundle root and every section directory carry an index, and a \
1254 member directory carries none"
1255 );
1256
1257 let from_root: Vec<String> = internal_links(&files)
1258 .into_iter()
1259 .filter(|(from, _)| from == "/index.md")
1260 .map(|(_, target)| target)
1261 .collect();
1262 assert_eq!(
1263 from_root,
1264 vec![
1265 "/app/files/index.md".to_owned(),
1266 "/deploy/symbols/index.md".to_owned()
1267 ],
1268 "the root index must reach each section directly, since the member \
1269 directory between them carries no index of its own"
1270 );
1271 }
1272
1273 /// A key longer than the filesystem allows is truncated, and truncation does
1274 /// not merge two concepts into one.
1275 ///
1276 /// Found by *running* the renderer over this repository, not by a unit test:
1277 /// it failed with `File name too long (os error 63)` after writing part of
1278 /// the bundle. Short fixtures cannot reach this, which is why the earlier
1279 /// tests were all green while the real render was broken.
1280 #[test]
1281 fn an_overlong_key_is_truncated_without_colliding() {
1282 let long = "sym:rust:".to_owned() + &"a".repeat(400);
1283 // Same 400-character prefix, different tails: truncation alone would
1284 // merge them.
1285 let a = format!("{long}#one");
1286 let b = format!("{long}#two");
1287
1288 assert!(
1289 slug(&a).len() <= MAX_SLUG,
1290 "slug must fit: {}",
1291 slug(&a).len()
1292 );
1293 assert!(slug(&b).len() <= MAX_SLUG);
1294 assert_ne!(
1295 slug(&a),
1296 slug(&b),
1297 "two keys sharing a truncated prefix must not slug to one name"
1298 );
1299 // And the cap leaves room for `.md` plus a disambiguating suffix inside
1300 // NAME_MAX (255).
1301 assert!(slug(&a).len() + ".md".len() + 9 <= 255);
1302 }
1303
1304 #[test]
1305 fn colliding_slugs_do_not_lose_a_concept() {
1306 // Different keys, identical slug. (Case is not a separate hazard here:
1307 // `slug` lowercases, so a case-only difference cannot survive into a
1308 // filename at all.)
1309 let a = explanation("sym:rust:a/b.rs#Thing", "fn", "Thing");
1310 let b = explanation("sym:rust:a-b.rs#thing", "fn", "thing");
1311 assert_eq!(
1312 slug(&a.node.key).to_ascii_lowercase(),
1313 slug(&b.node.key).to_ascii_lowercase(),
1314 "fixture must actually collide, or this test proves nothing"
1315 );
1316
1317 let files = assemble(vec![concept(&a, "fn"), concept(&b, "fn")], "T", &[]);
1318 let concepts: Vec<&BundleFile> = files
1319 .iter()
1320 .filter(|f| !f.path.ends_with(INDEX_FILE) && !f.path.ends_with(LOG_FILE))
1321 .collect();
1322 assert_eq!(concepts.len(), 2, "both concepts must be written");
1323
1324 let paths: std::collections::BTreeSet<String> = concepts
1325 .iter()
1326 .map(|f| f.path.to_ascii_lowercase())
1327 .collect();
1328 assert_eq!(
1329 paths.len(),
1330 2,
1331 "and to distinct files even when case is folded: {paths:?}"
1332 );
1333 }
1334
1335 /// The same graph renders to the same bytes, whatever order it arrives in.
1336 ///
1337 /// Both fixtures are in the **same section** on purpose. An earlier version
1338 /// used an `adr` and a `fn`, which land in different directories — so each
1339 /// section held one member, ordering within a section was never exercised,
1340 /// and deleting the sort changed nothing. The test passed and guarded nothing.
1341 #[test]
1342 fn assembly_is_deterministic() {
1343 let a = explanation("sym:rust:a.rs#a", "fn", "a");
1344 let b = explanation("sym:rust:z.rs#z", "fn", "z");
1345 assert_eq!(
1346 section_for(&a.node.kind),
1347 section_for(&b.node.kind),
1348 "the fixtures must share a section, or ordering is not under test"
1349 );
1350 let once = assemble(vec![concept(&a, "fn"), concept(&b, "fn")], "T", &[]);
1351 let twice = assemble(vec![concept(&b, "fn"), concept(&a, "fn")], "T", &[]);
1352 assert_eq!(once, twice, "input order must not change the bundle");
1353 }
1354
1355 fn tool() -> Actor {
1356 Actor::Tool("roteiro".into(), "4.0.0".into())
1357 }
1358
1359 #[test]
1360 fn the_only_required_field_is_type() {
1361 let fm = Frontmatter {
1362 type_: "adr".into(),
1363 ..Frontmatter::default()
1364 };
1365 let rendered = fm.render();
1366 assert_eq!(rendered, "---\ntype: \"adr\"\n---\n");
1367 }
1368
1369 #[test]
1370 fn actors_use_the_forms_the_spec_requires() {
1371 assert_eq!(Actor::Human("pixie79".into()).as_token(), "human:pixie79");
1372 assert_eq!(tool().as_token(), "roteiro/4.0.0");
1373 assert_eq!(
1374 Actor::Process("nightly".into()).as_token(),
1375 "process:nightly"
1376 );
1377 }
1378
1379 /// The trust tiers of §5.3, asserted through the rendered frontmatter rather
1380 /// than through `Origin`, because the tier is what a consumer derives.
1381 #[test]
1382 fn provenance_maps_onto_the_trust_tiers() {
1383 let human = Actor::Human("pixie79".into());
1384 let at = "2026-08-28T10:00:00Z";
1385
1386 let authored = origin_for(Provenance::Authored, at, &tool(), Some(&human));
1387 let fm = Frontmatter {
1388 type_: "adr".into(),
1389 origin: Some(authored),
1390 ..Frontmatter::default()
1391 };
1392 let rendered = fm.render();
1393 assert!(
1394 rendered.contains("verified:") && rendered.contains("human:pixie79"),
1395 "authored prose is human-reviewed: {rendered}"
1396 );
1397
1398 let derived = origin_for(Provenance::Derived, at, &tool(), Some(&human));
1399 let fm = Frontmatter {
1400 type_: "fn".into(),
1401 origin: Some(derived),
1402 ..Frontmatter::default()
1403 };
1404 let rendered = fm.render();
1405 assert!(
1406 rendered.contains("verified:"),
1407 "deterministic extraction is machine-confirmed: {rendered}"
1408 );
1409 assert!(
1410 !rendered.contains("human:"),
1411 "but it is not human-reviewed — the prefix is the only thing that \
1412 separates the tiers: {rendered}"
1413 );
1414
1415 let inferred = origin_for(Provenance::Inferred, at, &tool(), Some(&human));
1416 let fm = Frontmatter {
1417 type_: "fn".into(),
1418 origin: Some(inferred),
1419 ..Frontmatter::default()
1420 };
1421 let rendered = fm.render();
1422 assert!(
1423 rendered.contains("generated:"),
1424 "a heuristic still records that it was produced: {rendered}"
1425 );
1426 assert!(
1427 !rendered.contains("verified:"),
1428 "but claims no confirmation — absence *is* the unverified tier, so an \
1429 empty list here would launder a guess: {rendered}"
1430 );
1431 }
1432
1433 /// An authored node whose author is unknown must not silently become
1434 /// machine-confirmed.
1435 #[test]
1436 fn an_authored_node_with_no_known_human_claims_nothing() {
1437 let o = origin_for(Provenance::Authored, "2026-08-28T10:00:00Z", &tool(), None);
1438 assert!(
1439 !o.confirms,
1440 "falling back to the tool would move the concept between trust tiers"
1441 );
1442 }
1443
1444 #[test]
1445 fn scalars_are_quoted_so_yaml_cannot_retype_them() {
1446 // `no`, `12:30` and `1.0` all change type when written bare.
1447 for raw in ["no", "yes", "null", "~", "12:30", "1.0", "on"] {
1448 let fm = Frontmatter {
1449 type_: raw.into(),
1450 ..Frontmatter::default()
1451 };
1452 assert_eq!(fm.render(), format!("---\ntype: \"{raw}\"\n---\n"));
1453 }
1454 }
1455
1456 /// **A value cannot break out of its own scalar.**
1457 ///
1458 /// Every scalar here comes from somewhere a person can put anything — a git
1459 /// author name, a heading, a key derived from a path. A raw newline does not
1460 /// merely make the YAML ugly: the text after it starts a new line at column
1461 /// 0, so `verified:` written inside a *title* becomes a sibling key of the
1462 /// title, and this bundle's frontmatter is what a consumer derives a trust
1463 /// tier from (§5.3). Forging `verified` is the whole attack.
1464 ///
1465 /// Asserted as *the injected key never begins a line*, not merely as "the
1466 /// output contains `\\n`": a rendering that escaped the newline but left the
1467 /// text somewhere else would satisfy the weaker check.
1468 #[test]
1469 fn a_scalar_cannot_forge_a_sibling_key() {
1470 let forged = "Innocent Title\"\nverified:\n - by: \"human:someone-else";
1471 let fm = Frontmatter {
1472 type_: "adr".into(),
1473 title: Some(forged.to_owned()),
1474 ..Frontmatter::default()
1475 };
1476 let rendered = fm.render();
1477
1478 assert!(
1479 !rendered.lines().any(|l| l.starts_with("verified:")),
1480 "a title must not be able to open a `verified` block: {rendered}"
1481 );
1482 // Exactly three lines of frontmatter — the fences and one `type`, one
1483 // `title`. A forged key would add its own.
1484 assert_eq!(
1485 rendered.lines().count(),
1486 4,
1487 "the block must hold two keys and two fences: {rendered}"
1488 );
1489 assert!(
1490 rendered.contains("\\n"),
1491 "the newline is escaped: {rendered}"
1492 );
1493
1494 // The control characters a quoted scalar cannot hold raw, each replaced
1495 // by an escape rather than written through.
1496 for (raw, escaped) in [
1497 ("a\nb", "\\n"),
1498 ("a\rb", "\\r"),
1499 ("a\tb", "\\t"),
1500 ("a\u{0}b", "\\u0000"),
1501 ("a\u{7}b", "\\u0007"),
1502 ("a\u{1b}b", "\\u001b"),
1503 ("a\u{7f}b", "\\u007f"),
1504 ] {
1505 let out = yaml_scalar(raw);
1506 assert!(out.contains(escaped), "{raw:?} -> {out}");
1507 assert!(
1508 !out.chars().any(char::is_control),
1509 "no control character may survive into the file: {out:?}"
1510 );
1511 }
1512 }
1513
1514 #[test]
1515 fn a_nested_index_carries_no_frontmatter_but_the_root_does() {
1516 let entries = [IndexEntry {
1517 title: "ADR-0001".into(),
1518 target: "/decisions/adr-0001.md".into(),
1519 description: Some("The founding decision.".into()),
1520 }];
1521 let nested = render_index("Decisions", &entries);
1522 assert!(
1523 !nested.starts_with("---"),
1524 "§8 permits frontmatter only in the bundle root: {nested}"
1525 );
1526 assert!(nested.contains("* [ADR-0001](/decisions/adr-0001.md) - The founding decision."));
1527
1528 let root = render_root_index("Bundle", &entries);
1529 assert!(
1530 root.starts_with("---\nokf_version: \"0.2\"\n---\n"),
1531 "{root}"
1532 );
1533 }
1534
1535 #[test]
1536 fn log_days_use_iso_8601_headings() {
1537 let log = render_log(
1538 "Update Log",
1539 &[LogDay {
1540 date: "2026-08-28".into(),
1541 entries: vec!["**Update**: rebuilt from `74fad8f`.".into()],
1542 }],
1543 );
1544 assert!(log.contains("## 2026-08-28\n"), "{log}");
1545 assert!(
1546 log.contains("* **Update**: rebuilt from `74fad8f`."),
1547 "{log}"
1548 );
1549 }
1550
1551 #[test]
1552 fn concepts_are_grouped_into_per_kind_directories() {
1553 assert_eq!(section_for("adr"), "decisions");
1554 assert_eq!(section_for("adr_section"), "decisions");
1555 assert_eq!(section_for("blueprint"), "blueprints");
1556 assert_eq!(section_for("file"), "files");
1557 assert_eq!(section_for("marker"), "debt");
1558 // Every code symbol shares one directory: a reader looking for `greet`
1559 // does not know whether it is a fn, a struct or a trait.
1560 assert_eq!(section_for("fn"), "symbols");
1561 assert_eq!(section_for("struct"), "symbols");
1562 assert_eq!(section_for("trait"), "symbols");
1563 }
1564
1565 #[test]
1566 fn slugs_are_stable_and_filesystem_safe() {
1567 assert_eq!(
1568 slug("sym:rust:src/main.rs#greet"),
1569 "sym-rust-src-main-rs-greet"
1570 );
1571 assert_eq!(slug("adr:0001#decision"), "adr-0001-decision");
1572 // No trailing separator, no empty result, no run of dashes.
1573 assert_eq!(slug("a//b"), "a-b");
1574 assert_eq!(slug("trailing///"), "trailing");
1575 assert_eq!(slug("###"), "concept");
1576 }
1577
1578 /// The digest is **always eight lowercase hex digits**, whatever the key.
1579 ///
1580 /// [`MAX_SLUG`]'s headroom is written against that eight — `slug` reserves
1581 /// `MAX_SLUG - 9` for a truncated name so the dash, the digest and `.md` fit
1582 /// inside `NAME_MAX`. A digest that could be wider would silently spend that
1583 /// reservation and put the failure back where it was found: a render dying on
1584 /// `File name too long` after writing part of the bundle.
1585 ///
1586 /// Nothing about the width is visible at the call sites, which is why it is
1587 /// asserted here rather than inferred from them.
1588 #[test]
1589 fn the_digest_is_always_eight_hex_digits() {
1590 // Long, empty, unicode, and enough varied keys to reach hashes on both
1591 // sides of 2^32 — the boundary the previous rendering was sensitive to.
1592 let mut keys: Vec<String> = vec![
1593 String::new(),
1594 "a".into(),
1595 "sym:rust:src/main.rs#greet".into(),
1596 "ünïcødé::key".into(),
1597 "x".repeat(4096),
1598 ];
1599 keys.extend((0..512).map(|i| format!("sym:rust:crates/a/src/b{i}.rs#Thing{i}")));
1600
1601 for key in &keys {
1602 let digest = short_digest(key);
1603 assert_eq!(digest.len(), 8, "{key:?} -> {digest}");
1604 assert!(
1605 digest
1606 .chars()
1607 .all(|c| c.is_ascii_digit() || ('a'..='f').contains(&c)),
1608 "lowercase hex only: {key:?} -> {digest}"
1609 );
1610 }
1611 // Stable across calls: the disambiguation must not move between renders.
1612 assert_eq!(short_digest("adr:0001"), short_digest("adr:0001"));
1613 }
1614}