Skip to main content

rto_render/
obsidian.rs

1//! The Obsidian-vault renderer: each graph node becomes a markdown note whose
2//! edges are `[[wikilinks]]`, so the provenance-tagged graph is browsable in
3//! Obsidian's graph view. Notes carry frontmatter `tags` (`roteiro/kind/*`,
4//! `roteiro/lang/*`, `roteiro/status/*`) so the graph is colourable/filterable —
5//! edge provenance is shown per-link in the body — surface the node's text as the
6//! knowledge base, show an ADR's status, and (when the repository's web host is
7//! known) a clickable **Source** link to the file.
8//!
9//! That text is the node's captured `meta.content` (a doc comment, PDF or image
10//! text) *except* where the caller supplies a full `body` — which it does for
11//! prose documents, because `meta.content` is an embedding budget and a note
12//! rendered from it is the document capped at 1500 characters and collapsed onto
13//! one line. See [`note_body`].
14//!
15//! A generated `_Home` note is the overview: what was
16//! scanned, counts by kind, provenance breakdown, ADR statuses, intent-debt (with
17//! the files it is densest in), an inventory of secret-**named** config keys and
18//! their redaction state, and the most depended-on symbols by directed call
19//! fan-in.
20//! Built from the same [`Explanation`] the query surface returns, so the vault
21//! and the CLI agree.
22
23use std::fmt::Write as _;
24
25use rto_graph::Explanation;
26
27/// Filename of the generated overview note (sorts first in the file list).
28pub const HOME_NOTE: &str = "_Home.md";
29
30/// A rendered vault note: its filename (with `.md`) and markdown content.
31#[derive(Debug, Clone, PartialEq, Eq)]
32pub struct VaultNote {
33    /// Filename including the `.md` extension.
34    pub filename: String,
35    /// Markdown content.
36    pub content: String,
37}
38
39/// Map a node key to a filesystem- and wikilink-safe note stem that is **unique
40/// per key even after case folding**.
41///
42/// A name is a lowercased, readable *hint* slugged from the key, followed by an
43/// unconditional 64-bit FNV-1a hash of the whole, exact key written as 16 hex
44/// digits — `<hint>-<16 hex digits>`. Characters outside `[a-z0-9._-]` collapse
45/// to a single `-` in the hint; the hash carries everything the hint threw away.
46///
47/// **The hash is the unconditional part, not the hint.** A key of nothing but
48/// separators slugs to an empty hint, and the name is then the bare 16 hex
49/// digits — no hint, and no `-` to join it to. The two forms cannot be confused
50/// for one another, which is what makes the exception safe rather than a second
51/// naming rule: a hinted name is at least 18 characters and contains a `-`,
52/// and a bare one is exactly 16 and contains none. That is argued again at the
53/// branch itself, and asserted by
54/// `every_name_carries_the_hash_however_short_the_key`.
55///
56/// # Why the hash is unconditional (issue #574)
57///
58/// It used to be applied only when the slug overran the filename limit, and the
59/// slug alone was lossy twice over. Measured on this repository — 8,239 nodes
60/// rendering to 8,135 notes, 104 of them silently overwritten:
61///
62/// | mechanism | lost | where |
63/// | --- | --- | --- |
64/// | every character outside the safe set becomes `-` and runs collapse, so `…cytoscape.min.js#$a` and `…cytoscape.min.js#a` are one name | 9 | everywhere |
65/// | macOS and Windows fold filename case, so `…#A` and `…#a` are two *names* but one *file* | 95 | macOS, Windows |
66///
67/// The second mechanism is the trap. A lossless-but-case-sensitive encoding
68/// fixes the 9, verifies clean on Linux CI, and still loses 95 notes on a Mac.
69/// So the requirement is stated after folding:
70///
71/// ```text
72/// lower(note_name(k1)) == lower(note_name(k2))  implies  k1 == k2
73/// ```
74///
75/// This matters more than lossiness in a cache would, because the note names are
76/// the vault's **only** stable interface: `reset_vault_dir` deletes and rebuilds
77/// the whole directory on every render, so the one thing that survives a render
78/// is a user's own note *outside* the vault linking in by name (issue #442).
79///
80/// # The trade taken
81///
82/// Two decisions, and what each bought:
83///
84/// **The hint is lowercased rather than case-preserved.** Case-preserving would
85/// also satisfy the requirement — the hash differs for `#A` and `#a`, so the two
86/// names differ in their suffix and stay distinct under folding. It was rejected
87/// because lowercasing makes `note_name(k) == note_name(k).to_lowercase()` an
88/// invariant of the function, and *that* collapses the folded property into the
89/// literal one: there is then no way to write a version of this that is green on
90/// Linux and lossy on macOS, which is the defect shape this repository keeps
91/// finding. The cost is that `parseHTTPHeader` reads as `parsehttpheader`. That
92/// is affordable precisely because the hint is a hint — once a 17-character
93/// suffix is mandatory the name is not something anyone types from memory, so
94/// its job is to be recognisable in a file list, not to be transcribed.
95///
96/// **Readability was spent, deliberately.** Every name grows by 17 characters and
97/// hand-writing a link now needs Obsidian's autocomplete. The alternatives that
98/// keep names short — hashing only the keys observed to collide — make the *set*
99/// of collisions platform-dependent, so one key would get one filename on macOS
100/// and another on Linux and a synced vault would churn. A name that is uglier
101/// everywhere beats a name that is different per platform.
102///
103/// The mapping is not reversible (the hint is lossy and the hash is one-way), but
104/// it does not need to be: every note's frontmatter carries `key:` verbatim, so
105/// name → key is recoverable from the vault itself, which is the direction a
106/// reader actually needs.
107///
108/// # What "unique" rests on
109///
110/// Equal names imply equal hashes, not equal keys — this is a 64-bit hash, not a
111/// proof. Over this repository's 8,239 keys there is no collision, and the
112/// birthday bound at that size is about 2e-12. Should one ever occur it is
113/// *reported*, not silent: `NoteNames` in the render path claims every filename
114/// case-insensitively and warns on a repeat. What is proved outright is the
115/// folding half — the output is lowercase by construction, so case folding is the
116/// identity on it.
117#[must_use]
118pub fn note_name(key: &str) -> String {
119    // Keep the whole stem well under the 255-byte filename limit (leaving room
120    // for ".md"). The hint is ASCII, so byte length equals char count and slicing
121    // is safe.
122    const MAX: usize = 200;
123    // '-' plus the 16 hex digits of the hash.
124    const SUFFIX: usize = 17;
125    const HINT: usize = MAX - SUFFIX;
126
127    let mut hint = String::with_capacity(key.len());
128    let mut prev_dash = false;
129    for c in key.chars() {
130        if c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-') {
131            hint.push(c.to_ascii_lowercase());
132            prev_dash = false;
133        } else if !prev_dash {
134            hint.push('-');
135            prev_dash = true;
136        }
137    }
138    let hint = hint.trim_matches('-');
139    // Truncation is only ever cosmetic now: the hash, not the hint, is what keeps
140    // a 300-character grouped `use` distinct from its neighbour.
141    let hint = hint[..hint.len().min(HINT)].trim_end_matches('-');
142    let hash = fnv1a64(key.as_bytes());
143    if hint.is_empty() {
144        // A key of nothing but separators. Bare hex, and it cannot be confused
145        // with a hinted name: those are `<hint>-<16 hex>`, so at least 18
146        // characters, and this is exactly 16 with no `-` in it.
147        format!("{hash:016x}")
148    } else {
149        format!("{hint}-{hash:016x}")
150    }
151}
152
153/// FNV-1a (64-bit) — a dependency-free, deterministic hash carrying everything
154/// [`note_name`]'s hint discards. No cryptographic properties needed: nothing
155/// here defends against a chosen collision, only against an accidental one.
156///
157/// 64 bits rather than fewer because the cost of a collision is exactly the
158/// defect this suffix exists to fix — a note silently overwritten. At 8k keys a
159/// 32-bit hash collides about 0.8% of the time and a 48-bit one about 1e-5;
160/// 64 bits is 2e-12, and stays under 1e-10 for a workspace vault an order of
161/// magnitude larger.
162fn fnv1a64(bytes: &[u8]) -> u64 {
163    let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
164    for &b in bytes {
165        hash ^= u64::from(b);
166        hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
167    }
168    hash
169}
170
171/// Emit `value` as a YAML **double-quoted** scalar, `"`-delimited and escaped so
172/// it parses back to exactly `value`.
173///
174/// The one escaping rule for this module's frontmatter. It exists because the
175/// three hand-rolled variants it replaced disagreed with each other — `key:` and
176/// `project:` turned a `"` into an apostrophe, and `path:` escaped nothing — and
177/// two of the three could emit YAML that does not mean what it says:
178///
179/// | value | was emitted | parsed back as |
180/// | --- | --- | --- |
181/// | `foo\bar` | `"foo\bar"` | `foo<BS>ar` — `\b` is YAML's **backspace** escape |
182/// | `foo\dir` | `"foo\dir"` | *parse error* — `\d` is not a YAML escape |
183/// | `say"hi".rs` | `"say"hi".rs"` | *parse error* — the scalar ends at the `"` |
184///
185/// The first is the dangerous one: seven characters silently become six, and
186/// nothing anywhere reports it. The other two cost the reader every property on
187/// the note, because Obsidian parses this block as the note's properties and a
188/// block that does not parse yields no properties at all rather than an error.
189///
190/// All three inputs are legal path components on Linux and macOS. None occurs in
191/// this repository today, so this is a latent defect rather than an observed one.
192///
193/// Escapes, per YAML 1.2 §7.3.1: the two structural characters `\` and `"`, then
194/// anything a parser is not obliged to accept literally — C0 controls, `DEL`, the
195/// C1 range, and the three separators (`U+2028`, `U+2029`, `U+FEFF`) that some
196/// parsers treat as line breaks. Short escapes where YAML defines one, so the
197/// common cases stay readable, and `\uXXXX` otherwise.
198fn yaml_double_quoted(value: &str) -> String {
199    let mut out = String::with_capacity(value.len() + 2);
200    out.push('"');
201    for ch in value.chars() {
202        match ch {
203            '\\' => out.push_str(r"\\"),
204            '"' => out.push_str("\\\""),
205            '\n' => out.push_str(r"\n"),
206            '\r' => out.push_str(r"\r"),
207            '\t' => out.push_str(r"\t"),
208            '\u{0}' => out.push_str(r"\0"),
209            '\u{7}' => out.push_str(r"\a"),
210            '\u{8}' => out.push_str(r"\b"),
211            '\u{b}' => out.push_str(r"\v"),
212            '\u{c}' => out.push_str(r"\f"),
213            '\u{1b}' => out.push_str(r"\e"),
214            // Everything else a YAML parser may reject or fold: the rest of C0,
215            // DEL, the C1 range, and the separators that can read as line breaks.
216            c if (c < ' ')
217                || c == '\u{7f}'
218                || ('\u{80}'..='\u{9f}').contains(&c)
219                || matches!(c, '\u{2028}' | '\u{2029}' | '\u{feff}') =>
220            {
221                let _ = write!(out, "\\u{:04x}", c as u32);
222            }
223            c => out.push(c),
224        }
225    }
226    out.push('"');
227    out
228}
229
230/// Emit `value` in YAML **plain** (unquoted) style when that round-trips, and as
231/// [`yaml_double_quoted`] when it would not.
232///
233/// For the frontmatter fields that are written bare today — `kind`, `lang`,
234/// `status`. Those are constrained by *today's* producers (an ADR's status is
235/// validated against the house states; kinds and languages come from extraction),
236/// but `roteiro load` installs a caller-supplied graph artifact whose nodes carry
237/// whatever JSON they carry, so "the producer is careful" is not a property this
238/// renderer can rely on. A `status:` of `Accepted: superseded by 0012` emitted
239/// bare is a parse error, and `Accepted # pending` silently truncates to
240/// `Accepted`.
241///
242/// Escalating only when needed is what keeps the bytes of an existing vault
243/// unchanged — every `kind`, `lang` and `status` in this repository is plain-safe
244/// and stays bare. [`is_plain_safe`] is deliberately stricter than YAML's plain
245/// grammar for the same reason it is safe: a value it rejects is merely quoted.
246fn yaml_scalar(value: &str) -> String {
247    if is_plain_safe(value) {
248        value.to_owned()
249    } else {
250        yaml_double_quoted(value)
251    }
252}
253
254/// Whether `value` can be written as a bare YAML scalar and read back unchanged.
255///
256/// A conservative allowlist rather than YAML's actual plain-scalar grammar, which
257/// is subtle enough (indicator characters, `: ` and ` #` only in some positions,
258/// leading and trailing space, implicit typing) that implementing it is how the
259/// bug this replaces gets written a second time. Getting this wrong in the
260/// strict direction costs a pair of quotation marks; getting it wrong in the
261/// permissive direction costs the note's properties.
262///
263/// So: a leading ASCII letter, then letters, digits, `_`, `-`, `.` and `/` — which
264/// covers every kind, language and status this renderer emits — and never a word
265/// YAML resolves to a boolean or null. That last exclusion is not hypothetical:
266/// `no` is the ISO 639-1 code for Norwegian, and YAML 1.1 parsers read a bare `no`
267/// as `false`.
268fn is_plain_safe(value: &str) -> bool {
269    const NOT_STRINGS: [&str; 11] = [
270        "true", "false", "yes", "no", "on", "off", "null", "nil", "none", "y", "n",
271    ];
272    !value.is_empty()
273        && value.starts_with(|c: char| c.is_ascii_alphabetic())
274        && value
275            .chars()
276            .all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.' | '/'))
277        && !NOT_STRINGS.contains(&value.to_ascii_lowercase().as_str())
278}
279
280/// Which vault a note is being rendered into: a single project's, or one member
281/// of a **workspace** vault spanning several repositories.
282///
283/// This is the whole of the workspace-vault naming rule, in one place. Node keys
284/// are **repository-relative** (`file:README.md` names no repo), so every member
285/// of a workspace produces the same note name for its `README.md` and one would
286/// silently overwrite the rest. Qualifying the key with its project fixes that.
287///
288/// [`VaultScope::PROJECT`] (`project: None`) is not a degenerate case but the
289/// contract: it makes every name in this module reduce to exactly [`note_name`]
290/// of the bare key, with nothing qualified and no `project:` frontmatter.
291///
292/// That reduction is *still* the promise; what it no longer implies is stability
293/// against `main`. #570 could say "a single-project vault's names do not move",
294/// because the only thing moving them would have been workspace qualification.
295/// #574 moves them all, on purpose: the old names were not injective under
296/// filename case folding and the vault lost 104 notes to that. The promise here
297/// was always about **this axis** — turning workspace mode on must not rename a
298/// project's notes — and it holds unchanged. See [`note_name`] for the rename and
299/// what it bought.
300#[derive(Debug, Clone, Copy)]
301pub struct VaultScope<'a> {
302    /// The member project this note belongs to, qualifying its name as
303    /// `<project>::<key>` — the same form ADR-0009's cross-repo links already use.
304    /// `None` ⇒ a single-project vault, and names are unqualified exactly as
305    /// before.
306    pub project: Option<&'a str>,
307    /// The workspace's member project names. An external-ref placeholder whose
308    /// target names one of these is a cross-repo edge the vault can actually
309    /// follow, so it is rendered as a link straight to that member's note. Empty
310    /// for a single-project vault.
311    pub members: &'a std::collections::BTreeSet<String>,
312}
313
314/// The empty member set backing [`VaultScope::PROJECT`] — a single-project vault
315/// has no other members to resolve a cross-repo reference against.
316static NO_MEMBERS: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
317
318impl VaultScope<'_> {
319    /// A single-project vault: names are unqualified, and no cross-repo reference
320    /// resolves. Every name this produces is byte-identical to [`note_name`] of
321    /// the bare key — see the type's documentation for why that reduction is
322    /// load-bearing, and for what it does *not* promise.
323    pub const PROJECT: Self = Self {
324        project: None,
325        members: &NO_MEMBERS,
326    };
327}
328
329impl Default for VaultScope<'_> {
330    fn default() -> Self {
331        Self::PROJECT
332    }
333}
334
335impl VaultScope<'_> {
336    /// Whether an external-ref placeholder `key` is one this vault resolves for
337    /// itself — its target names a member, so every edge to it points at the real
338    /// note and the placeholder need not be rendered at all.
339    ///
340    /// The single rule behind both halves of that: [`link_target`] redirects
341    /// exactly the keys this accepts, and the caller skips writing exactly the
342    /// notes this accepts. They cannot disagree.
343    #[must_use]
344    pub fn redirects_external_ref(&self, key: &str) -> bool {
345        key.strip_prefix("extref:")
346            .and_then(rto_graph::parse_qualified)
347            .is_some_and(|(project, _)| self.members.contains(project))
348    }
349}
350
351/// The note name for a node `key` owned by `scope`'s project.
352///
353/// In a single-project vault (`scope.project == None`) this *is* [`note_name`].
354/// In a workspace vault it is [`note_name`] of the project-qualified key
355/// `<project>::<key>` — reusing ADR-0009's qualified form rather than inventing a
356/// second one, which is what lets a cross-repo external-ref target (already
357/// stored qualified) map to its note by the very same call.
358#[must_use]
359pub fn scoped_note_name(scope: &VaultScope<'_>, key: &str) -> String {
360    match scope.project {
361        None => note_name(key),
362        Some(project) => note_name(&format!("{project}::{key}")),
363    }
364}
365
366/// The note an edge pointing at `key` should link to.
367///
368/// Almost always [`scoped_note_name`]. The exception is the one cross-repo edge
369/// the graph already models: a spoke's inferred link to a hub is stored as an
370/// edge to a **local external-ref placeholder** (`extref:<project>::<key>`,
371/// [`rto_graph::external_ref_key`]) because store integrity requires both ends of
372/// an edge in one store. A workspace vault holds both repos' notes, so when the
373/// placeholder's target names a member the link is pointed at the **real** note
374/// instead of the stand-in.
375///
376/// This invents no edge. It renders the edge that is there, following the
377/// placeholder exactly as [`rto_graph::Workspace::follow_external_ref`] does at
378/// query time — the cross-repo graph has only ever been *rendered* one repo at a
379/// time.
380fn link_target(scope: &VaultScope<'_>, key: &str) -> String {
381    if scope.redirects_external_ref(key) {
382        // `note_name(qualified)` is by construction the same string
383        // `scoped_note_name` produces for that member's own copy of the node.
384        // `strip_prefix`, not `trim_start_matches`: the latter strips the prefix
385        // repeatedly, which would mangle a target that legitimately starts with it.
386        return note_name(key.strip_prefix("extref:").unwrap_or(key));
387    }
388    scoped_note_name(scope, key)
389}
390
391/// Render a node's [`Explanation`] into an Obsidian note: YAML frontmatter (with
392/// `tags` for the graph view and an ADR's `status`), a clickable **Source** link
393/// (when `source_base` — a web "blob" base like
394/// `https://github.com/org/repo/blob/<sha>` — is known and the node has a path),
395/// the content as the knowledge base, and its edges as provenance-labelled
396/// wikilinks.
397///
398/// `body` is the node's **full source text**, which only the caller can fetch:
399/// this function is a pure function of the `Explanation`, and an `Explanation`
400/// carries no repository, store or blob. When it is `Some`, it replaces
401/// `meta.content` in the note's `## Content` section — see [`note_body`] for why
402/// replacing is the only correct combination of the two.
403#[must_use]
404pub fn render_note(ex: &Explanation, source_base: Option<&str>, body: Option<&str>) -> VaultNote {
405    render_note_scoped(ex, source_base, body, &VaultScope::PROJECT)
406}
407
408/// [`render_note`], for one member of a **workspace** vault: identical except
409/// that the note's own name and every link it emits are resolved through `scope`
410/// (see [`VaultScope`]).
411///
412/// With [`VaultScope::PROJECT`] this is [`render_note`] byte for byte, which is
413/// how the single-project vault's compatibility promise is kept by construction
414/// rather than by a parallel code path that has to be kept in step.
415#[must_use]
416pub fn render_note_scoped(
417    ex: &Explanation,
418    source_base: Option<&str>,
419    body: Option<&str>,
420    scope: &VaultScope<'_>,
421) -> VaultNote {
422    let meta = &ex.meta;
423    let status = meta.get("status").and_then(|v| v.as_str());
424    let content = note_body(meta.get("content").and_then(|v| v.as_str()), body);
425
426    let mut c = String::new();
427    c.push_str("---\n");
428    let _ = writeln!(c, "key: {}", yaml_double_quoted(&ex.node.key));
429    let _ = writeln!(c, "kind: {}", yaml_scalar(ex.node.kind.as_str()));
430    // Which member this note came from. Absent in a single-project vault, where
431    // it would be one constant repeated on every note — and where adding it would
432    // change every note's bytes.
433    if let Some(project) = scope.project {
434        let _ = writeln!(c, "project: {}", yaml_double_quoted(project));
435    }
436    if let Some(path) = &ex.node.path {
437        let _ = writeln!(c, "path: {}", yaml_double_quoted(path));
438    }
439    if let Some(lang) = &ex.node.lang {
440        let _ = writeln!(c, "lang: {}", yaml_scalar(lang));
441    }
442    if let Some(status) = status {
443        let _ = writeln!(c, "status: {}", yaml_scalar(status));
444    }
445    // Nested tags group in Obsidian's tag pane and colour the graph view.
446    c.push_str("tags:\n");
447    let _ = writeln!(c, "  - roteiro/kind/{}", tag_slug(&ex.node.kind));
448    // Colours the graph view by member, which is the one thing a workspace vault
449    // is for and a per-project vault has no use for.
450    if let Some(project) = scope.project {
451        let _ = writeln!(c, "  - roteiro/project/{}", tag_slug(project));
452    }
453    if let Some(lang) = &ex.node.lang {
454        let _ = writeln!(c, "  - roteiro/lang/{}", tag_slug(lang));
455    }
456    if let Some(status) = status {
457        let _ = writeln!(c, "  - roteiro/status/{}", tag_slug(status));
458    }
459    c.push_str("---\n\n");
460
461    let _ = writeln!(c, "# {}", ex.node.name);
462    if let Some(status) = status {
463        let _ = writeln!(c, "\n> **Status:** {status}");
464    }
465
466    // A clickable link to the file this node comes from. An absolute URL, so it
467    // works from the downloaded vault too (which has no repo files beside it).
468    if let (Some(base), Some(path)) = (source_base, ex.node.path.as_deref()) {
469        let _ = writeln!(
470            c,
471            "\n**Source:** [`{path}`]({}/{path})",
472            base.trim_end_matches('/')
473        );
474    }
475
476    // The knowledge base: the full source text, or the captured doc comment /
477    // prose / PDF / image text.
478    if let Some(content) = content.map(str::trim).filter(|s| !s.is_empty()) {
479        c.push_str("\n## Content\n\n");
480        c.push_str(content);
481        c.push('\n');
482    }
483
484    if !ex.outgoing.is_empty() {
485        c.push_str("\n## Outgoing\n\n");
486        for e in &ex.outgoing {
487            let _ = writeln!(
488                c,
489                "- {} ({}){} → [[{}]]",
490                e.kind,
491                e.provenance,
492                confidence(e.confidence),
493                link_target(scope, &e.node)
494            );
495        }
496    }
497    if !ex.incoming.is_empty() {
498        c.push_str("\n## Incoming\n\n");
499        for e in &ex.incoming {
500            let _ = writeln!(
501                c,
502                "- [[{}]] {} ({}){} →",
503                link_target(scope, &e.node),
504                e.kind,
505                e.provenance,
506                confidence(e.confidence)
507            );
508        }
509    }
510
511    VaultNote {
512        filename: format!("{}.md", scoped_note_name(scope, &ex.node.key)),
513        content: c,
514    }
515}
516
517/// Choose the text a note shows: the caller's full `body` when it has one, else
518/// the node's stored `content`.
519///
520/// The two are **not** complementary, they are the same text at two fidelities,
521/// so a note shows one of them and never both. `meta.content` is an embedding
522/// budget — extraction caps it (1500 chars) and collapses every whitespace run to
523/// a single space, which is right for a store that ships with the graph and wrong
524/// for a note: a 23 KB document arrives as one 1500-character line with every
525/// heading, table and code fence flattened into it. Where the caller can supply
526/// the source, that is what a reader wants; appending the capped rendering
527/// underneath it would only restate its first 6% badly.
528fn note_body<'a>(content: Option<&'a str>, body: Option<&'a str>) -> Option<&'a str> {
529    body.or(content)
530}
531
532/// `" (0.82)"` for an inferred edge's confidence, else empty.
533fn confidence(c: Option<f64>) -> String {
534    c.map_or_else(String::new, |c| format!(" ({c:.2})"))
535}
536
537/// A tag-safe slug: lowercase, non-alphanumeric runs → `-`. Keeps Obsidian tags
538/// (`roteiro/kind/adr-section`) valid and stable.
539fn tag_slug(s: &str) -> String {
540    let mut out = String::with_capacity(s.len());
541    let mut prev_dash = false;
542    for ch in s.chars() {
543        if ch.is_ascii_alphanumeric() {
544            out.push(ch.to_ascii_lowercase());
545            prev_dash = false;
546        } else if !prev_dash {
547            out.push('-');
548            prev_dash = true;
549        }
550    }
551    out.trim_matches('-').to_owned()
552}
553
554/// One ADR in the overview, with its lifecycle status.
555#[derive(Debug, Clone)]
556pub struct AdrEntry {
557    /// The ADR node key (`adr:<id>`).
558    pub key: String,
559    /// The ADR title.
560    pub name: String,
561    /// Lifecycle status (`Accepted`, …), if recorded.
562    pub status: Option<String>,
563}
564
565/// The `_Home` overview's config-secret inventory figures.
566///
567/// Counts and file paths only — deliberately not the key names, which belong in
568/// `roteiro config-secrets` where the caveat can be stated at length. A vault note
569/// is read casually and out of context, which is exactly the wrong place for a
570/// list that looks like a secret scan's output.
571#[derive(Debug, Clone, Default)]
572pub struct ConfigSecretSummary {
573    /// Config keys whose **name** matched the secret-name heuristic.
574    pub secret_named: usize,
575    /// Of those, how many had their value redacted before persistence.
576    pub redacted: usize,
577    /// Of those, how many are declared in code with no literal value.
578    pub declared: usize,
579    /// Of those, how many carry an unredacted value. Expected to be zero.
580    pub unredacted: usize,
581    /// Distinct files carrying at least one secret-named key, ordered and capped
582    /// by the caller.
583    pub files: Vec<String>,
584}
585
586/// One file in the `_Home` overview's intent-debt density table.
587#[derive(Debug, Clone)]
588pub struct DensityEntry {
589    /// Repository-relative path, used for both the wikilink and the label.
590    pub path: String,
591    /// Retained markers in the file.
592    pub markers: u32,
593    /// The file's length in lines — the denominator.
594    pub lines: u32,
595    /// Markers per 1,000 lines.
596    pub per_kloc: f64,
597}
598
599/// One node in the `_Home` overview's directed-coupling table.
600#[derive(Debug, Clone)]
601pub struct CouplingEntry {
602    /// The node key, for the wikilink.
603    pub key: String,
604    /// The symbol name.
605    pub name: String,
606    /// Distinct callers.
607    pub fan_in: u32,
608    /// Distinct callees.
609    pub fan_out: u32,
610}
611
612/// Aggregate figures for the vault's `_Home` overview note.
613#[derive(Debug, Clone, Default)]
614pub struct VaultSummary {
615    /// Name of the scanned project (repository directory).
616    pub project: String,
617    /// Total node and edge counts.
618    pub total_nodes: usize,
619    /// Total edge count.
620    pub total_edges: usize,
621    /// `(kind, count)` for each node kind, most-frequent first.
622    pub node_counts: Vec<(String, usize)>,
623    /// `(provenance, edge count)` — `derived` / `authored` / `inferred`.
624    pub edge_provenance: Vec<(String, usize)>,
625    /// The ADRs, with status.
626    pub adrs: Vec<AdrEntry>,
627    /// `(category, count)` of intent-debt markers.
628    pub debt: Vec<(String, usize)>,
629    /// The files where that debt is most **concentrated**, already ranked and
630    /// capped by the caller. Empty when the graph has no markers, or when no
631    /// file carrying one has a recorded length.
632    pub densest_files: Vec<DensityEntry>,
633    /// Secret-named config keys and their redaction state. `None` when the graph
634    /// holds no secret-named config key — the section is then absent rather than
635    /// rendering a row of zeroes, which would read as a clean bill of health this
636    /// lens cannot give.
637    pub config_secrets: Option<ConfigSecretSummary>,
638    /// The most depended-on symbols by **directed** call fan-in, already ranked
639    /// and capped by the caller. Empty when the graph has no `calls` edges.
640    pub most_called: Vec<CouplingEntry>,
641    /// Web root of the repository (`https://host/owner/repo`), if derivable from
642    /// the git remote — for a "Repository" link in the overview, and the
643    /// **clone-from** column of a workspace vault's manifest (#442 part 2).
644    ///
645    /// The web root rather than the raw `origin` fetch URL on purpose: a vault is
646    /// made to be handed to someone, and `git@host:owner/repo.git` is only
647    /// actionable for a reader who already has SSH access to that host.
648    ///
649    /// It is **where the code lives, not a guaranteed clone URL**, and the
650    /// manifest says so. `repo_web_root` normalises a remote to `https://host/…`,
651    /// which clones on the common forges and may not on an unusual one, and says
652    /// nothing about whether the reader can read a private repository. A manifest
653    /// that promised "clone from here" would be making a claim it cannot check.
654    pub repo_url: Option<String>,
655    /// Hex commit the graph was rendered from, for a permalink note — and, in a
656    /// workspace vault, the commit this member is pinned at.
657    ///
658    /// With [`Self::repo_url`] it is what makes a workspace vault **replicable**
659    /// rather than merely browsable: *"here is my workspace"* is far less useful
660    /// than *"here is my workspace **at these commits**"*, and it is what lets a
661    /// reader tell a stale vault from a current one instead of guessing.
662    pub commit: Option<String>,
663    /// The **enabled** `[ingest]` toggles this member was extracted under, by
664    /// name (`prose`, `pdf`, …), and its `[debt] ignore` globs.
665    ///
666    /// The manifest's third leg, after clone URL and commit: those two get a
667    /// reader the same *source*, and these two are what decide whether the same
668    /// source produces the same *vault*. `[ingest] prose` off means notes with no
669    /// captured content; a `[debt] ignore` glob means the debt figures on this
670    /// page are already filtered. Without them "reproducible" means "you can
671    /// obtain the code", which is a weaker claim than the section makes.
672    pub settings: RenderedUnder,
673    /// This member's stored analyzer findings (ADR-0012), ordered most severe
674    /// first by the caller. Rendered in a workspace vault's `_Home`; read
675    /// together with [`Self::coverage`], which is what says whether an empty
676    /// list means anything at all.
677    pub findings: Vec<FindingEntry>,
678    /// Whether an analyzer has ever run against this member — the context that
679    /// makes an empty [`Self::findings`] readable rather than reassuring. See
680    /// [`Coverage`].
681    pub coverage: Coverage,
682}
683
684/// Render the vault's overview note: what was scanned, the structure by kind,
685/// the provenance breakdown, the decisions (ADRs) and their status, the
686/// intent-debt summary, and how to navigate. The entry point for the vault.
687#[must_use]
688pub fn render_home(s: &VaultSummary) -> VaultNote {
689    let mut c = String::new();
690    c.push_str("---\ntags:\n  - roteiro/home\n---\n\n");
691    let _ = writeln!(c, "# {} — knowledge graph", s.project);
692    c.push_str(
693        "\n*A browsable snapshot of this codebase as one **knowledge graph**, \
694         generated by [Roteiro](https://roteiro.dev). Every symbol, document and \
695         decision is a note, linked to the things it relates to.*\n",
696    );
697    c.push_str(HOW_TO_READ);
698    let _ = writeln!(
699        c,
700        "\n**{} nodes**, **{} edges** across the project.",
701        s.total_nodes, s.total_edges
702    );
703    write_repo_line(&mut c, s);
704    write_summary_sections(&mut c, s, &VaultScope::PROJECT, 2);
705    c.push_str(NAVIGATING);
706
707    VaultNote {
708        filename: HOME_NOTE.to_owned(),
709        content: c,
710    }
711}
712
713/// The "how to read a note" paragraph. Shared verbatim by the single-project and
714/// workspace overviews — the notes themselves are identical in both, so a reader
715/// who learns the format once has learned it for either.
716const HOW_TO_READ: &str = "\n**How to read it.** Open any note to see what a thing is, the intent or \
717     docs behind it (its **Content**), where it lives (its **Source** link), \
718     and how it connects (**Outgoing**/**Incoming** links). Each link is \
719     labelled with how the fact was established — `derived` (extracted from \
720     code), `authored` (human intent: ADRs, blueprints, annotations), or \
721     `inferred` (a scored suggestion). Open Obsidian's **graph view** to see \
722     the whole thing at once.\n";
723
724/// The closing navigation section.
725const NAVIGATING: &str = "\n## Navigating this vault\n\n\
726     - Open the **graph view** to see the whole codebase; notes are coloured/\
727     filterable by their `roteiro/kind/*`, `roteiro/lang/*` and \
728     `roteiro/status/*` tags.\n\
729     - Each note carries its captured **content** (doc comments, prose, PDF/\
730     image text) and its provenance-labelled incoming/outgoing links.\n\
731     - Start from an ADR above, or search the tag pane for a kind.\n";
732
733/// `**Repository:** …` — the web root and the commit the graph was rendered from.
734fn write_repo_line(c: &mut String, s: &VaultSummary) {
735    if let Some(repo) = &s.repo_url {
736        let _ = write!(c, "\n**Repository:** [{repo}]({repo})");
737        if let Some(commit) = &s.commit {
738            let short = &commit[..commit.len().min(12)];
739            let _ = write!(c, " · rendered at commit `{short}`");
740        }
741        c.push('\n');
742    }
743}
744
745/// Every aggregate the overview carries for **one project**: structure by kind,
746/// provenance, ADRs, intent debt (and where it is densest), the config-secret
747/// inventory and directed call coupling.
748///
749/// Factored out of [`render_home`] so a workspace vault's per-member section is
750/// *the same code*, not a reimplementation that can drift: the promise in issue
751/// #442 is that today's per-project view stays a **subset** of the workspace one
752/// rather than a casualty of it. `level` is the markdown heading depth — 2 for a
753/// single-project `_Home`, 3 inside a member's section — and `scope` decides
754/// whether the wikilinks point at bare or project-qualified notes.
755fn write_summary_sections(c: &mut String, s: &VaultSummary, scope: &VaultScope<'_>, level: usize) {
756    let hd = &"#".repeat(level);
757    let sub = &"#".repeat(level + 1);
758    write_structure(c, s, hd);
759    write_decisions(c, s, scope, hd);
760    write_debt(c, s, scope, hd, sub);
761    write_config_secrets(c, s, scope, hd);
762    write_coupling(c, s, scope, hd);
763}
764
765/// `Structure` (nodes by kind) and `Provenance` (edges by how they were established).
766fn write_structure(c: &mut String, s: &VaultSummary, hd: &str) {
767    let _ = write!(c, "\n{hd} Structure\n\n| Kind | Count |\n| --- | --- |\n");
768    for (kind, n) in &s.node_counts {
769        let _ = writeln!(c, "| {kind} | {n} |");
770    }
771
772    if !s.edge_provenance.is_empty() {
773        let _ = write!(
774            c,
775            "\n{hd} Provenance\n\n| Provenance | Edges |\n| --- | --- |\n"
776        );
777        for (prov, n) in &s.edge_provenance {
778            let _ = writeln!(c, "| {prov} | {n} |");
779        }
780    }
781}
782
783/// `Decisions (ADRs)` — the recorded decisions and their lifecycle status.
784fn write_decisions(c: &mut String, s: &VaultSummary, scope: &VaultScope<'_>, hd: &str) {
785    let _ = write!(c, "\n{hd} Decisions (ADRs)\n\n");
786    if s.adrs.is_empty() {
787        c.push_str("*No ADRs found.*\n");
788    } else {
789        for adr in &s.adrs {
790            let status = adr.status.as_deref().unwrap_or("—");
791            let _ = writeln!(
792                c,
793                "- **{status}** — [[{}|{}]]",
794                scoped_note_name(scope, &adr.key),
795                adr.name
796            );
797        }
798    }
799}
800
801/// `Intent debt` — the marker categories, and the files the debt is densest in.
802fn write_debt(c: &mut String, s: &VaultSummary, scope: &VaultScope<'_>, hd: &str, sub: &str) {
803    let _ = write!(c, "\n{hd} Intent debt\n\n");
804    if s.debt.is_empty() {
805        c.push_str("*None recorded.*\n");
806    } else {
807        c.push_str("| Category | Count |\n| --- | --- |\n");
808        for (cat, n) in &s.debt {
809            let _ = writeln!(c, "| {cat} | {n} |");
810        }
811    }
812
813    if !s.densest_files.is_empty() {
814        let _ = write!(
815            c,
816            "\n{sub} Densest files (markers per 1,000 lines)\n\n\
817             *Where the debt above is concentrated, rather than where there is \
818             most of it — a raw count ranks the biggest file first by \
819             construction. The denominator is file length: every line, blanks and \
820             comments included, not source lines of code. Prose matches (`for \
821             now`, `tbd`) count too, so a design document can rank high.*\n\n"
822        );
823        c.push_str("| File | Markers | Lines | Per 1k |\n| --- | --- | --- | --- |\n");
824        for e in &s.densest_files {
825            let _ = writeln!(
826                c,
827                "| [[{}\\|{}]] | {} | {} | {:.2} |",
828                scoped_note_name(scope, &format!("file:{}", e.path)),
829                e.path,
830                e.markers,
831                e.lines,
832                e.per_kloc
833            );
834        }
835    }
836}
837
838/// `Config keys named like secrets` — an inventory and its unconditional caveat.
839fn write_config_secrets(c: &mut String, s: &VaultSummary, scope: &VaultScope<'_>, hd: &str) {
840    if let Some(cs) = &s.config_secrets {
841        let _ = write!(c, "\n{hd} Config keys named like secrets\n\n");
842        let _ = writeln!(
843            c,
844            "**{}** secret-named config key(s): {} redacted before storage, {} \
845             declared in code without a value, {} unredacted.",
846            cs.secret_named, cs.redacted, cs.declared, cs.unredacted
847        );
848        if cs.unredacted > 0 {
849            let _ = writeln!(
850                c,
851                "\n> [!warning] {} key(s) carry an **unredacted** value. Extraction \
852                 always redacts, so these came from an import layer — inspect the \
853                 importing tool, not this repository.",
854                cs.unredacted
855            );
856        }
857        if !cs.files.is_empty() {
858            c.push_str("\nIn:\n");
859            for path in &cs.files {
860                let _ = writeln!(
861                    c,
862                    "- [[{}\\|{path}]]",
863                    scoped_note_name(scope, &format!("file:{path}"))
864                );
865            }
866        }
867        // The caveat is unconditional and comes last, so it is the final thing read
868        // in this section. A vault note is browsed out of context; this is exactly
869        // where "config keys named like secrets" would otherwise be misread as a
870        // secret scan that came back clean.
871        c.push_str(
872            "\n*An inventory of config keys whose **names** look secret, not a secret \
873             scan. Values are redacted before they are stored, so this reports that \
874             such keys exist and were redacted — never a value. It cannot see a \
875             hardcoded credential in source code, cannot judge whether a value is \
876             valid, and cannot tell a real secret from a placeholder. A credential \
877             under an innocuous key name (`dsn`, `endpoint`) does not appear here at \
878             all, so this section being small says nothing about whether this \
879             repository leaks secrets.*\n",
880        );
881    }
882}
883
884/// `Most depended-on (call fan-in)` — directed call coupling, capped.
885fn write_coupling(c: &mut String, s: &VaultSummary, scope: &VaultScope<'_>, hd: &str) {
886    if !s.most_called.is_empty() {
887        let _ = write!(
888            c,
889            "\n{hd} Most depended-on (call fan-in)\n\n\
890             *Distinct callers and callees over `calls` edges — direction kept, so \
891             \"everything calls this\" and \"this calls everything\" are not the same \
892             row. Call targets are resolved by simple name, so a short, generically-\
893             named function can absorb every call to that name: read a large fan-in on \
894             one as a question, not a finding.*\n\n"
895        );
896        c.push_str("| Symbol | Called by | Calls |\n| --- | --- | --- |\n");
897        for e in &s.most_called {
898            let _ = writeln!(
899                c,
900                "| [[{}\\|{}]] | {} | {} |",
901                scoped_note_name(scope, &e.key),
902                e.name,
903                e.fan_in,
904                e.fan_out
905            );
906        }
907    }
908}
909
910/// One member's **version pin**: the revision of a hub it deploys (ADR-0009 step 8).
911///
912/// # Why it names its hub
913///
914/// A pin is hub-**relative**, and since #623 a workspace is a snowflake rather
915/// than a star: a project can be a spoke of one project and the hub of others, so
916/// "the hub version this member pins" has no single referent. `infra` pins
917/// `chart`, and `chart` pins `app`. Naming the hub is what lets a reader follow
918/// the chain instead of guessing which end of it a bare revision belongs to.
919#[derive(Debug, Clone, PartialEq, Eq)]
920pub struct MemberPin {
921    /// The member doing the pinning — the spoke.
922    pub member: String,
923    /// The workspace member whose version is pinned — this member's parent in the
924    /// dependency graph, not necessarily the workspace's busiest node.
925    pub hub: String,
926    /// The hub git revision pinned: a submodule sha, or an image tag resolved to a
927    /// hub ref. `None` when the member depends on `hub` but declares no version
928    /// this tool can resolve.
929    ///
930    /// `None` is a **finding**, not an absence of one — see the module's rendering
931    /// of it, and issue #505, which established that "asked and found nothing" must
932    /// not render identically to "never asked".
933    pub rev: Option<String>,
934    /// Where the pin was read from (`submodule vendor/app`, `image acme/app:1.4.0`).
935    /// `None` exactly when `rev` is.
936    pub via: Option<String>,
937}
938
939/// One cross-repo edge the workspace vault can actually follow: a spoke's node
940/// linking to a hub's, through the external-ref placeholder ADR-0009 persists.
941///
942/// Collected by the caller, which has every member's store open; the renderer
943/// only lays them out. Nothing here is a new edge — these are the `inferred`
944/// links `roteiro links` already reports, rendered for the first time.
945#[derive(Debug, Clone)]
946pub struct CrossLink {
947    /// The member the edge starts in.
948    pub from_project: String,
949    /// The source node's key, within `from_project`.
950    pub from_key: String,
951    /// The source node's display name.
952    pub from_name: String,
953    /// The edge kind (`links`, …).
954    pub kind: String,
955    /// Confidence, for an `inferred` edge.
956    pub confidence: Option<f64>,
957    /// Whether this link was **declared** (`[[links]]` in the source repo's
958    /// config, ADR-0009) rather than inferred by key matching.
959    ///
960    /// The distinction is the whole of ADR-0009's `authored → gold,
961    /// inferred → slate`: a declaration is a statement of intent by someone who
962    /// knows the topology, a match is a candidate. Until #573 the vault could not
963    /// draw it, because nothing persisted an authored cross-repo edge — so this
964    /// section carried a blanket caveat saying every row was a candidate.
965    pub authored: bool,
966    /// The project-qualified target, `<project>::<key>` (ADR-0009).
967    pub to_qualified: String,
968    /// Whether `to_qualified`'s project is a member of this workspace — and so
969    /// whether the link resolves to a note in this vault, or dangles because the
970    /// target repository is outside it.
971    pub resolves: bool,
972}
973
974/// The settings a member's notes were rendered under — the ones that change what
975/// the vault *contains*, not the whole merged config.
976///
977/// Deliberately a short list rather than the effective configuration in full.
978/// #442 asks the manifest to record "effective settings", and dumping every
979/// resolved key into a shareable artifact would re-open the redaction question
980/// this vault already has to warn about, to record settings that cannot change
981/// what a reader sees. These two can.
982#[derive(Debug, Clone, Default)]
983pub struct RenderedUnder {
984    /// Enabled `[ingest]` toggles by name, in declaration order. Empty means
985    /// every toggle was off — which is a real state and renders as such, not as
986    /// an absent row.
987    pub ingest: Vec<String>,
988    /// `[debt] ignore` globs. Non-empty means the debt figures on this page are
989    /// **already filtered**, and a reader comparing them against a fresh
990    /// `roteiro debt` without the same config will not match.
991    pub debt_ignore: Vec<String>,
992}
993
994/// One analyzer finding, as the vault renders it (ADR-0012).
995///
996/// A render-facing copy rather than `rto_graph::Finding`, matching [`AdrEntry`]
997/// and [`CouplingEntry`]: the renderer takes the fields it prints and stays free
998/// of the findings model. It deliberately does **not** carry `meta` — that is
999/// whatever the analyzer emitted, kept verbatim, and a shareable artifact is the
1000/// worst place to reproduce "whatever the tool said" unread.
1001#[derive(Debug, Clone)]
1002pub struct FindingEntry {
1003    /// The rule, advisory or check id the analyzer fired (`RUSTSEC-2026-0031`).
1004    pub rule: String,
1005    /// The severity the analyzer assigned — **a tool judgement, not a
1006    /// confidence** — rendered as the analyzer's word rather than the vault's.
1007    pub severity: String,
1008    /// One-line summary.
1009    pub title: String,
1010    /// The analyzer's full message.
1011    pub message: String,
1012    /// Repository-relative path the finding is about, if the analyzer located one.
1013    pub path: Option<String>,
1014    /// The analyzer that produced it, so a reader can tell one tool's opinion
1015    /// from another's rather than reading a merged list as a single verdict.
1016    pub analyzer: String,
1017}
1018
1019/// What a member's analyzer coverage actually is — the distinction the vault
1020/// must never blur.
1021///
1022/// An empty findings list means one of two completely different things, and
1023/// printing both as "no findings" is the failure `roteiro security status`
1024/// records as `no-analyzer-on-record`: **nothing has been analyzed** is not
1025/// **nothing is wrong**. A shareable artifact is the worst place to conflate
1026/// them, because its reader is the one person who cannot check.
1027///
1028/// [`Coverage::NotRun`] is the `Default` deliberately. A `VaultSummary` built
1029/// from `Default` has had no analyzer run against it, and defaulting the other
1030/// way would render "no findings" for a member nobody looked at — the exact
1031/// conflation this type exists to prevent, arrived at by omission.
1032#[derive(Debug, Clone, Default)]
1033pub enum Coverage {
1034    /// No analyzer has ever run against this member. **Not** a clean result.
1035    #[default]
1036    NotRun,
1037    /// At least one analyzer ran, as `(analyzer, version)` per run — so an empty
1038    /// findings list is attributable to a tool that actually looked.
1039    Ran(Vec<(String, String)>),
1040}
1041
1042/// Aggregate figures for a **workspace** vault's `_Home` overview: the members,
1043/// each with exactly the aggregates a single-project `_Home` carries, plus the
1044/// cross-repo links between them.
1045#[derive(Debug, Clone, Default)]
1046pub struct WorkspaceSummary {
1047    /// When this vault was rendered, RFC 3339 UTC.
1048    ///
1049    /// A vault is **read-only and point-in-time**. Stamping it is what gives
1050    /// that property teeth: with the per-member commits, a reader can tell
1051    /// whether what they are looking at still describes the workspace, rather
1052    /// than assuming it does.
1053    pub generated_at: String,
1054    /// The workspace name (`--workspace-name`).
1055    pub name: String,
1056    /// One entry per member repository, in stable name order. Each is the very
1057    /// same [`VaultSummary`] a per-project vault would render.
1058    pub members: Vec<VaultSummary>,
1059    /// Version pins: for each member that depends on another, the hub revision it
1060    /// deploys (ADR-0009 step 8). One entry per **dependency**, not per member — a
1061    /// project with two parents pins each of them separately, and a member with no
1062    /// parents contributes nothing.
1063    ///
1064    /// Ordered by the caller, which holds the dependency graph; the renderer only
1065    /// lays them out.
1066    pub pins: Vec<MemberPin>,
1067    /// Cross-repo links between members, already ordered and capped by the caller.
1068    pub cross_links: Vec<CrossLink>,
1069    /// Cross-repo links found in total, which `cross_links` may be a capped view
1070    /// of — so the section can say what it is not showing.
1071    pub cross_links_total: usize,
1072    /// How many of `cross_links_total` were **declared** (`[[links]]`) rather
1073    /// than inferred.
1074    ///
1075    /// Counted before the cap, not from `cross_links`: that vector is truncated
1076    /// to [`WORKSPACE_CROSS_LINK_ROWS`](crate::WORKSPACE_CROSS_LINK_ROWS) rows,
1077    /// so counting it would describe the rows on screen while reading as a
1078    /// statement about the workspace — a caption that quietly changes meaning
1079    /// once a workspace grows past the cap.
1080    pub cross_links_authored: usize,
1081}
1082
1083/// Render a **workspace** vault's overview: the members and their scale, the
1084/// cross-repo links between them, and then each member's own aggregates —
1085/// structure, provenance, ADRs, intent debt, config-secret inventory and call
1086/// coupling — under its own heading.
1087///
1088/// The per-member sections are rendered by the same [`write_summary_sections`]
1089/// the single-project `_Home` uses, so the existing view is a **subset** of this
1090/// one: someone who came for their repository's coupling and debt tables finds
1091/// them, rather than a workspace total that averages them away.
1092#[must_use]
1093pub fn render_workspace_home(ws: &WorkspaceSummary) -> VaultNote {
1094    let members: std::collections::BTreeSet<String> =
1095        ws.members.iter().map(|m| m.project.clone()).collect();
1096
1097    let mut c = String::new();
1098    c.push_str("---\ntags:\n  - roteiro/home\n  - roteiro/workspace\n---\n\n");
1099    let _ = writeln!(c, "# {} — workspace knowledge graph", ws.name);
1100    c.push_str(
1101        "\n*A browsable snapshot of a whole **workspace** as one **knowledge \
1102         graph**, generated by [Roteiro](https://roteiro.dev). Every symbol, \
1103         document and decision in every member repository is a note, linked to the \
1104         things it relates to — including across repositories.*\n",
1105    );
1106    c.push_str(HOW_TO_READ);
1107    // The example is *rendered* by `note_name` rather than spelled out. A
1108    // hand-written spelling of this sentence survived #574 unchanged, so every
1109    // vault v2.0.0 built stated the pre-#574 naming rule — on the first page a
1110    // reader opens — while `note_name` was writing something else. This is the
1111    // one copy that lives in the crate defining the rule, so it can simply ask:
1112    // a derived example cannot drift, and a spelled one already has.
1113    //
1114    // The *key* it names has to be real too, or the fix trades one false
1115    // sentence in `_Home` for another: `<member>::file:README.md` was fabricated
1116    // from the member list, and workspace membership does not require a README.
1117    // A cross-repo link's **source** end is the strongest key available here —
1118    // `from_project` is a member by definition and `from_key` is a node in that
1119    // member's own store, which the Cross-repo links table below already links
1120    // to by name. The *target* end will not do: `resolves == false` means the
1121    // target repository is outside this vault, so `to_qualified` names no note
1122    // here — the same false claim one remove away.
1123    //
1124    // With no cross-repo links there is no key this function can prove is a
1125    // node, so the sentence says nothing rather than inventing one. The rule it
1126    // states is complete without an example; only the illustration is lost.
1127    let example = ws.cross_links.first().map_or_else(String::new, |l| {
1128        let key = format!("{}::{}", l.from_project, l.from_key);
1129        format!(" Here, `{key}` is the note `{}.md`.", note_name(&key))
1130    });
1131    let _ = writeln!(
1132        c,
1133        "\n**Every note is keyed `<project>::<key>`**, because a node key is \
1134         repository-relative: the same path or symbol can occur in more than one \
1135         member, and without the project the second note would overwrite the \
1136         first. A note's *filename* is derived from that key — a readable \
1137         lowercase hint, then a hash of the whole key — so no filename contains \
1138         `::`.{example} Filter the graph view by a member's `roteiro/project/*` \
1139         tag to see one repository at a time."
1140    );
1141
1142    let total_nodes: usize = ws.members.iter().map(|m| m.total_nodes).sum();
1143    let total_edges: usize = ws.members.iter().map(|m| m.total_edges).sum();
1144    let _ = writeln!(
1145        c,
1146        "\n**{total_nodes} nodes**, **{total_edges} edges** across **{}** member \
1147         repositor{}.",
1148        ws.members.len(),
1149        if ws.members.len() == 1 { "y" } else { "ies" }
1150    );
1151
1152    c.push_str("\n## Members\n\n| Project | Nodes | Edges | Repository | Commit |\n| --- | --- | --- | --- | --- |\n");
1153    for m in &ws.members {
1154        let repo = m
1155            .repo_url
1156            .as_ref()
1157            .map_or_else(|| "—".to_owned(), |u| format!("[{u}]({u})"));
1158        let commit = m.commit.as_ref().map_or_else(
1159            || "—".to_owned(),
1160            |c| format!("`{}`", &c[..c.len().min(12)]),
1161        );
1162        let _ = writeln!(
1163            c,
1164            "| [[#{}\\|{}]] | {} | {} | {repo} | {commit} |",
1165            m.project, m.project, m.total_nodes, m.total_edges
1166        );
1167    }
1168    // `[[#Heading]]`, the form the member rows above already use, rather than a
1169    // `](#slug)` anchor: the slug a renderer computes and the slug a reader's
1170    // tool computes are exactly what issue #524 is about, and a manifest is a
1171    // poor place to find out they disagree.
1172    c.push_str(
1173        "\n*The `Repository` and `Commit` columns say where each member came from \
1174         and what was read. The manifest that makes those reconstructable — and \
1175         states what this vault carries before you share it — is \
1176         [[#Reproducing this vault]], below.*\n",
1177    );
1178
1179    write_cross_links(&mut c, ws);
1180    write_findings(&mut c, ws);
1181    write_manifest(&mut c, ws);
1182
1183    for m in &ws.members {
1184        let _ = writeln!(c, "\n## {}", m.project);
1185        let _ = writeln!(
1186            c,
1187            "\n**{} nodes**, **{} edges** in this member.",
1188            m.total_nodes, m.total_edges
1189        );
1190        write_repo_line(&mut c, m);
1191        let scope = VaultScope {
1192            project: Some(&m.project),
1193            members: &members,
1194        };
1195        write_summary_sections(&mut c, m, &scope, 3);
1196    }
1197
1198    c.push_str(NAVIGATING);
1199
1200    VaultNote {
1201        filename: HOME_NOTE.to_owned(),
1202        content: c,
1203    }
1204}
1205
1206/// The `### Version pins` table — which hub revision each member deploys
1207/// (ADR-0009 step 8, issue #442).
1208///
1209/// # Why this is part of a *manifest* rather than a curiosity
1210///
1211/// The rest of the manifest reconstructs the workspace **as rendered**: clone
1212/// here, check out these commits. Pins answer a different question — what each
1213/// deployment repo is *running*, which is rarely the commit it was rendered at. A
1214/// spoke at `HEAD` deploying `app@1.4.0` describes a live system that its own
1215/// commit id says nothing about.
1216///
1217/// # Each pin names its hub
1218///
1219/// Because since #623 a workspace is a snowflake, not a star: `infra → chart →
1220/// app`, where `chart` is a spoke of one and the hub of the others. A column of
1221/// bare revisions would be ambiguous the moment a workspace has more than one
1222/// level, and silently so.
1223///
1224/// # A member that pins nothing is said to pin nothing
1225///
1226/// A dependency with no resolvable version renders as *(none detected)* rather
1227/// than being dropped. Issue #505 settled the rule this follows: **asked and found
1228/// nothing** must not render identically to **never asked**. Dropping the row
1229/// would make a workspace where pin detection is inert look exactly like one with
1230/// no dependencies at all.
1231fn write_pins(c: &mut String, ws: &WorkspaceSummary) {
1232    if ws.pins.is_empty() {
1233        return;
1234    }
1235    c.push_str("\n### Version pins\n\n");
1236    let found = ws.pins.iter().filter(|p| p.rev.is_some()).count();
1237    // Both halves agree with the count, not just the noun: "1 of 1 dependency
1238    // resolve" is what varying one and fixing the other produces.
1239    let (noun, verb) = if ws.pins.len() == 1 {
1240        ("dependency", "resolves")
1241    } else {
1242        ("dependencies", "resolve")
1243    };
1244    let _ = writeln!(
1245        c,
1246        "*{found} of {} {noun} {verb} to a hub revision. A pin is what the member \
1247         **deploys**, which is not the commit it was rendered at.*\n",
1248        ws.pins.len()
1249    );
1250    c.push_str("| Member | Pins | At revision | Read from |\n| --- | --- | --- | --- |\n");
1251    for p in &ws.pins {
1252        let _ = writeln!(
1253            c,
1254            "| {} | {} | {} | {} |",
1255            // Names are prose, not spans: `inline_untrusted` escapes `|` so they
1256            // are table-safe without becoming monospaced. The revision and the
1257            // source string come from git and from a config file, so they go
1258            // through `table_cell` like every other value this manifest quotes.
1259            inline_untrusted(&p.member),
1260            inline_untrusted(&p.hub),
1261            p.rev
1262                .as_deref()
1263                .map_or_else(|| "*(none detected)*".to_owned(), table_cell),
1264            p.via.as_deref().map_or_else(|| "—".to_owned(), table_cell),
1265        );
1266    }
1267}
1268
1269/// The `### Rendered under` table — the settings half of the manifest.
1270///
1271/// A clone URL and a commit get a reader the same **source**; these get them the
1272/// same **vault**. Rendering the same commits with `[ingest] prose` off, or with
1273/// a different `[debt] ignore`, produces a different document from the same
1274/// code — so a manifest that omits them promises a reproducibility it cannot
1275/// deliver, which is worse than promising less.
1276fn write_rendered_under(c: &mut String, ws: &WorkspaceSummary) {
1277    c.push_str("\n### Rendered under\n\n");
1278    c.push_str(
1279        "*These change what a vault **contains**, so re-rendering the commits \
1280         above under different ones will not reproduce this document.*\n\n",
1281    );
1282    c.push_str("| Member | `[ingest]` | `[debt] ignore` |\n| --- | --- | --- |\n");
1283    for m in &ws.members {
1284        let list = |v: &[String]| {
1285            if v.is_empty() {
1286                // "none" as a word, not an empty cell: a blank reads as unknown,
1287                // and these two are the difference between reproducing this
1288                // vault and something that merely resembles it.
1289                "*none*".to_owned()
1290            } else {
1291                // `[debt] ignore` is user config landing in a table cell: a
1292                // glob carrying a backtick or a `|` reshapes the row. Not
1293                // analyzer-sourced, but untrusted for the same reason — the
1294                // vault did not write it.
1295                v.iter()
1296                    .map(|x| table_cell(x))
1297                    .collect::<Vec<_>>()
1298                    .join(", ")
1299            }
1300        };
1301        let _ = writeln!(
1302            c,
1303            "| {} | {} | {} |",
1304            inline_untrusted(&m.project),
1305            list(&m.settings.ingest),
1306            list(&m.settings.debt_ignore)
1307        );
1308    }
1309}
1310
1311/// The `## Security findings` section: the stored analyzer output (ADR-0012) for
1312/// every member.
1313///
1314/// # Why this is here at all, stated once
1315///
1316/// ADR-0012 keeps findings out of `nodes`/`edges` **specifically** so they cannot
1317/// reach `export_factset`, and records the alternative as rejected because it
1318/// would "silently publish tool output into an artifact". This section is that
1319/// same publication, made **deliberately and visibly** rather than by accident:
1320/// a workspace vault is a hand-over document, and the owner ruled that it should
1321/// answer *"what is wrong with this workspace"* as well as *"what is in it"*.
1322/// The exclusions section says so at the point a reader is about to share it.
1323///
1324/// # The distinction this section exists to preserve
1325///
1326/// A member with no findings is rendered **two different ways** depending on
1327/// [`Coverage`], because "no analyzer has run" and "an analyzer ran and found
1328/// nothing" are opposite facts that look identical when both are printed as an
1329/// empty list. `roteiro security status` names that failure `no-analyzer-on-record`
1330/// and refuses to let it read as clean; a shareable artifact must refuse harder,
1331/// because its reader is the one person who cannot go and check.
1332fn write_findings(c: &mut String, ws: &WorkspaceSummary) {
1333    c.push_str("\n## Security findings\n\n");
1334
1335    let total: usize = ws.members.iter().map(|m| m.findings.len()).sum();
1336    let unanalyzed = ws
1337        .members
1338        .iter()
1339        .filter(|m| matches!(m.coverage, Coverage::NotRun))
1340        .count();
1341
1342    let _ = writeln!(
1343        c,
1344        "*Stored analyzer output (ADR-0012). **{total} finding(s)** across \
1345         {} member(s){}. A severity is the **analyzer's** judgement, not \
1346         Roteiro's, and a finding is a tool's claim rather than a confirmed \
1347         defect — read one as something to check, not something proven.*\n",
1348        ws.members.len(),
1349        if unanalyzed > 0 {
1350            format!(", with {unanalyzed} never analyzed at all")
1351        } else {
1352            String::new()
1353        }
1354    );
1355
1356    for m in &ws.members {
1357        match (&m.coverage, m.findings.is_empty()) {
1358            // Never analyzed. Said loudly, and *not* in the same breath as a
1359            // member that came back clean — the whole point of the split.
1360            (Coverage::NotRun, _) => {
1361                let _ = writeln!(c, "### {} — **not analyzed**\n", m.project);
1362                c.push_str(
1363                    "*No analyzer has run against this member, so nothing here is \
1364                     a statement about it. An empty list is the absence of a \
1365                     question, not a reassuring answer.*\n\n",
1366                );
1367            }
1368            // Analyzed, nothing found — attributable to a tool that looked.
1369            (Coverage::Ran(runs), true) => {
1370                let _ = writeln!(
1371                    c,
1372                    "### {} — no findings\n\n*{} ran and reported none.*\n",
1373                    m.project,
1374                    describe_runs(runs)
1375                );
1376            }
1377            (Coverage::Ran(runs), false) => {
1378                let _ = writeln!(
1379                    c,
1380                    "### {} — {} finding(s)\n\n*Reported by {}.*\n",
1381                    m.project,
1382                    m.findings.len(),
1383                    describe_runs(runs)
1384                );
1385                for f in &m.findings {
1386                    let at = f
1387                        .path
1388                        .as_deref()
1389                        .map_or_else(String::new, |p| format!(" · {}", inline_code(p)));
1390                    let _ = writeln!(
1391                        c,
1392                        "**{}** · {}{at} — {}\n",
1393                        inline_code(&f.severity),
1394                        inline_code(&f.rule),
1395                        // Prose, not a span: the title is the sentence a reader
1396                        // scans, so it is escaped rather than monospaced.
1397                        inline_untrusted(&f.title)
1398                    );
1399                    write_analyzer_message(c, f);
1400                }
1401            }
1402        }
1403    }
1404}
1405
1406/// An untrusted value as a **complete code span**, delimiters included.
1407///
1408/// A code span renders its content literally — no emphasis, no links, no HTML —
1409/// so the only thing that can escape one is a backtick run long enough to close
1410/// it. Widening the delimiter past the longest run inside the value is therefore
1411/// the whole defence, and it is the *right* defence: backslash-escaping inside a
1412/// span protects nothing and does show, turning `vendor/**` into `vendor\*\*`
1413/// on the page.
1414///
1415/// Whitespace is still collapsed — a newline ends the line the span sits on
1416/// however well the span itself is delimited.
1417///
1418/// **Not sufficient inside a table cell.** GFM splits a row on `|` *before*
1419/// inline parsing, so a pipe inside a code span still ends the cell. Use
1420/// [`table_cell`] there.
1421fn inline_code(s: &str) -> String {
1422    let collapsed = s.split_whitespace().collect::<Vec<_>>().join(" ");
1423    let longest_run = collapsed
1424        .split(|ch| ch != '`')
1425        .map(str::len)
1426        .max()
1427        .unwrap_or(0);
1428    let tick = "`".repeat(longest_run + 1);
1429    // CommonMark strips one leading and trailing space from a span, which is how
1430    // content that itself starts or ends with a backtick is expressed.
1431    let pad = if collapsed.starts_with('`') || collapsed.ends_with('`') {
1432        " "
1433    } else {
1434        ""
1435    };
1436    format!("{tick}{pad}{collapsed}{pad}{tick}")
1437}
1438
1439/// One line of analyzer-sourced text, made safe to interpolate into the note.
1440///
1441/// The message gets a fence; **these fields did not**, and they come from the
1442/// same place. A `title` carrying a newline ends the line it was placed on and
1443/// starts a new block — one that can open a heading, a list or a table row — so
1444/// the "quoted, not absorbed" property held for one field out of four. A `rule`
1445/// or `path` carrying a backtick escapes the code span it is wrapped in.
1446///
1447/// Four steps, in order:
1448///
1449/// 1. **Collapse every whitespace run to one space.** This is what removes the
1450///    structural attack: Markdown block constructs need a line start, and after
1451///    this there are no line starts left inside the value.
1452/// 2. **HTML-escape `&`, `<`, `>`.** Some of these values land inside raw HTML
1453///    (`<sub>`, `<details><summary>`), where a backslash escapes nothing — an
1454///    analyzer named `</sub><script>` would close the tag and keep going. This
1455///    step is why there is **one** helper rather than a Markdown one and an HTML
1456///    one: two helpers means two contexts to keep straight, and the reason this
1457///    function exists at all is that the first version secured one context and
1458///    missed its neighbour.
1459/// 3. **Backslash-escape the remaining Markdown punctuation.** A link in a
1460///    document handed to someone can point anywhere, and a stray backtick or
1461///    pipe silently reshapes the line it lands in. `<` and `>` are absent from
1462///    that set because step 2 already turned them into entities.
1463/// 4. **Defuse bare URLs.** Escaping `[` stops explicit `[text](url)` syntax and
1464///    nothing else: Obsidian and GFM *linkify* a plain `https://…`, so analyzer
1465///    text could still hand the reader a clickable link to anywhere. Replacing
1466///    the scheme's colon with `&#58;` renders identically and matches no
1467///    linkifier — the reader still sees the URL and can copy it deliberately.
1468///
1469/// Safe in both contexts, so a caller never has to know which one it is in.
1470fn inline_untrusted(s: &str) -> String {
1471    let collapsed = s.split_whitespace().collect::<Vec<_>>().join(" ");
1472    let mut out = String::with_capacity(collapsed.len());
1473    for ch in collapsed.chars() {
1474        match ch {
1475            '&' => out.push_str("&amp;"),
1476            '<' => out.push_str("&lt;"),
1477            '>' => out.push_str("&gt;"),
1478            // `&` is deliberately absent below: the entities written above must
1479            // survive intact, and backslash-escaping their `&` would render them
1480            // literally.
1481            '\\' | '`' | '*' | '_' | '[' | ']' | '|' | '#' => {
1482                out.push('\\');
1483                out.push(ch);
1484            }
1485            _ => out.push(ch),
1486        }
1487    }
1488    defuse_autolinks(&out)
1489}
1490
1491/// Stop a bare URL in untrusted text from being turned into a clickable link.
1492///
1493/// Escaping `[` covers explicit link syntax; it does nothing about linkification,
1494/// which is on by default in Obsidian and GFM. `https&#58;//evil.example` renders
1495/// as `https://evil.example` and matches no autolinker, so the URL stays
1496/// readable and copyable while ceasing to be a thing the reader can click by
1497/// accident in a document someone handed them.
1498fn defuse_autolinks(s: &str) -> String {
1499    s.replace("https://", "https&#58;//")
1500        .replace("http://", "http&#58;//")
1501}
1502
1503/// [`inline_code`] for a value that lands in a **table cell**.
1504///
1505/// A code span is not enough there: GFM splits a row on `|` before it parses
1506/// inline content, so a pipe inside the span still ends the cell and shifts
1507/// every column after it. `\|` is the documented escape, and it is applied to
1508/// the finished span so the delimiter widening still holds.
1509///
1510/// The distinction is not cosmetic — it is a third rendering context, and this
1511/// file has now been wrong about a context twice.
1512fn table_cell(s: &str) -> String {
1513    inline_code(s).replace('|', "\\|")
1514}
1515
1516/// One finding's full analyzer message, collapsed and **verbatim**.
1517///
1518/// Collapsed because the messages are long — a single GHSA advisory runs to
1519/// paragraphs, and seventeen of them inline turn `_Home` into a wall of text
1520/// nobody reads, which loses the findings as surely as omitting them. `<details>`
1521/// keeps every byte in the file (the ruling was to include them in full) while
1522/// letting the reader see the list first.
1523///
1524/// **Verbatim, in a fence, rather than as Markdown.** An analyzer message is
1525/// tool output travelling into a document that gets handed to people: rendered
1526/// as Markdown it can open headings that restructure the note, or links that
1527/// point anywhere. A fence is what makes it text the vault *quotes* rather than
1528/// text the vault *becomes* — and it preserves the advisory's own line structure,
1529/// which flattening to one line destroys.
1530///
1531/// The fence is sized to beat the longest backtick run in the message, for the
1532/// reason [`crate::docs`] handles multi-backtick spans: a three-backtick fence
1533/// around a message that itself contains one ends the block early and spills the
1534/// remainder into the note as prose.
1535fn write_analyzer_message(c: &mut String, f: &FindingEntry) {
1536    let message = f.message.trim();
1537    if message.is_empty() {
1538        let _ = writeln!(
1539            c,
1540            "<sub>reported by `{}`</sub>\n",
1541            inline_untrusted(&f.analyzer)
1542        );
1543        return;
1544    }
1545    let longest_run = message
1546        .split(|ch| ch != '`')
1547        .map(str::len)
1548        .max()
1549        .unwrap_or(0);
1550    let fence = "`".repeat(longest_run.max(2) + 1);
1551    let _ = writeln!(
1552        c,
1553        "<details><summary><sub>what `{}` said</sub></summary>\n\n\
1554         {fence}text\n{message}\n{fence}\n\n</details>\n",
1555        inline_untrusted(&f.analyzer)
1556    );
1557}
1558
1559/// `analyzer version` for each run that produced a member's findings, joined —
1560/// so "no findings" names the tool that looked rather than asserting a state of
1561/// the world.
1562fn describe_runs(runs: &[(String, String)]) -> String {
1563    if runs.is_empty() {
1564        // `Coverage::Ran` with no runs should not occur; say something true
1565        // rather than rendering an empty clause that reads as a name.
1566        return "an analyzer".to_owned();
1567    }
1568    runs.iter()
1569        .map(|(a, v)| {
1570            // `inline_untrusted`, not `inline_code`: this same value is also
1571            // interpolated into `<summary>`/`<sub>` by `write_analyzer_message`,
1572            // and keeping one treatment for it means there is no second context
1573            // to get wrong later. That is the mistake this pair of helpers was
1574            // introduced to fix, so it is not worth re-creating for monospace.
1575            let (a, v) = (inline_untrusted(a), inline_untrusted(v));
1576            // An ingested report often carries no version the producer stated,
1577            // and the store keeps that as `unknown`. Printing "`osv-scanner`
1578            // unknown" reads as a version string; saying nothing reads as the
1579            // absence it is.
1580            if v.is_empty() || v == "unknown" {
1581                format!("`{a}`")
1582            } else {
1583                format!("`{a}` {v}")
1584            }
1585        })
1586        .collect::<Vec<_>>()
1587        .join(", ")
1588}
1589
1590/// The `## Reproducing this vault` section — the **manifest** half of #442.
1591///
1592/// A vault that says *"here is my workspace"* is far less useful than one that
1593/// says *"here is my workspace **at these commits**"*. With an origin and a
1594/// commit per member, a reader can clone, check out, and hold exactly what this
1595/// vault describes; without them they have a picture and no way back to the
1596/// thing pictured.
1597///
1598/// It is also where the reader is told what a shared vault **does not** contain,
1599/// at the moment they are most likely to share it. Each exclusion is a decision
1600/// recorded on #442 rather than an oversight, so each is named with its reason.
1601fn write_manifest(c: &mut String, ws: &WorkspaceSummary) {
1602    c.push_str("\n## Reproducing this vault\n\n");
1603    let _ = writeln!(
1604        c,
1605        "*Rendered **{}**. A vault is read-only and point-in-time: it describes \
1606         these repositories at these commits, and does not change when they do.*\n",
1607        ws.generated_at
1608    );
1609    // "Repository", not "Clone from". The value is the **web root derived from
1610    // the `origin` remote**, which is the right thing to show a reader — an
1611    // `git@host:owner/repo.git` is only actionable for someone who already has
1612    // SSH to that host — but it is not guaranteed to be a working clone URL for
1613    // every forge or for a private repository. Naming the column "Clone from"
1614    // promised something this cannot deliver, and the manifest's whole value is
1615    // that its promises hold.
1616
1617    let any_origin = ws.members.iter().any(|m| m.repo_url.is_some());
1618    if any_origin {
1619        // Stated here rather than above the `if`: it says "each repository
1620        // below", and above the branch it would be followed immediately by
1621        // "no member has an `origin` remote".
1622        c.push_str(
1623            "*Each repository below is the web root derived from that member's \
1624             `origin` remote — where the code lives, not a guaranteed clone URL. \
1625             A private repository, or a forge with a different clone path, still \
1626             needs whatever access you would normally use.*\n\n",
1627        );
1628        c.push_str("| Member | Repository | At commit |\n| --- | --- | --- |\n");
1629        for m in &ws.members {
1630            let _ = writeln!(
1631                c,
1632                "| {} | {} | {} |",
1633                // The name is prose, not a span — `inline_untrusted` already
1634                // backslash-escapes `|`, so it is table-safe without becoming
1635                // monospaced. The two values below *are* spans, and go through
1636                // `table_cell`: a remote URL and a sha come from git, not from
1637                // us, and this table sits thirty lines above the one that
1638                // already knew that.
1639                inline_untrusted(&m.project),
1640                m.repo_url
1641                    .as_deref()
1642                    .map_or_else(|| "*(no `origin` remote)*".to_owned(), table_cell),
1643                m.commit
1644                    .as_deref()
1645                    .map_or_else(|| "*(unknown)*".to_owned(), table_cell),
1646            );
1647        }
1648    } else {
1649        // Every member is local-only. Say so rather than rendering a table of
1650        // "no origin" rows, which reads as a fault rather than as a workspace
1651        // that was never pushed anywhere.
1652        c.push_str(
1653            "*No member has an `origin` remote, so this vault cannot be \
1654             reconstructed from it — it describes repositories that exist only \
1655             where it was rendered.*\n",
1656        );
1657    }
1658
1659    write_pins(c, ws);
1660    write_rendered_under(c, ws);
1661
1662    c.push_str(
1663        "\n### Before you share this\n\n\
1664         Stated here because this is the point at which a vault is handed to \
1665         someone.\n\n\
1666         - **It lists this workspace's known security findings** — see *Security \
1667           findings* above. That is deliberate: a hand-over document should say \
1668           what is wrong as well as what is there. But it means this file is a \
1669           list of **unpatched weaknesses and where they are**, and unlike a \
1670           local store it cannot be un-shared once sent. Treat it as you would \
1671           the analyzer reports themselves.\n\
1672         - **Config keys are redacted by *name*, and that is narrower than it \
1673           looks.** The check matches ten well-known key names and inspects no \
1674           values, so a secret in a value whose key is not named like one — \
1675           `DATABASE_URL=postgres://user:pw@host` — is **not** redacted. \
1676           Tolerable in a local store; materially different in an artifact whose \
1677           purpose is to be handed on.\n\
1678         - **Agent memory** (ADR-0013) is the one thing deliberately left out. It \
1679           is per-developer and uncommitted, it records prose that can carry \
1680           pasted tokens, stack traces and customer names, and it has **no \
1681           redaction chokepoint** at all — so unlike the two above, there is no \
1682           version of it that is safe to include.\n",
1683    );
1684}
1685
1686/// The `## Cross-repo links` section: the edges that only a workspace vault can
1687/// show, and the honest statement of what is missing from them.
1688fn write_cross_links(c: &mut String, ws: &WorkspaceSummary) {
1689    c.push_str("\n## Cross-repo links\n\n");
1690    if ws.cross_links.is_empty() {
1691        c.push_str(
1692            "*None. These are the `inferred` cross-repo links `roteiro links \
1693             --infer --write` persists (ADR-0009); a workspace whose members have \
1694             never been inferred over has none recorded yet.*\n",
1695        );
1696        return;
1697    }
1698    // The caveat is per-row now, because the two provenances are no longer the
1699    // same claim: an **authored** row was declared by someone who knows the
1700    // topology, an **inferred** row is a scored guess. Saying "these are all
1701    // candidates" over a table containing declarations would understate the
1702    // declarations exactly as saying nothing would overstate the matches.
1703    let authored = ws.cross_links_authored;
1704    // `saturating_sub`: `WorkspaceSummary` is public, so a caller can hand us a
1705    // count larger than the total. In release that subtraction wraps and the
1706    // caption reports billions of inferred links — a rendering function should
1707    // not be the place an inconsistent input becomes nonsense. The debug
1708    // assertion says which caller was wrong, in the build that can afford to.
1709    debug_assert!(
1710        authored <= ws.cross_links_total,
1711        "cross_links_authored ({authored}) exceeds cross_links_total ({})",
1712        ws.cross_links_total
1713    );
1714    let inferred = ws.cross_links_total.saturating_sub(authored);
1715    let _ = writeln!(
1716        c,
1717        "*A spoke's config key and the hub key it corresponds to, across \
1718         repositories — the one thing a per-project vault structurally cannot \
1719         show. **{authored} declared** (`[[links]]`, ADR-0009 — a statement of \
1720         intent) and **{inferred} inferred** (`roteiro links --infer --write` — \
1721         read those as candidate correspondences).*\n"
1722    );
1723    c.push_str("| From | | To | Kind |\n| --- | --- | --- | --- |\n");
1724    for l in &ws.cross_links {
1725        let from_scope = VaultScope {
1726            project: Some(&l.from_project),
1727            members: &NO_MEMBERS,
1728        };
1729        let to = if l.resolves {
1730            format!("[[{}\\|{}]]", note_name(&l.to_qualified), l.to_qualified)
1731        } else {
1732            // Outside this workspace: there is no note to link to, and a wikilink
1733            // to a note that does not exist reads in Obsidian as one that is
1734            // merely unwritten.
1735            format!("`{}` *(outside this workspace)*", l.to_qualified)
1736        };
1737        // `declared` rather than a confidence score: an authored link carries no
1738        // score by construction, so an empty cell there would read as "confidence
1739        // unknown" instead of "not that kind of claim".
1740        let how = if l.authored {
1741            " *(declared)*".to_owned()
1742        } else {
1743            confidence(l.confidence)
1744        };
1745        let _ = writeln!(
1746            c,
1747            "| [[{}\\|{}]] | {} | {to} | {}{how} |",
1748            scoped_note_name(&from_scope, &l.from_key),
1749            l.from_name,
1750            l.from_project,
1751            l.kind,
1752        );
1753    }
1754    if ws.cross_links_total > ws.cross_links.len() {
1755        let _ = writeln!(
1756            c,
1757            "\n*Showing {} of {} — the full report is `roteiro links --matrix`.*",
1758            ws.cross_links.len(),
1759            ws.cross_links_total
1760        );
1761    }
1762    c.push_str(
1763        "\n*Shown in one direction only. The edge lives in the spoke's store, \
1764         pointing at a local placeholder for the hub's node, so the hub's own note \
1765         carries no matching **Incoming** entry — Obsidian's **Backlinks** pane \
1766         still shows it, because the link is in the vault.*\n",
1767    );
1768}
1769
1770#[cfg(test)]
1771mod tests {
1772    use super::{
1773        AdrEntry, ConfigSecretSummary, CouplingEntry, Coverage, CrossLink, DensityEntry,
1774        FindingEntry, HOME_NOTE, MemberPin, RenderedUnder, VaultScope, VaultSummary,
1775        WorkspaceSummary, note_name, render_home, render_note, render_note_scoped,
1776        render_workspace_home, scoped_note_name,
1777    };
1778    use rto_graph::{EdgeRef, Explanation, NodeSummary};
1779
1780    /// The shape of a name, pinned once so a change to it is a deliberate edit
1781    /// here rather than a diff spread over twenty other assertions.
1782    ///
1783    /// Everything else in this module composes `note_name` instead of repeating
1784    /// its output, because those tests are about *which key a link points at* and
1785    /// were never about the spelling.
1786    #[test]
1787    fn note_name_is_a_lowercase_hint_and_a_hash_of_the_whole_key() {
1788        assert_eq!(
1789            note_name("sym:rust:src/a.rs#Store"),
1790            "sym-rust-src-a.rs-store-b4cbf6633003361f"
1791        );
1792        assert_eq!(note_name("adr:0001"), "adr-0001-559a2e837953b2ff");
1793        assert_eq!(
1794            note_name("file:src/main.rs"),
1795            "file-src-main.rs-4a72627453f6780e"
1796        );
1797        // Deterministic: the suffix is a pure function of the key, so a vault
1798        // renders the same names on every machine and every run.
1799        assert_eq!(note_name("adr:0001"), note_name("adr:0001"));
1800    }
1801
1802    /// **The property `note_name` exists to have** (issue #574): distinct keys
1803    /// give distinct notes *on a case-folding filesystem*, which is where the
1804    /// vault was losing them.
1805    ///
1806    /// Asserted over lowercased names, not names. On macOS and Windows two names
1807    /// differing only in case are one file, so a name set that is distinct as
1808    /// strings can still be a vault with notes missing — and Linux CI cannot see
1809    /// it. Folding here makes the assertion say what the filesystem says, on
1810    /// every platform.
1811    ///
1812    /// The keys are the two mechanisms that were actually losing notes, taken
1813    /// from this repository's own render rather than invented: the vendored
1814    /// `cytoscape.min.js` bundle whose minified single-letter symbols differ only
1815    /// by a sigil or by case, and a pair of grouped Rust `use` keys differing
1816    /// only by a trailing comma. `render_cli` runs the same assertion end to end
1817    /// over a rendered vault; this is the unit-level statement of it.
1818    #[test]
1819    fn distinct_keys_give_distinct_notes_even_after_case_folding() {
1820        const JS: &str = "sym:javascript:crates/roteiro/src/assets/cytoscape.min.js";
1821        let keys: Vec<String> = [
1822            // Slug lossiness: the sigil and the letter both slugged to the same
1823            // thing (9 notes lost this way, on every platform).
1824            format!("{JS}#$a"),
1825            format!("{JS}#a"),
1826            format!("{JS}#$o"),
1827            format!("{JS}#o"),
1828            // Case folding: distinct names, one file (95 notes lost this way, and
1829            // only on macOS and Windows).
1830            format!("{JS}#A"),
1831            format!("{JS}#O"),
1832            format!("{JS}#S"),
1833            format!("{JS}#s"),
1834            // Real source symbols, same shape.
1835            "sym:rust:crates/rto-exec/src/sandbox_store.rs#Store".into(),
1836            "sym:rust:crates/rto-exec/src/sandbox_store.rs#store".into(),
1837            // A trailing comma is the whole difference between these two.
1838            "import:rust:crate::engine::{ChatRequest,Engine,ModelInfo,}".into(),
1839            "import:rust:crate::engine::{ChatRequest,Engine,ModelInfo}".into(),
1840            // Nothing but separators: no hint at all, so the name is bare hash.
1841            "::".into(),
1842            "##".into(),
1843            // Over the length bound, differing only past the truncation point —
1844            // the case truncation alone used to merge.
1845            format!("import:rust:{}A", "a::b::c,".repeat(60)),
1846            format!("import:rust:{}a", "a::b::c,".repeat(60)),
1847        ]
1848        .into();
1849
1850        let folded: std::collections::BTreeSet<String> =
1851            keys.iter().map(|k| note_name(k).to_lowercase()).collect();
1852        assert_eq!(
1853            folded.len(),
1854            keys.len(),
1855            "two keys share a note after case folding; the vault would hold one \
1856             file for both and report two"
1857        );
1858    }
1859
1860    /// Case folding is the identity on a note name, so the assertion above is not
1861    /// weaker than the filesystem it stands in for.
1862    ///
1863    /// This is the reason the hint is lowercased rather than case-preserved: it
1864    /// makes "distinct names" and "distinct files on macOS" the same statement,
1865    /// so there is no version of this module that passes on Linux and loses notes
1866    /// on a Mac. Without it, the two assertions could drift apart and only the
1867    /// weaker one would ever run in CI.
1868    #[test]
1869    fn a_note_name_is_already_lowercase() {
1870        for key in [
1871            "sym:rust:src/a.rs#Store",
1872            "file:README.md",
1873            "app::file:CHANGELOG.md",
1874            "sym:javascript:a.js#ABC",
1875        ] {
1876            let name = note_name(key);
1877            assert_eq!(name, name.to_lowercase(), "`{key}` kept case in its name");
1878        }
1879    }
1880
1881    /// `_Home` is a name in the same namespace as every note, and it is not
1882    /// derived from a key — so nothing must be able to collide with it. The
1883    /// mandatory suffix gives that for free: every generated name either ends in
1884    /// `-<16 hex>` or *is* 16 hex digits, and `_home` is neither.
1885    #[test]
1886    fn no_key_can_claim_the_home_note() {
1887        for key in ["_Home", "file:_Home", "_home", "::_Home::"] {
1888            assert_ne!(
1889                format!("{}.md", note_name(key)).to_lowercase(),
1890                HOME_NOTE.to_lowercase(),
1891                "`{key}` would overwrite the overview note"
1892            );
1893        }
1894    }
1895
1896    #[test]
1897    fn render_note_emits_frontmatter_and_wikilinks() {
1898        let ex = Explanation {
1899            schema: rto_graph::SCHEMA,
1900            node: NodeSummary {
1901                key: "sym:rust:a.rs#main".into(),
1902                kind: "fn".into(),
1903                name: "main".into(),
1904                path: Some("a.rs".into()),
1905                lang: Some("rust".into()),
1906            },
1907            meta: serde_json::Value::Null,
1908            outgoing: vec![EdgeRef {
1909                kind: "calls".into(),
1910                provenance: "derived",
1911                confidence: None,
1912                node: "sym:rust:a.rs#helper".into(),
1913            }],
1914            incoming: vec![EdgeRef {
1915                kind: "references".into(),
1916                provenance: "authored",
1917                confidence: None,
1918                node: "adr:0001".into(),
1919            }],
1920        };
1921        let note = render_note(&ex, None, None);
1922        assert_eq!(
1923            note.filename,
1924            format!("{}.md", note_name("sym:rust:a.rs#main"))
1925        );
1926        assert!(note.content.contains("kind: fn"));
1927        // No source base → no Source link.
1928        assert!(!note.content.contains("**Source:**"));
1929        assert!(note.content.contains("# main"));
1930        assert!(note.content.contains(&format!(
1931            "- calls (derived) → [[{}]]",
1932            note_name("sym:rust:a.rs#helper")
1933        )));
1934        assert!(note.content.contains(&format!(
1935            "- [[{}]] references (authored) →",
1936            note_name("adr:0001")
1937        )));
1938        // Tags for the graph view.
1939        assert!(note.content.contains("- roteiro/kind/fn"));
1940        assert!(note.content.contains("- roteiro/lang/rust"));
1941    }
1942
1943    #[test]
1944    fn note_name_bounds_long_keys_deterministically() {
1945        let long = format!("import:rust:{}", "a::b::c,".repeat(60));
1946        let a = note_name(&long);
1947        let b = note_name(&long);
1948        assert_eq!(a, b, "deterministic");
1949        assert!(
1950            a.len() <= 205,
1951            "bounded under the filename limit: {}",
1952            a.len()
1953        );
1954        assert_ne!(
1955            note_name(&format!("{long}x")),
1956            a,
1957            "different keys stay distinct after truncation"
1958        );
1959        // Truncation must not leave a doubled separator before the suffix — the
1960        // hint is trimmed after cutting, not before.
1961        assert!(!a.contains("--"), "{a}");
1962    }
1963
1964    /// A short key is bounded too, and every name carries the suffix — the hash
1965    /// is no longer reached for only when the hint overruns.
1966    ///
1967    /// That gating was the defect (#574): two keys short enough to skip the hash
1968    /// had nothing left to tell them apart once the slug had flattened them.
1969    #[test]
1970    fn every_name_carries_the_hash_however_short_the_key() {
1971        for key in ["a", "adr:0001", "file:README.md"] {
1972            let name = note_name(key);
1973            let (hint, hash) = name.rsplit_once('-').expect("a suffixed name");
1974            assert!(!hint.is_empty(), "{name}");
1975            assert_eq!(hash.len(), 16, "{name}");
1976            assert!(
1977                hash.chars().all(|c| c.is_ascii_hexdigit()),
1978                "the suffix is the key's hash, not part of the hint: {name}"
1979            );
1980        }
1981        // A key with no hint at all is the bare hash, which cannot be mistaken
1982        // for a hinted name (those are at least 18 characters).
1983        let bare = note_name("::");
1984        assert_eq!(bare.len(), 16, "{bare}");
1985        assert!(!bare.contains('-'), "{bare}");
1986    }
1987
1988    #[test]
1989    fn render_note_surfaces_content_and_status() {
1990        let ex = Explanation {
1991            schema: rto_graph::SCHEMA,
1992            node: NodeSummary {
1993                key: "adr:0001".into(),
1994                kind: "adr".into(),
1995                name: "Build Roteiro".into(),
1996                path: Some("docs/adr/0001.md".into()),
1997                lang: None,
1998            },
1999            meta: serde_json::json!({ "status": "Accepted", "content": "The decision text." }),
2000            outgoing: vec![],
2001            incoming: vec![],
2002        };
2003        let note = render_note(&ex, Some("https://github.com/org/repo/blob/abc123"), None);
2004        assert!(note.content.contains("status: Accepted"));
2005        assert!(note.content.contains("- roteiro/status/accepted"));
2006        assert!(note.content.contains("> **Status:** Accepted"));
2007        assert!(note.content.contains("## Content\n\nThe decision text."));
2008        // A clickable link to the actual ADR file on the repository host.
2009        assert!(
2010            note.content.contains(
2011                "**Source:** [`docs/adr/0001.md`](https://github.com/org/repo/blob/abc123/docs/adr/0001.md)"
2012            ),
2013            "{}",
2014            note.content
2015        );
2016    }
2017
2018    /// The structured document a prose note is supposed to reproduce: headings, a
2019    /// table and a fenced code block, none of which survive whitespace collapse.
2020    const DOC: &str = "# Working offline\n\nRoteiro is **offline-capable**.\n\n| Host | What |\n| --- | --- |\n| `example.com` | models |\n\n```sh\nroteiro model pull\n```\n";
2021
2022    fn prose_note(content: Option<&str>) -> Explanation {
2023        Explanation {
2024            schema: rto_graph::SCHEMA,
2025            node: NodeSummary {
2026                key: "file:docs/OFFLINE_SETUP.md".into(),
2027                kind: "file".into(),
2028                name: "OFFLINE_SETUP.md".into(),
2029                path: Some("docs/OFFLINE_SETUP.md".into()),
2030                lang: None,
2031            },
2032            meta: content.map_or(
2033                serde_json::Value::Null,
2034                |c| serde_json::json!({ "content": c }),
2035            ),
2036            outgoing: vec![],
2037            incoming: vec![],
2038        }
2039    }
2040
2041    /// The whole readability defect, in one assertion pair: a note built from
2042    /// `meta.content` alone is the document whitespace-collapsed onto one line,
2043    /// and a note built from the source is the document.
2044    ///
2045    /// The newline count is the claim. A character count alone would pass on a
2046    /// note that had merely grown longer while staying flat, which is exactly the
2047    /// failure being fixed — `meta.content` is capped *and* collapsed, and only
2048    /// the collapse is what makes it unreadable.
2049    #[test]
2050    fn a_supplied_body_supersedes_the_collapsed_stored_content() {
2051        // What extraction stores: the same text, whitespace-collapsed.
2052        let collapsed = DOC.split_whitespace().collect::<Vec<_>>().join(" ");
2053        let ex = prose_note(Some(&collapsed));
2054
2055        let note = render_note(&ex, None, Some(DOC));
2056        assert!(
2057            note.content.contains(DOC.trim()),
2058            "the source document is reproduced verbatim: {}",
2059            note.content
2060        );
2061        assert!(
2062            !note.content.contains(&collapsed),
2063            "the collapsed rendering is replaced, not appended: {}",
2064            note.content
2065        );
2066        assert!(
2067            note.content.contains("\n| Host | What |\n"),
2068            "a table needs its own lines to be a table: {}",
2069            note.content
2070        );
2071        assert!(
2072            note.content.contains("\n```sh\n"),
2073            "a fenced block needs its own lines to be a fence: {}",
2074            note.content
2075        );
2076
2077        // The flat control: the same node with no body is the one-line note.
2078        let flat = render_note(&ex, None, None);
2079        assert!(
2080            flat.content.contains(&collapsed),
2081            "without a body the stored content is still shown: {}",
2082            flat.content
2083        );
2084        assert!(
2085            content_lines(&note.content) > content_lines(&flat.content),
2086            "structure restored: {} line(s) with a body vs {} without",
2087            content_lines(&note.content),
2088            content_lines(&flat.content)
2089        );
2090        assert_eq!(
2091            content_lines(&flat.content),
2092            1,
2093            "the defect: the stored content is a single line"
2094        );
2095    }
2096
2097    /// A doc comment is a summary of a definition, not a document, and its note is
2098    /// correct as it stands. The caller supplies no body for these, so this pins
2099    /// the unchanged path — the fix must not depend on every node gaining one.
2100    #[test]
2101    fn a_note_with_no_body_is_unchanged() {
2102        let ex = Explanation {
2103            schema: rto_graph::SCHEMA,
2104            node: NodeSummary {
2105                key: "sym:rust:a.rs#main".into(),
2106                kind: "fn".into(),
2107                name: "main".into(),
2108                path: Some("a.rs".into()),
2109                lang: Some("rust".into()),
2110            },
2111            meta: serde_json::json!({ "content": "Entry point." }),
2112            outgoing: vec![],
2113            incoming: vec![],
2114        };
2115        assert!(
2116            render_note(&ex, None, None)
2117                .content
2118                .contains("## Content\n\nEntry point.")
2119        );
2120    }
2121
2122    /// Lines in the note's `## Content` section.
2123    fn content_lines(note: &str) -> usize {
2124        let body = note
2125            .split_once("## Content\n\n")
2126            .map_or("", |(_, rest)| rest);
2127        let body = body.split_once("\n## ").map_or(body, |(head, _)| head);
2128        body.trim_end().lines().count()
2129    }
2130
2131    #[test]
2132    fn render_note_shows_inferred_confidence() {
2133        let ex = Explanation {
2134            schema: rto_graph::SCHEMA,
2135            node: NodeSummary {
2136                key: "file:a.md".into(),
2137                kind: "file".into(),
2138                name: "a.md".into(),
2139                path: Some("a.md".into()),
2140                lang: None,
2141            },
2142            meta: serde_json::Value::Null,
2143            outgoing: vec![EdgeRef {
2144                kind: "related".into(),
2145                provenance: "inferred",
2146                confidence: Some(0.82),
2147                node: "file:b.md".into(),
2148            }],
2149            incoming: vec![],
2150        };
2151        let note = render_note(&ex, None, None);
2152        assert!(
2153            note.content.contains(&format!(
2154                "related (inferred) (0.82) → [[{}]]",
2155                note_name("file:b.md")
2156            )),
2157            "{}",
2158            note.content
2159        );
2160    }
2161
2162    /// The single-project `_Home` fixture, extracted so the test that reads its
2163    /// rendering stays about the rendering. Every field is populated: the
2164    /// sections it drives are individually omitted when empty, so a partial
2165    /// fixture would silently stop exercising them.
2166    fn home_summary() -> VaultSummary {
2167        VaultSummary {
2168            project: "demo".into(),
2169            total_nodes: 3,
2170            total_edges: 2,
2171            node_counts: vec![("fn".into(), 2), ("adr".into(), 1)],
2172            edge_provenance: vec![("derived".into(), 1), ("authored".into(), 1)],
2173            adrs: vec![AdrEntry {
2174                key: "adr:0001".into(),
2175                name: "First".into(),
2176                status: Some("Accepted".into()),
2177            }],
2178            debt: vec![("todo".into(), 4)], // roteiro:ignore
2179            densest_files: vec![DensityEntry {
2180                path: "src/small.rs".into(),
2181                markers: 3,
2182                lines: 120,
2183                per_kloc: 25.0,
2184            }],
2185            config_secrets: Some(ConfigSecretSummary {
2186                secret_named: 4,
2187                redacted: 3,
2188                declared: 1,
2189                unredacted: 0,
2190                files: vec![".env".into()],
2191            }),
2192            most_called: vec![CouplingEntry {
2193                key: "sym:rust:a.rs#helper".into(),
2194                name: "helper".into(),
2195                fan_in: 7,
2196                fan_out: 1,
2197            }],
2198            repo_url: Some("https://github.com/org/repo".into()),
2199            commit: Some("abcdef0123456789".into()),
2200            findings: vec![],
2201            coverage: Coverage::NotRun,
2202            settings: RenderedUnder::default(),
2203        }
2204    }
2205
2206    #[test]
2207    fn render_home_summarises_the_graph() {
2208        let summary = home_summary();
2209        let note = render_home(&summary);
2210        assert_eq!(note.filename, HOME_NOTE);
2211        assert!(note.content.contains("# demo — knowledge graph"));
2212        assert!(note.content.contains("**3 nodes**, **2 edges**"));
2213        assert!(note.content.contains("| fn | 2 |"));
2214        assert!(note.content.contains("| derived | 1 |"));
2215        assert!(note.content.contains(&format!(
2216            "**Accepted** — [[{}|First]]",
2217            note_name("adr:0001")
2218        )));
2219        assert!(note.content.contains("| todo | 4 |")); // roteiro:ignore
2220        // Directed coupling: the two fans are separate columns, and the wikilink's
2221        // own `|` is escaped so it cannot break the table it sits in.
2222        assert!(
2223            note.content.contains(&format!(
2224                "| [[{}\\|helper]] | 7 | 1 |",
2225                note_name("sym:rust:a.rs#helper")
2226            )),
2227            "{}",
2228            note.content
2229        );
2230        assert!(
2231            note.content.contains("resolved by simple name"),
2232            "the precision caveat travels with the figures"
2233        );
2234        // Density: the count and the denominator are both shown, so the ratio can
2235        // be checked rather than taken on trust, and the wikilink's own `|` is
2236        // escaped so it cannot break the table it sits in.
2237        assert!(
2238            note.content.contains(&format!(
2239                "| [[{}\\|src/small.rs]] | 3 | 120 | 25.00 |",
2240                note_name("file:src/small.rs")
2241            )),
2242            "{}",
2243            note.content
2244        );
2245        assert!(
2246            note.content.contains("not source lines of code"),
2247            "the denominator caveat travels with the figures"
2248        );
2249        // Config secrets: counts and files, and no key names — a vault note is
2250        // browsed out of context, which is the wrong place for a list that would
2251        // read as a secret scan's output.
2252        assert!(
2253            note.content.contains(
2254                "**4** secret-named config key(s): 3 redacted before storage, 1 \
2255                 declared in code without a value, 0 unredacted."
2256            ),
2257            "{}",
2258            note.content
2259        );
2260        assert!(
2261            note.content
2262                .contains(&format!("- [[{}\\|.env]]", note_name("file:.env"))),
2263            "{}",
2264            note.content
2265        );
2266        assert!(
2267            note.content.contains("not a secret scan")
2268                && note.content.contains("cannot see a hardcoded credential"),
2269            "the limitation travels with the figures: {}",
2270            note.content
2271        );
2272        assert!(
2273            !note.content.contains("[!warning]"),
2274            "no warning when nothing is unredacted: {}",
2275            note.content
2276        );
2277        // A repository link + short-commit permalink note.
2278        assert!(
2279            note.content
2280                .contains("**Repository:** [https://github.com/org/repo](https://github.com/org/repo) · rendered at commit `abcdef012345`"),
2281            "{}",
2282            note.content
2283        );
2284    }
2285
2286    #[test]
2287    fn render_home_omits_density_for_a_graph_with_no_markers() {
2288        // A clean repository has no markers, so there is no density to rank. An
2289        // empty table under a heading reads as "measured, and there is nothing";
2290        // the section is absent instead. Same rule as the coupling table below.
2291        let note = render_home(&VaultSummary {
2292            project: "clean".into(),
2293            total_nodes: 1,
2294            ..VaultSummary::default()
2295        });
2296        assert!(
2297            !note.content.contains("Densest files"),
2298            "no heading without rows: {}",
2299            note.content
2300        );
2301        // The intent-debt section itself still renders — density is an addition
2302        // to it, not a replacement.
2303        assert!(note.content.contains("## Intent debt"));
2304        assert!(note.content.contains("*None recorded.*"));
2305    }
2306
2307    #[test]
2308    fn render_home_omits_config_secrets_rather_than_rendering_zeroes() {
2309        // A row of zeroes under this heading would read as "scanned, and clean" —
2310        // a conclusion the lens cannot support, since a credential under an
2311        // innocuous key name never appears in it. The section is absent instead.
2312        let note = render_home(&VaultSummary {
2313            project: "clean".into(),
2314            total_nodes: 1,
2315            ..VaultSummary::default()
2316        });
2317        assert!(
2318            !note.content.contains("named like secrets"),
2319            "no heading without figures: {}",
2320            note.content
2321        );
2322    }
2323
2324    #[test]
2325    fn render_home_warns_loudly_about_an_unredacted_value() {
2326        // Extraction cannot produce this state, so if it appears something else
2327        // put an unredacted value in the store — and the note must say where to
2328        // look rather than implicating the repository.
2329        let note = render_home(&VaultSummary {
2330            project: "imported".into(),
2331            total_nodes: 1,
2332            config_secrets: Some(ConfigSecretSummary {
2333                secret_named: 1,
2334                redacted: 0,
2335                declared: 0,
2336                unredacted: 1,
2337                files: vec!["imported.env".into()],
2338            }),
2339            ..VaultSummary::default()
2340        });
2341        assert!(
2342            note.content.contains("[!warning]") && note.content.contains("**unredacted**"),
2343            "{}",
2344            note.content
2345        );
2346        assert!(
2347            note.content.contains("came from an import layer"),
2348            "and it points at the importing tool, not the repository: {}",
2349            note.content
2350        );
2351    }
2352
2353    #[test]
2354    fn render_home_omits_coupling_for_a_graph_with_no_calls() {
2355        // A prose-only vault has no `calls` edges. An empty table under a heading
2356        // reads as "measured, and there is nothing" — the section is absent instead.
2357        let note = render_home(&VaultSummary {
2358            project: "docs".into(),
2359            total_nodes: 1,
2360            ..VaultSummary::default()
2361        });
2362        assert!(
2363            !note.content.contains("Most depended-on"),
2364            "no heading without rows: {}",
2365            note.content
2366        );
2367        // The rest of the overview is unaffected.
2368        assert!(note.content.contains("# docs — knowledge graph"));
2369    }
2370
2371    // ---- Workspace vaults (issue #442 part 1) --------------------------------
2372
2373    /// A `Explanation` for `key`, with one outgoing edge to `to`.
2374    fn node_linking_to(key: &str, name: &str, to: &str) -> Explanation {
2375        Explanation {
2376            schema: rto_graph::SCHEMA,
2377            node: NodeSummary {
2378                key: key.into(),
2379                kind: "config_key".into(),
2380                name: name.into(),
2381                path: Some("config.toml".into()),
2382                lang: None,
2383            },
2384            meta: serde_json::Value::Null,
2385            outgoing: vec![EdgeRef {
2386                kind: "links".into(),
2387                provenance: "inferred",
2388                confidence: Some(0.91),
2389                node: to.into(),
2390            }],
2391            incoming: vec![],
2392        }
2393    }
2394
2395    fn members(names: &[&str]) -> std::collections::BTreeSet<String> {
2396        names.iter().map(|s| (*s).to_owned()).collect()
2397    }
2398
2399    /// **Rewritten deliberately under #574.** #570 landed this as "a project
2400    /// scope leaves every note name exactly as it was", and read that two ways at
2401    /// once: `PROJECT` reduces to `note_name`, *and* `note_name` itself does not
2402    /// move. #574 breaks the second half on purpose — the old names were not
2403    /// injective under filename case folding and this repository's vault lost 104
2404    /// notes to it — so the two halves are separated here rather than having
2405    /// expected values quietly updated underneath the old title.
2406    ///
2407    /// What survives is the half #570 was actually about, and it is unweakened:
2408    /// **turning workspace mode on must not rename a project's notes.** Names may
2409    /// move when `note_name` changes, for a reason argued at `note_name`; they may
2410    /// never move because a repository happens to sit inside a configured
2411    /// workspace, because that would happen by inference rather than by a release.
2412    ///
2413    /// The other half of #570's promise — that a project render is byte-identical
2414    /// apart from names — is now [`render_note_is_the_project_scoped_render_byte_for_byte`]
2415    /// and `render_cli`'s end-to-end pair.
2416    #[test]
2417    fn a_project_scope_never_qualifies_a_name() {
2418        // A user's own notes live outside the vault and link into it *by name*
2419        // (#442), so a rename breaks them silently, with no error and nothing to
2420        // grep for. Whatever workspace mode does, `VaultScope::PROJECT` must
2421        // reduce to `note_name` of the bare key.
2422        for key in [
2423            "file:README.md",
2424            "adr:0001",
2425            "sym:rust:src/a.rs#Store",
2426            "extref:other::file:README.md",
2427            "cfgkey:config.toml#serve.addr",
2428        ] {
2429            assert_eq!(
2430                scoped_note_name(&VaultScope::PROJECT, key),
2431                note_name(key),
2432                "single-project name moved for `{key}`"
2433            );
2434            // And the qualified form really is a different name, so the assertion
2435            // above is not vacuously true of every scope.
2436            let ms = members(&["app"]);
2437            assert_ne!(
2438                scoped_note_name(
2439                    &VaultScope {
2440                        project: Some("app"),
2441                        members: &ms,
2442                    },
2443                    key
2444                ),
2445                note_name(key),
2446                "qualification must move the name for `{key}`, or nothing above holds"
2447            );
2448        }
2449    }
2450
2451    #[test]
2452    fn render_note_is_the_project_scoped_render_byte_for_byte() {
2453        let ex = node_linking_to("cfgkey:config.toml#addr", "addr", "sym:rust:a.rs#A");
2454        assert_eq!(
2455            render_note(&ex, Some("https://h/b"), Some("body")),
2456            render_note_scoped(&ex, Some("https://h/b"), Some("body"), &VaultScope::PROJECT),
2457            "the unscoped entry point must stay the scoped one at PROJECT, so the \
2458             two cannot drift apart"
2459        );
2460    }
2461
2462    #[test]
2463    fn each_member_gets_its_own_note_for_the_same_key() {
2464        // The collision the whole feature exists for: node keys are
2465        // repository-relative, so every member's `README.md` is `file:README.md`.
2466        let ms = members(&["api", "sdk"]);
2467        let names: Vec<String> = ["api", "sdk"]
2468            .iter()
2469            .map(|p| {
2470                scoped_note_name(
2471                    &VaultScope {
2472                        project: Some(p),
2473                        members: &ms,
2474                    },
2475                    "file:README.md",
2476                )
2477            })
2478            .collect();
2479        assert_eq!(
2480            names,
2481            [
2482                note_name("api::file:README.md"),
2483                note_name("sdk::file:README.md")
2484            ]
2485        );
2486        assert_ne!(names[0], names[1], "two members must not share one note");
2487    }
2488
2489    /// The two names this feature has, pinned together in one place.
2490    ///
2491    /// They are easy to conflate and were, in this PR, described inconsistently
2492    /// in two doc comments — the **key** is `<project>::<key>` (ADR-0009's
2493    /// cross-repo form, which is why cross-repo links resolve), and the **note
2494    /// name** is [`note_name`] of that key, in which `::` has become `-`. A
2495    /// reader told the wrong one goes looking for a file with `::` in it.
2496    ///
2497    /// Asserting both here means the next description that drifts has something
2498    /// to disagree with, rather than waiting for a reviewer to read two comments
2499    /// side by side.
2500    #[test]
2501    fn the_qualified_key_and_the_note_name_are_different_strings() {
2502        let ms = members(&["app"]);
2503        let scope = VaultScope {
2504            project: Some("app"),
2505            members: &ms,
2506        };
2507        // The key: project-qualified, `::` intact — this is what the graph and
2508        // ADR-0009's external refs use.
2509        let qualified = "app::file:README.md";
2510        // The note name: `note_name` of exactly that key, `::` slugged to `-`,
2511        // the whole hint lowercased, and the key's own hash appended.
2512        assert_eq!(
2513            scoped_note_name(&scope, "file:README.md"),
2514            "app-file-readme.md-a114bde6dcaba1c1"
2515        );
2516        assert_eq!(note_name(qualified), "app-file-readme.md-a114bde6dcaba1c1");
2517        assert!(
2518            !scoped_note_name(&scope, "file:README.md").contains("::"),
2519            "no note name ever contains `::`"
2520        );
2521        // And on disk the stem gains the extension, which is the string a reader
2522        // actually looks for.
2523        let note = render_note_scoped(
2524            &node_with("file:README.md", Some("README.md"), None),
2525            None,
2526            None,
2527            &scope,
2528        );
2529        assert_eq!(note.filename, "app-file-readme.md-a114bde6dcaba1c1.md");
2530    }
2531
2532    /// `_Home` must *show* a name, not spell the form out.
2533    ///
2534    /// The test above pins the distinction in the code. It did not stop the
2535    /// distinction being described wrongly in the same file, because it guards
2536    /// the function and not the sentences: `render_workspace_home` went on
2537    /// writing the pre-#574 form into the `_Home` of every workspace vault
2538    /// v2.0.0 built, and nothing here disagreed with it.
2539    ///
2540    /// So this asserts the property that made that possible is gone — the
2541    /// paragraph now contains a string `note_name` actually produced for a key
2542    /// the workspace really holds, which a hand-written spelling cannot
2543    /// satisfy. It is not a tautology despite both sides calling `note_name`:
2544    /// what it rejects is the *shape* of the old copy, a form written out by
2545    /// hand next to the function that could have rendered it.
2546    ///
2547    /// That the *key* is real is the other half, and the reason the example is
2548    /// drawn from `cross_links` rather than invented from the member list —
2549    /// a name rendered for a node the vault does not hold is a true sentence
2550    /// about a note nobody can open. The empty case is
2551    /// `the_workspace_home_claims_no_example_note_when_it_has_no_real_key`.
2552    fn finding(rule: &str, severity: &str, message: &str) -> FindingEntry {
2553        FindingEntry {
2554            rule: rule.to_owned(),
2555            severity: severity.to_owned(),
2556            title: "a title".to_owned(),
2557            message: message.to_owned(),
2558            path: Some("Cargo.lock".to_owned()),
2559            analyzer: "osv-scanner".to_owned(),
2560        }
2561    }
2562
2563    fn ws_with(members: Vec<VaultSummary>) -> WorkspaceSummary {
2564        WorkspaceSummary {
2565            generated_at: "2026-08-22T10:00:00Z".to_owned(),
2566            name: "platform".into(),
2567            members,
2568            pins: Vec::new(),
2569            cross_links: vec![],
2570            cross_links_total: 0,
2571            cross_links_authored: 0,
2572        }
2573    }
2574
2575    /// An analyzer message is **tool output** being embedded in a document that
2576    /// gets handed to people. It must be quoted, never absorbed: a message
2577    /// carrying its own fence would otherwise close the block early and spill the
2578    /// rest into the note as vault prose — headings and links included.
2579    #[test]
2580    fn an_analyzer_message_cannot_break_out_of_its_fence() {
2581        let mut api = member_summary("api", 1);
2582        api.coverage = Coverage::Ran(vec![("osv-scanner".into(), "1.9.0".into())]);
2583        api.findings = vec![finding(
2584            "GHSA-x",
2585            "high",
2586            "before
2587```
2588## not a vault heading
2589```
2590after",
2591        )];
2592        let c = render_workspace_home(&ws_with(vec![api])).content;
2593
2594        // The fence opened must be longer than any run inside the message, so the
2595        // message's own ``` is content rather than a terminator.
2596        assert!(
2597            c.contains("````text"),
2598            "fence widened past the message's own: {c}"
2599        );
2600        assert!(
2601            c.contains("## not a vault heading"),
2602            "and the text is still all there: {c}"
2603        );
2604        // Nothing between the finding and the next section may be loose prose.
2605        let after = c.split("````text").nth(1).expect("a fenced block");
2606        let body = after.split("````").next().expect("a closing fence");
2607        assert!(
2608            body.contains("## not a vault heading"),
2609            "the heading is INSIDE the fence, not outside it: {body}"
2610        );
2611    }
2612
2613    /// The fence secured the **message**. Every other analyzer-sourced field on a
2614    /// finding is interpolated into a line of prose, and they come from the same
2615    /// place — so a title carrying a newline used to end its line and start a new
2616    /// block, which can open a heading the vault never wrote.
2617    #[test]
2618    fn every_analyzer_sourced_field_is_quoted_not_absorbed() {
2619        let mut api = member_summary("api", 1);
2620        api.coverage = Coverage::Ran(vec![("osv-scanner".into(), "1.9.0".into())]);
2621        api.findings = vec![FindingEntry {
2622            // Each field carries a different escape hatch.
2623            rule: "R-1`x".to_owned(),
2624            severity: "high".to_owned(),
2625            // Newlines, a heading, a link, and a list marker — the last of which
2626            // no escape set covers, so only the whitespace collapse stops it.
2627            title: "broken\n\n## an injected heading\n\n- a list item\n\n[a link](http://evil)"
2628                .to_owned(),
2629            message: "fine".to_owned(),
2630            path: Some("src/a`b.rs".to_owned()),
2631            analyzer: "osv-scanner".to_owned(),
2632        }];
2633        let c = render_workspace_home(&ws_with(vec![api])).content;
2634
2635        assert!(
2636            !c.contains("\n## an injected heading"),
2637            "a newline in a title must not start a block: {c}"
2638        );
2639        assert!(
2640            !c.contains("[a link](http://evil)"),
2641            "and a link must not survive as a link: {c}"
2642        );
2643        // The text is still readable — escaped, not deleted. Losing it would be a
2644        // different failure: a finding whose title vanished is a finding nobody
2645        // acts on.
2646        assert!(c.contains("an injected heading"), "the words remain: {c}");
2647        // A widened delimiter, not a backslash: inside a span the backslash
2648        // would show, and `vendor/**` would reach the reader as `vendor\*\*`.
2649        assert!(
2650            c.contains("``R-1`x``"),
2651            "the span widens past the backtick inside it: {c}"
2652        );
2653
2654        // The structural property, asserted directly rather than inferred from
2655        // the absence of a heading: the whole finding stays on **one line**. An
2656        // escape set can neutralise `#` and `[`, and a newline would still end
2657        // the line and start a block — and nothing escapes a `-` list marker.
2658        let line = c
2659            .lines()
2660            .find(|l| l.contains("R-1"))
2661            .unwrap_or_else(|| panic!("the finding renders: {c}"));
2662        assert!(
2663            line.contains("a link") && line.contains("a list item"),
2664            "every part of the title stays on the finding's own line: {line}"
2665        );
2666    }
2667
2668    /// The analyzer name is interpolated into **raw HTML** (`<sub>`,
2669    /// `<details><summary>`), where a backslash escapes nothing. Fixing the
2670    /// Markdown surface and leaving this one is how the first version of the
2671    /// escaping shipped.
2672    #[test]
2673    fn analyzer_text_cannot_break_out_of_the_html_it_sits_in() {
2674        let mut api = member_summary("api", 1);
2675        let hostile = "osv</sub><script>alert(1)</script>".to_owned();
2676        api.coverage = Coverage::Ran(vec![(hostile.clone(), "1.0".into())]);
2677        api.findings = vec![FindingEntry {
2678            rule: "R-1".to_owned(),
2679            severity: "high".to_owned(),
2680            title: "t".to_owned(),
2681            message: "m".to_owned(),
2682            path: None,
2683            analyzer: hostile,
2684        }];
2685        let c = render_workspace_home(&ws_with(vec![api])).content;
2686
2687        assert!(
2688            !c.contains("<script>"),
2689            "a tag in analyzer text must not survive as a tag: {c}"
2690        );
2691        assert!(
2692            !c.contains("osv</sub>"),
2693            "and must not close the element it was placed inside: {c}"
2694        );
2695        // Escaped, not dropped: the reader still sees which analyzer said it.
2696        assert!(c.contains("&lt;script&gt;"), "the text remains, inert: {c}");
2697    }
2698
2699    /// `[debt] ignore` is user config, not analyzer output — but the vault did
2700    /// not write it either, and it lands in a table cell where a `|` reshapes
2701    /// the row.
2702    #[test]
2703    fn a_config_glob_cannot_reshape_the_settings_table() {
2704        let mut api = member_summary("api", 1);
2705        api.settings = RenderedUnder {
2706            ingest: vec!["prose".into()],
2707            debt_ignore: vec!["a`b|c".into()],
2708        };
2709        let c = render_workspace_home(&ws_with(vec![api])).content;
2710        // Scoped to the settings table. The manifest table above it also has a
2711        // row starting `| api |`, and a bare `.find` matched *that* one — so the
2712        // first version of this test asserted against a row containing no glob
2713        // at all, and stayed green with the escaping removed.
2714        let section = c
2715            .split("### Rendered under")
2716            .nth(1)
2717            .unwrap_or_else(|| panic!("the settings section: {c}"));
2718        let row = section
2719            .lines()
2720            .find(|l| l.starts_with("| api |"))
2721            .unwrap_or_else(|| panic!("a settings row: {section}"));
2722        // Count **cell delimiters**, not `|` characters: an escaped `\|` is
2723        // content, and GFM does not split on it. Counting raw pipes would fail
2724        // on the correct output and pass on some wrong ones.
2725        let delimiters = row
2726            .char_indices()
2727            .filter(|&(i, ch)| ch == '|' && !row[..i].ends_with('\\'))
2728            .count();
2729        assert_eq!(
2730            delimiters, 4,
2731            "the row keeps exactly its own four cell delimiters: {row}"
2732        );
2733    }
2734
2735    /// The manifest is a table too. `table_cell` was written for the settings
2736    /// table and not applied to this one, thirty lines above it — a remote URL
2737    /// and a project name both come from git rather than from us.
2738    #[test]
2739    fn a_remote_url_cannot_reshape_the_manifest_row() {
2740        let mut api = member_summary("api", 1);
2741        api.repo_url = Some("https://host/a|b/c".to_owned());
2742        api.commit = Some("dead`beef".to_owned());
2743        let c = render_workspace_home(&ws_with(vec![api])).content;
2744
2745        let section = c
2746            .split("## Reproducing this vault")
2747            .nth(1)
2748            .unwrap_or_else(|| panic!("the manifest: {c}"));
2749        let row = section
2750            .lines()
2751            .find(|l| l.starts_with("| api |"))
2752            .unwrap_or_else(|| panic!("a manifest row: {section}"));
2753        let delimiters = row
2754            .char_indices()
2755            .filter(|&(i, ch)| ch == '|' && !row[..i].ends_with('\\'))
2756            .count();
2757        assert_eq!(
2758            delimiters, 4,
2759            "the row keeps exactly its own four cell delimiters: {row}"
2760        );
2761        assert!(
2762            row.contains("``dead`beef``"),
2763            "and a backtick in a sha widens its span: {row}"
2764        );
2765    }
2766
2767    /// Escaping `[` stops explicit link syntax and nothing else. Obsidian and
2768    /// GFM linkify a bare URL, so analyzer text could still hand the reader
2769    /// something clickable in a document they were given.
2770    #[test]
2771    fn a_bare_url_in_analyzer_text_is_not_left_clickable() {
2772        let mut api = member_summary("api", 1);
2773        api.coverage = Coverage::Ran(vec![("osv-scanner".into(), "1.9.0".into())]);
2774        api.findings = vec![FindingEntry {
2775            rule: "R-1".to_owned(),
2776            severity: "high".to_owned(),
2777            title: "see https://evil.example/path for details".to_owned(),
2778            message: "m".to_owned(),
2779            path: None,
2780            analyzer: "osv-scanner".to_owned(),
2781        }];
2782        let c = render_workspace_home(&ws_with(vec![api])).content;
2783
2784        assert!(
2785            !c.contains("https://evil.example"),
2786            "a bare URL must not survive in linkifiable form: {c}"
2787        );
2788        // Rendered identically for a reader, and still copyable — defusing it
2789        // must not amount to hiding it.
2790        assert!(c.contains("https&#58;//evil.example/path"), "{c}");
2791    }
2792
2793    /// The two empty states are opposite facts and must never render alike.
2794    #[test]
2795    fn an_unanalyzed_member_never_reads_as_one_that_came_back_clean() {
2796        let mut looked = member_summary("api", 1);
2797        looked.coverage = Coverage::Ran(vec![("osv-scanner".into(), "1.9.0".into())]);
2798        let never = member_summary("sdk", 1); // Coverage::NotRun by default
2799        let c = render_workspace_home(&ws_with(vec![looked, never])).content;
2800
2801        assert!(c.contains("### api — no findings"), "{c}");
2802        assert!(
2803            c.contains("osv-scanner` 1.9.0 ran and reported none"),
2804            "{c}"
2805        );
2806        assert!(c.contains("### sdk — **not analyzed**"), "{c}");
2807        assert!(
2808            !c.contains("### sdk — no findings"),
2809            "an unanalyzed member must never be rendered as clean: {c}"
2810        );
2811        assert!(c.contains("1 never analyzed at all"), "counted too: {c}");
2812    }
2813
2814    /// The severity a finding carries is the **analyzer's** word. An unrecognised
2815    /// level is rendered as given rather than mapped onto a known rung, because
2816    /// mapping it would be inventing a judgement the tool did not make.
2817    #[test]
2818    fn an_unrecognised_severity_keeps_the_analyzers_own_label() {
2819        let mut api = member_summary("api", 1);
2820        api.coverage = Coverage::Ran(vec![("semgrep".into(), "1.2.3".into())]);
2821        api.findings = vec![finding("R-1", "WARNING", "msg")];
2822        let c = render_workspace_home(&ws_with(vec![api])).content;
2823        assert!(c.contains("**`WARNING`**"), "{c}");
2824    }
2825
2826    /// #442: the manifest records what each member **deploys**, not only the
2827    /// commit it was rendered at — and each pin names the hub it is relative to.
2828    ///
2829    /// A snowflake fixture on purpose (`infra → chart → app`), because a star
2830    /// would let a bare-revision column pass: it is only when a workspace has two
2831    /// levels that "the hub version" stops having one answer. And deliberately
2832    /// mixed — one dependency resolves, one does not — so the row that reports
2833    /// *finding nothing* is exercised rather than assumed.
2834    #[test]
2835    fn the_manifest_records_each_members_pin_and_which_hub_it_is_relative_to() {
2836        let ws = WorkspaceSummary {
2837            generated_at: "2026-08-26T10:00:00Z".to_owned(),
2838            name: "platform".into(),
2839            members: vec![
2840                member_summary("infra", 3),
2841                member_summary("chart", 4),
2842                member_summary("app", 9),
2843            ],
2844            pins: vec![
2845                MemberPin {
2846                    member: "chart".into(),
2847                    hub: "app".into(),
2848                    rev: Some("1.4.0".into()),
2849                    via: Some("image acme/app:1.4.0".into()),
2850                },
2851                MemberPin {
2852                    member: "infra".into(),
2853                    hub: "chart".into(),
2854                    rev: None,
2855                    via: None,
2856                },
2857            ],
2858            cross_links: vec![],
2859            cross_links_total: 0,
2860            cross_links_authored: 0,
2861        };
2862        let c = render_workspace_home(&ws).content;
2863
2864        assert!(c.contains("### Version pins"), "{c}");
2865        // The hub is named per row. Without this the table is ambiguous the moment
2866        // a workspace has more than one level — which is exactly this fixture.
2867        assert!(
2868            c.contains("| chart | app | `1.4.0` |") || c.contains("| chart | app |"),
2869            "the pin names its hub: {c}"
2870        );
2871        assert!(
2872            c.contains("image acme/app:1.4.0"),
2873            "says where it read it: {c}"
2874        );
2875
2876        // The unresolved dependency is REPORTED, not dropped. #505's rule: asked
2877        // and found nothing must not render as never asked.
2878        assert!(c.contains("| infra | chart |"), "the row survives: {c}");
2879        assert!(c.contains("(none detected)"), "and says so: {c}");
2880
2881        // The count separates what was asked from what was found.
2882        assert!(
2883            c.contains("1 of 2 dependencies resolve"),
2884            "counts found against asked: {c}"
2885        );
2886    }
2887
2888    /// A one-dependency workspace reads as English.
2889    ///
2890    /// Varying the noun and fixing the verb produced "1 of 1 dependency resolve",
2891    /// which is the failure mode of pluralising by hand: the two halves have to
2892    /// agree with the same count, not one of them with the count and the other
2893    /// with a guess. Two-member workspaces are common enough that this renders.
2894    #[test]
2895    fn the_pins_caption_agrees_with_a_single_dependency() {
2896        let ws = WorkspaceSummary {
2897            generated_at: "2026-08-26T10:00:00Z".to_owned(),
2898            name: "pair".into(),
2899            members: vec![member_summary("deploy", 2), member_summary("app", 5)],
2900            pins: vec![MemberPin {
2901                member: "deploy".into(),
2902                hub: "app".into(),
2903                rev: Some("1.4.0".into()),
2904                via: Some("image acme/app:1.4.0".into()),
2905            }],
2906            cross_links: vec![],
2907            cross_links_total: 0,
2908            cross_links_authored: 0,
2909        };
2910        let c = render_workspace_home(&ws).content;
2911        assert!(
2912            c.contains("1 of 1 dependency resolves"),
2913            "singular noun and singular verb: {c}"
2914        );
2915    }
2916
2917    /// A workspace whose members depend on nothing has no pins section at all —
2918    /// rather than an empty table, which reads as a fault.
2919    #[test]
2920    fn a_workspace_with_no_dependencies_has_no_pins_table() {
2921        let ws = WorkspaceSummary {
2922            generated_at: "2026-08-26T10:00:00Z".to_owned(),
2923            name: "solo".into(),
2924            members: vec![member_summary("only", 1)],
2925            pins: Vec::new(),
2926            cross_links: vec![],
2927            cross_links_total: 0,
2928            cross_links_authored: 0,
2929        };
2930        let c = render_workspace_home(&ws).content;
2931        assert!(!c.contains("Version pins"), "{c}");
2932    }
2933
2934    /// #442 part 2: the vault says how to reconstruct the workspace it describes,
2935    /// and what it deliberately leaves out.
2936    ///
2937    /// A vault that says *"here is my workspace"* is far less useful than one
2938    /// that says *"here is my workspace at these commits"* — and the exclusions
2939    /// are stated at the point a reader is most likely to share it, because that
2940    /// is when they matter.
2941    #[test]
2942    fn the_workspace_home_says_how_to_reproduce_itself_and_what_it_omits() {
2943        let mut api = member_summary("api", 7);
2944        api.repo_url = Some("https://github.com/acme/api".to_owned());
2945        api.commit = Some("4e0d5a6afd0b1c2d".to_owned());
2946        // Deliberately **mixed**: one member reproducible, one not. A fixture where
2947        // every member has a remote never exercises the row that says so, and the
2948        // gap is the thing a reader most needs to see.
2949        let mut sdk = member_summary("sdk", 4);
2950        sdk.repo_url = None;
2951        sdk.commit = None;
2952        let ws = WorkspaceSummary {
2953            generated_at: "2026-08-22T10:00:00Z".to_owned(),
2954            name: "platform".into(),
2955            members: vec![api, sdk],
2956            pins: Vec::new(),
2957            cross_links: vec![],
2958            cross_links_total: 0,
2959            cross_links_authored: 0,
2960        };
2961        let c = render_workspace_home(&ws).content;
2962
2963        assert!(c.contains("2026-08-22T10:00:00Z"), "stamped: {c}");
2964        assert!(c.contains("read-only and point-in-time"), "{c}");
2965        assert!(c.contains("https://github.com/acme/api"), "clone-from: {c}");
2966        assert!(c.contains("4e0d5a6afd0b1c2d"), "pinned commit: {c}");
2967        // A member with no remote is named as such rather than omitted — a gap in
2968        // the manifest is the reader's problem to know about, not ours to hide.
2969        assert!(c.contains("no `origin` remote"), "{c}");
2970
2971        // The share-time warning, each item with its reason. Two of the three are
2972        // about what the vault *carries*, not what it omits — the owner ruled
2973        // findings in, so this section warns rather than reassures.
2974        assert!(
2975            c.contains("cannot be un-shared"),
2976            "including findings has a consequence, stated where it is acted on: {c}"
2977        );
2978        assert!(
2979            c.contains("Agent memory"),
2980            "the one genuine exclusion is still named: {c}"
2981        );
2982        assert!(
2983            c.contains("no redaction chokepoint"),
2984            "with its reason: {c}"
2985        );
2986        // The limit of what *is* included, which is narrower than it looks.
2987        assert!(
2988            c.contains("DATABASE_URL"),
2989            "the redaction gap is shown, not described: {c}"
2990        );
2991    }
2992
2993    /// The manifest records the settings a re-render must match, because a clone
2994    /// URL and a commit get a reader the same **source** and not the same
2995    /// **vault**: `[ingest] prose` off produces notes with no captured content,
2996    /// and a `[debt] ignore` glob means the debt figures on the page are already
2997    /// filtered.
2998    #[test]
2999    fn the_manifest_records_the_settings_a_re_render_would_have_to_match() {
3000        let mut api = member_summary("api", 1);
3001        api.settings = RenderedUnder {
3002            ingest: vec!["prose".into(), "pdf".into()],
3003            debt_ignore: vec!["vendor/**".into()],
3004        };
3005        // A member with everything off is a real state, and must not render as a
3006        // blank cell that reads as "unknown".
3007        let sdk = member_summary("sdk", 1);
3008        let c = render_workspace_home(&ws_with(vec![api, sdk])).content;
3009
3010        assert!(c.contains("### Rendered under"), "{c}");
3011        assert!(c.contains("`prose`, `pdf`"), "the enabled toggles: {c}");
3012        assert!(c.contains("`vendor/**`"), "the debt filter: {c}");
3013        assert!(
3014            c.contains("| sdk | *none* | *none* |"),
3015            "all-off is stated, not left blank: {c}"
3016        );
3017        assert!(
3018            c.contains("will not reproduce this document"),
3019            "and the section says why it is there: {c}"
3020        );
3021    }
3022
3023    /// A workspace of purely local repositories cannot be reconstructed, and the
3024    /// vault says so instead of rendering a table of blanks — which would read as
3025    /// a fault rather than as repositories that were never pushed anywhere.
3026    #[test]
3027    fn a_vault_with_no_remotes_says_it_cannot_be_reconstructed() {
3028        // `member_summary` gives every member a remote, so it must be cleared —
3029        // the fixture has to actually be the case it names.
3030        let mut api = member_summary("api", 1);
3031        api.repo_url = None;
3032        api.commit = None;
3033        let ws = WorkspaceSummary {
3034            generated_at: "2026-08-22T10:00:00Z".to_owned(),
3035            name: "local".into(),
3036            members: vec![api],
3037            pins: Vec::new(),
3038            cross_links: vec![],
3039            cross_links_total: 0,
3040            cross_links_authored: 0,
3041        };
3042        let c = render_workspace_home(&ws).content;
3043        assert!(c.contains("cannot be reconstructed"), "{c}");
3044        assert!(
3045            !c.contains("| Member | Repository |"),
3046            "no empty table: {c}"
3047        );
3048    }
3049
3050    /// #573: the cross-repo section distinguishes a **declaration** from a
3051    /// **match**, and says how many of each.
3052    ///
3053    /// Before authored links could be persisted, every row was a candidate and
3054    /// the section said so in one blanket caveat. That caveat is now false for
3055    /// declared rows, and an edge that exists but renders as a guess leaves
3056    /// ADR-0009's `authored → gold` path just as unreachable as no edge at all —
3057    /// so the rendering is part of the contract, not decoration.
3058    #[test]
3059    fn the_cross_repo_section_separates_declared_links_from_inferred_ones() {
3060        let link = |authored: bool, key: &str| CrossLink {
3061            from_project: "sdk".into(),
3062            from_key: format!("cfgkey:config.toml#{key}"),
3063            from_name: key.into(),
3064            kind: "references".into(),
3065            // A declaration carries no score by construction; a match does.
3066            confidence: if authored { None } else { Some(0.91) },
3067            to_qualified: format!("api::cfgkey:config.toml#{key}"),
3068            resolves: true,
3069            authored,
3070        };
3071        let ws = WorkspaceSummary {
3072            generated_at: "2026-08-22T10:00:00Z".to_owned(),
3073            name: "platform".into(),
3074            members: vec![member_summary("api", 7), member_summary("sdk", 4)],
3075            pins: Vec::new(),
3076            cross_links: vec![link(true, "addr"), link(false, "port")],
3077            // Totals deliberately **larger** than the two rows shown: the caption
3078            // is a statement about the workspace, and `cross_links` is a capped
3079            // view of it. Counting the rows would give 1 and 1 here and read as
3080            // correct — which is exactly the bug, invisible until a workspace
3081            // outgrows the cap.
3082            cross_links_total: 9,
3083            cross_links_authored: 4,
3084        };
3085        let c = render_workspace_home(&ws).content;
3086
3087        assert!(
3088            c.contains("**4 declared**"),
3089            "the caption counts the workspace, not the rows on screen: {c}"
3090        );
3091        assert!(c.contains("**5 inferred**"), "{c}");
3092        assert!(
3093            !c.contains("not authored facts"),
3094            "the blanket caveat is false once a declared row can appear: {c}"
3095        );
3096        // Per row: a declaration is marked as one, and a match keeps its score.
3097        assert!(c.contains("references *(declared)*"), "{c}");
3098        assert!(c.contains("references (0.91)"), "{c}");
3099    }
3100
3101    #[test]
3102    fn the_workspace_home_names_an_example_note_name_actually_produces() {
3103        let ws = WorkspaceSummary {
3104            generated_at: "2026-08-22T10:00:00Z".to_owned(),
3105            name: "platform".into(),
3106            members: vec![member_summary("api", 7), member_summary("sdk", 4)],
3107            pins: Vec::new(),
3108            cross_links: vec![CrossLink {
3109                from_project: "sdk".into(),
3110                from_key: "cfgkey:config.toml#addr".into(),
3111                from_name: "addr".into(),
3112                kind: "links".into(),
3113                confidence: Some(0.91),
3114                to_qualified: "api::cfgkey:config.toml#addr".into(),
3115                resolves: true,
3116                authored: false,
3117            }],
3118            cross_links_total: 1,
3119            cross_links_authored: 0,
3120        };
3121        let note = render_workspace_home(&ws);
3122
3123        // The source end of the first cross-repo link, rendered through the real
3124        // function. `from_project` is a member and `from_key` is one of its own
3125        // nodes, so this is a note the render writes rather than one the
3126        // sentence assumes.
3127        let expected = format!("{}.md", note_name("sdk::cfgkey:config.toml#addr"));
3128        assert!(
3129            note.content.contains(&expected),
3130            "the naming paragraph must show a real name ({expected}), not a \
3131             hand-written form:\n{}",
3132            note.content
3133        );
3134        // And the key form it is derived *from* is still stated, because that is
3135        // the half a reader needs to look a note up by its frontmatter.
3136        assert!(
3137            note.content.contains("`<project>::<key>`"),
3138            "{}",
3139            note.content
3140        );
3141        // No filename anywhere in the vault carries `::`.
3142        assert!(!expected.contains("::"), "{expected}");
3143    }
3144
3145    /// With no cross-repo links there is no key the renderer can prove is a
3146    /// node, so it must say nothing rather than fabricate one.
3147    ///
3148    /// The example this replaced was `<first member>::file:README.md`, invented
3149    /// from the member list — and membership does not require a README, so
3150    /// `_Home` could assert a note that was never written. That is the very
3151    /// defect this PR exists to fix, one remove away, so the empty case gets an
3152    /// assertion of its own rather than an assumption.
3153    #[test]
3154    fn the_workspace_home_claims_no_example_note_when_it_has_no_real_key() {
3155        let ws = WorkspaceSummary {
3156            generated_at: "2026-08-22T10:00:00Z".to_owned(),
3157            name: "platform".into(),
3158            members: vec![member_summary("api", 7), member_summary("sdk", 4)],
3159            pins: Vec::new(),
3160            cross_links: vec![],
3161            cross_links_total: 0,
3162            cross_links_authored: 0,
3163        };
3164        let note = render_workspace_home(&ws);
3165
3166        assert!(
3167            !note.content.contains("is the note "),
3168            "no cross-repo link means no provable key, so no `Here, X is the \
3169             note Y` claim:\n{}",
3170            note.content
3171        );
3172        // The fabricated form specifically: never emitted, with or without links.
3173        assert!(
3174            !note.content.contains("::file:README.md"),
3175            "{}",
3176            note.content
3177        );
3178        // The rule itself is still stated — only the illustration is absent.
3179        assert!(
3180            note.content.contains("`<project>::<key>`")
3181                && note.content.contains("no filename contains `::`"),
3182            "{}",
3183            note.content
3184        );
3185    }
3186
3187    #[test]
3188    fn a_member_note_declares_which_member_it_came_from() {
3189        let ms = members(&["api"]);
3190        let ex = node_linking_to("cfgkey:config.toml#addr", "addr", "sym:rust:a.rs#A");
3191        let note = render_note_scoped(
3192            &ex,
3193            None,
3194            None,
3195            &VaultScope {
3196                project: Some("api"),
3197                members: &ms,
3198            },
3199        );
3200        assert_eq!(
3201            note.filename,
3202            format!("{}.md", note_name("api::cfgkey:config.toml#addr"))
3203        );
3204        assert!(
3205            note.content.contains("project: \"api\""),
3206            "{}",
3207            note.content
3208        );
3209        assert!(
3210            note.content.contains("- roteiro/project/api"),
3211            "the tag is what filters the graph view to one repository: {}",
3212            note.content
3213        );
3214        // A within-member edge is qualified to the same member, not left bare.
3215        assert!(
3216            note.content
3217                .contains(&format!("→ [[{}]]", note_name("api::sym:rust:a.rs#A"))),
3218            "{}",
3219            note.content
3220        );
3221    }
3222
3223    #[test]
3224    fn a_project_note_declares_no_project() {
3225        let ex = node_linking_to("cfgkey:config.toml#addr", "addr", "sym:rust:a.rs#A");
3226        let note = render_note(&ex, None, None);
3227        assert!(!note.content.contains("project:"), "{}", note.content);
3228        assert!(
3229            !note.content.contains("roteiro/project/"),
3230            "a per-project vault would carry one constant on every note — and \
3231             adding it would change every note's bytes: {}",
3232            note.content
3233        );
3234    }
3235
3236    #[test]
3237    fn a_cross_repo_edge_links_straight_to_the_other_members_note() {
3238        // ADR-0009: the spoke's edge points at a *local placeholder* for the hub's
3239        // node, because store integrity needs both ends in one store. A workspace
3240        // vault holds both, so the link goes to the real note. No new edge — the
3241        // resolver already follows this placeholder at query time.
3242        let ms = members(&["spoke", "hub"]);
3243        let scope = VaultScope {
3244            project: Some("spoke"),
3245            members: &ms,
3246        };
3247        let ex = node_linking_to(
3248            "cfgkey:config.toml#addr",
3249            "addr",
3250            &rto_graph::external_ref_key("hub::cfgkey:config.toml#addr"),
3251        );
3252        let note = render_note_scoped(&ex, None, None, &scope);
3253        assert!(
3254            note.content.contains(&format!(
3255                "→ [[{}]]",
3256                note_name("hub::cfgkey:config.toml#addr")
3257            )),
3258            "the edge must land on the hub's own note: {}",
3259            note.content
3260        );
3261        assert!(
3262            !note.content.contains("extref"),
3263            "and never on the placeholder: {}",
3264            note.content
3265        );
3266        // The same rule decides that the placeholder is not written as a note, so
3267        // the two halves cannot disagree.
3268        assert!(
3269            scope.redirects_external_ref(&rto_graph::external_ref_key(
3270                "hub::cfgkey:config.toml#addr"
3271            ))
3272        );
3273    }
3274
3275    #[test]
3276    fn a_cross_repo_edge_out_of_the_workspace_keeps_its_placeholder() {
3277        // The target repo is not in this vault, so there is no note to point at.
3278        // Redirecting anyway would produce a link that resolves to nothing —
3279        // Obsidian shows that as merely unwritten, which is a worse lie than a
3280        // placeholder that honestly says "elsewhere".
3281        let ms = members(&["spoke"]);
3282        let scope = VaultScope {
3283            project: Some("spoke"),
3284            members: &ms,
3285        };
3286        let key = rto_graph::external_ref_key("elsewhere::cfgkey:config.toml#addr");
3287        assert!(!scope.redirects_external_ref(&key));
3288        let ex = node_linking_to("cfgkey:config.toml#addr", "addr", &key);
3289        let note = render_note_scoped(&ex, None, None, &scope);
3290        assert!(
3291            note.content.contains(&format!(
3292                "→ [[{}]]",
3293                note_name("spoke::extref:elsewhere::cfgkey:config.toml#addr")
3294            )),
3295            "{}",
3296            note.content
3297        );
3298    }
3299
3300    #[test]
3301    fn a_single_project_vault_never_redirects_an_external_ref() {
3302        // No members ⇒ nothing to resolve against, so today's vault keeps rendering
3303        // the placeholder exactly as it does now.
3304        let key = rto_graph::external_ref_key("hub::cfgkey:config.toml#addr");
3305        assert!(!VaultScope::PROJECT.redirects_external_ref(&key));
3306        assert_eq!(
3307            scoped_note_name(&VaultScope::PROJECT, &key),
3308            note_name(&key)
3309        );
3310    }
3311
3312    fn member_summary(project: &str, fan_in: u32) -> VaultSummary {
3313        VaultSummary {
3314            project: project.to_owned(),
3315            total_nodes: 3,
3316            total_edges: 2,
3317            node_counts: vec![("fn".into(), 2)],
3318            edge_provenance: vec![("derived".into(), 2)],
3319            adrs: vec![AdrEntry {
3320                key: "adr:0001".into(),
3321                name: "First".into(),
3322                status: Some("Accepted".into()),
3323            }],
3324            debt: vec![("todo".into(), 4)], // roteiro:ignore
3325            densest_files: vec![DensityEntry {
3326                path: "src/small.rs".into(),
3327                markers: 3,
3328                lines: 120,
3329                per_kloc: 25.0,
3330            }],
3331            config_secrets: None,
3332            most_called: vec![CouplingEntry {
3333                key: "sym:rust:a.rs#helper".into(),
3334                name: "helper".into(),
3335                fan_in,
3336                fan_out: 1,
3337            }],
3338            repo_url: Some(format!("https://github.com/org/{project}")),
3339            commit: Some("abcdef0123456789".into()),
3340            findings: vec![],
3341            coverage: Coverage::NotRun,
3342            settings: RenderedUnder::default(),
3343        }
3344    }
3345
3346    #[test]
3347    fn the_workspace_home_keeps_every_members_own_aggregates() {
3348        // The promise in issue #442: the existing per-project `_Home` view is a
3349        // *subset* of the workspace one, not a casualty of it. Someone who came for
3350        // their repository's coupling and debt tables must still find them —
3351        // not a workspace total that averages them away.
3352        let ws = WorkspaceSummary {
3353            generated_at: "2026-08-22T10:00:00Z".to_owned(),
3354            name: "platform".into(),
3355            members: vec![member_summary("api", 7), member_summary("sdk", 4)],
3356            pins: Vec::new(),
3357            cross_links: vec![],
3358            cross_links_total: 0,
3359            cross_links_authored: 0,
3360        };
3361        let note = render_workspace_home(&ws);
3362        assert_eq!(note.filename, HOME_NOTE);
3363        assert!(
3364            note.content
3365                .contains("# platform — workspace knowledge graph")
3366        );
3367        // Summed, and the members listed.
3368        assert!(
3369            note.content
3370                .contains("**6 nodes**, **4 edges** across **2** member")
3371        );
3372        assert!(note.content.contains("| [[#api\\|api]] | 3 | 2 |"));
3373
3374        for project in ["api", "sdk"] {
3375            assert!(
3376                note.content.contains(&format!("\n## {project}\n")),
3377                "each member gets its own section"
3378            );
3379        }
3380        // Today's sections, one level deeper, once per member.
3381        for section in [
3382            "### Structure",
3383            "### Provenance",
3384            "### Decisions (ADRs)",
3385            "### Intent debt",
3386            "#### Densest files",
3387            "### Most depended-on",
3388        ] {
3389            assert_eq!(
3390                note.content.matches(section).count(),
3391                2,
3392                "`{section}` must appear once per member: {}",
3393                note.content
3394            );
3395        }
3396        // And every link inside a member's section resolves within that member.
3397        assert!(note.content.contains(&format!(
3398            "**Accepted** — [[{}|First]]",
3399            note_name("api::adr:0001")
3400        )));
3401        assert!(note.content.contains(&format!(
3402            "**Accepted** — [[{}|First]]",
3403            note_name("sdk::adr:0001")
3404        )));
3405        assert!(note.content.contains(&format!(
3406            "[[{}\\|helper]] | 7 |",
3407            note_name("api::sym:rust:a.rs#helper")
3408        )));
3409        assert!(note.content.contains(&format!(
3410            "[[{}\\|src/small.rs]]",
3411            note_name("sdk::file:src/small.rs")
3412        )));
3413    }
3414
3415    #[test]
3416    fn the_workspace_home_renders_cross_repo_links_and_marks_the_ones_it_cannot_follow() {
3417        let ws = WorkspaceSummary {
3418            generated_at: "2026-08-22T10:00:00Z".to_owned(),
3419            name: "platform".into(),
3420            members: vec![member_summary("api", 7), member_summary("sdk", 4)],
3421            pins: Vec::new(),
3422            cross_links: vec![
3423                CrossLink {
3424                    from_project: "sdk".into(),
3425                    from_key: "cfgkey:config.toml#addr".into(),
3426                    from_name: "addr".into(),
3427                    kind: "links".into(),
3428                    confidence: Some(0.91),
3429                    to_qualified: "api::cfgkey:config.toml#addr".into(),
3430                    resolves: true,
3431                    authored: false,
3432                },
3433                CrossLink {
3434                    from_project: "sdk".into(),
3435                    from_key: "cfgkey:config.toml#other".into(),
3436                    from_name: "other".into(),
3437                    kind: "links".into(),
3438                    confidence: None,
3439                    to_qualified: "absent::cfgkey:config.toml#other".into(),
3440                    resolves: false,
3441                    authored: false,
3442                },
3443            ],
3444            cross_links_total: 2,
3445            cross_links_authored: 0,
3446        };
3447        let note = render_workspace_home(&ws);
3448        // Resolvable: a link to the other member's note, with its confidence.
3449        assert!(
3450            note.content.contains(&format!(
3451                "| [[{}\\|addr]] | sdk | [[{}\\|api::cfgkey:config.toml#addr]] | links (0.91) |",
3452                note_name("sdk::cfgkey:config.toml#addr"),
3453                note_name("api::cfgkey:config.toml#addr"),
3454            )),
3455            "{}",
3456            note.content
3457        );
3458        // Outside the workspace: stated as such, never as a wikilink — Obsidian
3459        // renders a link to a missing note as one that is merely unwritten.
3460        assert!(
3461            note.content
3462                .contains("`absent::cfgkey:config.toml#other` *(outside this workspace)*"),
3463            "{}",
3464            note.content
3465        );
3466        assert!(
3467            !note.content.contains("[[absent-"),
3468            "a dangling wikilink would read as a note someone forgot to write: {}",
3469            note.content
3470        );
3471    }
3472
3473    #[test]
3474    fn the_workspace_home_says_when_it_has_truncated_the_cross_links() {
3475        // A capped table that does not say it is capped reads as the whole set.
3476        let ws = WorkspaceSummary {
3477            generated_at: "2026-08-22T10:00:00Z".to_owned(),
3478            name: "platform".into(),
3479            members: vec![member_summary("api", 7)],
3480            pins: Vec::new(),
3481            cross_links: vec![CrossLink {
3482                from_project: "api".into(),
3483                from_key: "cfgkey:config.toml#addr".into(),
3484                from_name: "addr".into(),
3485                kind: "links".into(),
3486                confidence: None,
3487                to_qualified: "api::cfgkey:config.toml#addr".into(),
3488                resolves: true,
3489                authored: false,
3490            }],
3491            cross_links_total: 40,
3492            cross_links_authored: 0,
3493        };
3494        let note = render_workspace_home(&ws);
3495        assert!(note.content.contains("Showing 1 of 40"), "{}", note.content);
3496        assert!(note.content.contains("roteiro links --matrix"));
3497    }
3498
3499    #[test]
3500    fn a_workspace_with_no_cross_repo_links_says_why_rather_than_showing_nothing() {
3501        let ws = WorkspaceSummary {
3502            generated_at: "2026-08-22T10:00:00Z".to_owned(),
3503            name: "platform".into(),
3504            members: vec![member_summary("api", 7)],
3505            pins: Vec::new(),
3506            cross_links: vec![],
3507            cross_links_total: 0,
3508            cross_links_authored: 0,
3509        };
3510        let note = render_workspace_home(&ws);
3511        assert!(note.content.contains("## Cross-repo links"));
3512        assert!(
3513            note.content.contains("links --infer --write"),
3514            "an empty section must name what would fill it, or it reads as \
3515             \"these repos are unrelated\": {}",
3516            note.content
3517        );
3518        // Singular, because getting this wrong on a one-member workspace is the
3519        // kind of thing nobody notices until it ships.
3520        assert!(note.content.contains("**1** member repository."));
3521    }
3522
3523    // ---- YAML frontmatter escaping -------------------------------------------
3524
3525    /// Parse a note's frontmatter block with a **real** YAML parser and return
3526    /// `field`'s value, or the parse error.
3527    ///
3528    /// Every assertion below goes through this rather than checking the emitted
3529    /// bytes. An escaper that is wrong in a self-consistent way passes a
3530    /// byte-comparison — that is precisely how `"foo\bar"` survived: it looks
3531    /// exactly like what was asked for, and means something else.
3532    fn frontmatter_field(note: &str, field: &str) -> Result<Option<String>, String> {
3533        let block = note
3534            .strip_prefix("---\n")
3535            .and_then(|rest| rest.split_once("\n---\n"))
3536            .map(|(block, _)| block)
3537            .expect("note must open with a frontmatter block");
3538        let docs = yaml_rust2::YamlLoader::load_from_str(block).map_err(|e| e.to_string())?;
3539        Ok(docs[0][field].as_str().map(ToOwned::to_owned))
3540    }
3541
3542    /// A node whose key, path and language are whatever the test needs.
3543    fn node_with(key: &str, path: Option<&str>, lang: Option<&str>) -> Explanation {
3544        Explanation {
3545            schema: rto_graph::SCHEMA,
3546            node: NodeSummary {
3547                key: key.into(),
3548                kind: "fn".into(),
3549                name: "n".into(),
3550                path: path.map(ToOwned::to_owned),
3551                lang: lang.map(ToOwned::to_owned),
3552            },
3553            meta: serde_json::Value::Null,
3554            outgoing: vec![],
3555            incoming: vec![],
3556        }
3557    }
3558
3559    /// The three measured failure modes of the escaping this replaced, each
3560    /// asserted on the **parsed** value.
3561    ///
3562    /// Before the fix: `foo\bar` parsed back as `foo<BS>ar` (silently six
3563    /// characters, not seven), and the other two made the whole block
3564    /// unparseable — which in Obsidian costs the note *every* property, with no
3565    /// error shown.
3566    #[test]
3567    fn a_backslash_or_quote_in_a_path_still_parses_back_to_itself() {
3568        for path in [
3569            r"foo\bar",     // `\b` was YAML's backspace escape: silent corruption
3570            r"foo\dir",     // `\d` is not a YAML escape at all: parse error
3571            "say\"hi\".rs", // an unescaped `"` ended the scalar early: parse error
3572            r"a\\b",
3573            "trailing-backslash\\",
3574        ] {
3575            let note = render_note(&node_with("file:x", Some(path), None), None, None);
3576            assert_eq!(
3577                frontmatter_field(&note.content, "path"),
3578                Ok(Some(path.to_owned())),
3579                "path {path:?} must round-trip"
3580            );
3581        }
3582    }
3583
3584    /// `key:` is not hypothetical for this: node keys already carry `:` and `#`,
3585    /// and a symbol name can contain a quotation mark.
3586    #[test]
3587    fn a_node_key_round_trips_whatever_punctuation_it_carries() {
3588        for key in [
3589            "sym:rust:src/a.rs#Store",
3590            r"sym:rust:src\weird.rs#Thing",
3591            "sym:rust:a.rs#say\"hi\"",
3592            "cfgkey:config.toml#serve.addr",
3593        ] {
3594            let note = render_note(&node_with(key, None, None), None, None);
3595            assert_eq!(
3596                frontmatter_field(&note.content, "key"),
3597                Ok(Some(key.to_owned())),
3598                "key {key:?} must round-trip"
3599            );
3600        }
3601        // The old rule turned a `"` into an apostrophe, so the note reported a key
3602        // that was not the node's key — parseable, and wrong.
3603        let note = render_note(
3604            &node_with("sym:rust:a.rs#say\"hi\"", None, None),
3605            None,
3606            None,
3607        );
3608        assert!(
3609            !note.content.contains("say'hi'"),
3610            "a quotation mark must be escaped, not rewritten: {}",
3611            note.content
3612        );
3613    }
3614
3615    /// A member directory name is a path component, so it reaches the same rule.
3616    #[test]
3617    fn a_member_project_name_round_trips() {
3618        let ms: std::collections::BTreeSet<String> =
3619            std::iter::once(r"odd\name".to_owned()).collect();
3620        let note = render_note_scoped(
3621            &node_with("file:x", None, None),
3622            None,
3623            None,
3624            &VaultScope {
3625                project: Some(r"odd\name"),
3626                members: &ms,
3627            },
3628        );
3629        assert_eq!(
3630            frontmatter_field(&note.content, "project"),
3631            Ok(Some(r"odd\name".to_owned()))
3632        );
3633    }
3634
3635    /// The **bare** fields are the other half of the same class, and were missed
3636    /// by the review that found the quoted ones: `status` is written unquoted, and
3637    /// `roteiro load` installs a caller-supplied artifact whose nodes carry
3638    /// whatever they carry.
3639    #[test]
3640    fn a_bare_field_is_quoted_only_when_being_bare_would_change_it() {
3641        let with_status = |status: &str| {
3642            let mut ex = node_with("adr:0001", None, None);
3643            ex.meta = serde_json::json!({ "status": status });
3644            render_note(&ex, None, None)
3645        };
3646
3647        // Would be a parse error bare; would silently truncate bare.
3648        for status in [
3649            "Accepted: superseded by 0012",
3650            "Accepted # pending",
3651            "{draft}",
3652            "",
3653        ] {
3654            let note = with_status(status);
3655            assert_eq!(
3656                frontmatter_field(&note.content, "status"),
3657                Ok(Some(status.to_owned())),
3658                "status {status:?} must round-trip"
3659            );
3660        }
3661
3662        // …and a safe one stays bare, which is what keeps an existing vault's
3663        // bytes unchanged.
3664        let note = with_status("Accepted");
3665        assert!(
3666            note.content.contains("\nstatus: Accepted\n"),
3667            "a plain-safe status must not gain quotes: {}",
3668            note.content
3669        );
3670    }
3671
3672    /// `no` is Norwegian, and a bare `no` reads as `false` to a YAML **1.1**
3673    /// parser.
3674    ///
3675    /// The only assertion here that pins emitted bytes, and deliberately so:
3676    /// `yaml-rust2` implements YAML 1.2, whose core schema resolves a bare `no`
3677    /// to the *string* `no`, so a round-trip through this test's own oracle
3678    /// cannot see the problem — it passes either way. The exposure is to the
3679    /// parser on the other side, and Obsidian's is not this one. Quoting costs
3680    /// two characters on a value that never occurs here; guessing which YAML
3681    /// version every downstream reader implements does not seem like the better
3682    /// bet.
3683    #[test]
3684    fn a_language_that_spells_a_yaml_boolean_is_quoted() {
3685        let note = render_note(&node_with("file:x", None, Some("no")), None, None);
3686        assert!(
3687            note.content.contains("\nlang: \"no\"\n"),
3688            "a bare `no` is `false` to a 1.1 parser and must be quoted: {}",
3689            note.content
3690        );
3691        assert_eq!(
3692            frontmatter_field(&note.content, "lang"),
3693            Ok(Some("no".to_owned())),
3694            "and it must still read back as the string: {}",
3695            note.content
3696        );
3697        // And an ordinary language is untouched.
3698        let rust = render_note(&node_with("file:x", None, Some("rust")), None, None);
3699        assert!(rust.content.contains("\nlang: rust\n"), "{}", rust.content);
3700    }
3701
3702    /// Control characters and the separators some parsers fold as line breaks.
3703    #[test]
3704    fn control_characters_cannot_break_out_of_the_block() {
3705        for path in [
3706            "a\nb",
3707            "a\tb",
3708            "a\u{0}b",
3709            "a\u{2028}b",
3710            "a\u{7f}b",
3711            "a\u{85}b",
3712        ] {
3713            let note = render_note(&node_with("file:x", Some(path), None), None, None);
3714            assert_eq!(
3715                frontmatter_field(&note.content, "path"),
3716                Ok(Some(path.to_owned())),
3717                "path {path:?} must round-trip"
3718            );
3719            // A raw newline would end the scalar and inject a sibling key.
3720            assert_eq!(
3721                note.content.matches("\npath: ").count(),
3722                1,
3723                "the value must stay on one line: {}",
3724                note.content
3725            );
3726        }
3727    }
3728
3729    /// The escaping is *only* an escaping: for a value with nothing to escape it
3730    /// must emit the same bytes it always did, or #442's promise that a
3731    /// single-project vault is byte-identical does not hold.
3732    #[test]
3733    fn an_ordinary_value_is_emitted_exactly_as_before() {
3734        let note = render_note(
3735            &node_with("sym:rust:src/a.rs#Store", Some("src/a.rs"), Some("rust")),
3736            None,
3737            None,
3738        );
3739        assert!(
3740            note.content
3741                .contains("\nkey: \"sym:rust:src/a.rs#Store\"\n")
3742        );
3743        assert!(note.content.contains("\nkind: fn\n"));
3744        assert!(note.content.contains("\npath: \"src/a.rs\"\n"));
3745        assert!(note.content.contains("\nlang: rust\n"));
3746    }
3747
3748    /// The plain-style decision is checked against a real parser rather than
3749    /// against itself: whatever `is_plain_safe` accepts must actually round-trip
3750    /// bare, and whatever it rejects must round-trip quoted.
3751    #[test]
3752    fn the_plain_style_decision_agrees_with_a_real_yaml_parser() {
3753        for value in [
3754            "fn",
3755            "config_key",
3756            "rust",
3757            "Accepted",
3758            "a.b",
3759            "a/b",
3760            "a-b_c",
3761            "no",
3762            "yes",
3763            "true",
3764            "null",
3765            "y",
3766            "N",
3767            "",
3768            " lead",
3769            "trail ",
3770            "a: b",
3771            "a #c",
3772            "{x}",
3773            "[x]",
3774            "*x",
3775            "&x",
3776            "!x",
3777            "#x",
3778            ">x",
3779            "|x",
3780            "%x",
3781            "@x",
3782            "`x",
3783            "\"x",
3784            "'x",
3785            ",x",
3786            "123",
3787            "1.5",
3788            "-x",
3789            ".x",
3790            "a\\b",
3791        ] {
3792            let emitted = super::yaml_scalar(value);
3793            let doc = format!("v: {emitted}");
3794            let parsed = yaml_rust2::YamlLoader::load_from_str(&doc)
3795                .unwrap_or_else(|e| panic!("{value:?} emitted {emitted:?}: {e}"));
3796            assert_eq!(
3797                parsed[0]["v"].as_str(),
3798                Some(value),
3799                "{value:?} emitted as {emitted:?} did not round-trip"
3800            );
3801        }
3802    }
3803}