Skip to main content

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