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/// The name is always `<hint>-<16 hex digits>`: a lowercased, readable *hint*
43/// slugged from the key, then an unconditional 64-bit FNV-1a hash of the whole,
44/// exact key. Characters outside `[a-z0-9._-]` collapse to a single `-` in the
45/// hint; the hash carries everything the hint threw away.
46///
47/// # Why the hash is unconditional (issue #574)
48///
49/// It used to be applied only when the slug overran the filename limit, and the
50/// slug alone was lossy twice over. Measured on this repository — 8,239 nodes
51/// rendering to 8,135 notes, 104 of them silently overwritten:
52///
53/// | mechanism | lost | where |
54/// | --- | --- | --- |
55/// | 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 |
56/// | macOS and Windows fold filename case, so `…#A` and `…#a` are two *names* but one *file* | 95 | macOS, Windows |
57///
58/// The second mechanism is the trap. A lossless-but-case-sensitive encoding
59/// fixes the 9, verifies clean on Linux CI, and still loses 95 notes on a Mac.
60/// So the requirement is stated after folding:
61///
62/// ```text
63/// lower(note_name(k1)) == lower(note_name(k2))  implies  k1 == k2
64/// ```
65///
66/// This matters more than lossiness in a cache would, because the note names are
67/// the vault's **only** stable interface: `reset_vault_dir` deletes and rebuilds
68/// the whole directory on every render, so the one thing that survives a render
69/// is a user's own note *outside* the vault linking in by name (issue #442).
70///
71/// # The trade taken
72///
73/// Two decisions, and what each bought:
74///
75/// **The hint is lowercased rather than case-preserved.** Case-preserving would
76/// also satisfy the requirement — the hash differs for `#A` and `#a`, so the two
77/// names differ in their suffix and stay distinct under folding. It was rejected
78/// because lowercasing makes `note_name(k) == note_name(k).to_lowercase()` an
79/// invariant of the function, and *that* collapses the folded property into the
80/// literal one: there is then no way to write a version of this that is green on
81/// Linux and lossy on macOS, which is the defect shape this repository keeps
82/// finding. The cost is that `parseHTTPHeader` reads as `parsehttpheader`. That
83/// is affordable precisely because the hint is a hint — once a 17-character
84/// suffix is mandatory the name is not something anyone types from memory, so
85/// its job is to be recognisable in a file list, not to be transcribed.
86///
87/// **Readability was spent, deliberately.** Every name grows by 17 characters and
88/// hand-writing a link now needs Obsidian's autocomplete. The alternatives that
89/// keep names short — hashing only the keys observed to collide — make the *set*
90/// of collisions platform-dependent, so one key would get one filename on macOS
91/// and another on Linux and a synced vault would churn. A name that is uglier
92/// everywhere beats a name that is different per platform.
93///
94/// The mapping is not reversible (the hint is lossy and the hash is one-way), but
95/// it does not need to be: every note's frontmatter carries `key:` verbatim, so
96/// name → key is recoverable from the vault itself, which is the direction a
97/// reader actually needs.
98///
99/// # What "unique" rests on
100///
101/// Equal names imply equal hashes, not equal keys — this is a 64-bit hash, not a
102/// proof. Over this repository's 8,239 keys there is no collision, and the
103/// birthday bound at that size is about 2e-12. Should one ever occur it is
104/// *reported*, not silent: `NoteNames` in the render path claims every filename
105/// case-insensitively and warns on a repeat. What is proved outright is the
106/// folding half — the output is lowercase by construction, so case folding is the
107/// identity on it.
108#[must_use]
109pub fn note_name(key: &str) -> String {
110    // Keep the whole stem well under the 255-byte filename limit (leaving room
111    // for ".md"). The hint is ASCII, so byte length equals char count and slicing
112    // is safe.
113    const MAX: usize = 200;
114    // '-' plus the 16 hex digits of the hash.
115    const SUFFIX: usize = 17;
116    const HINT: usize = MAX - SUFFIX;
117
118    let mut hint = String::with_capacity(key.len());
119    let mut prev_dash = false;
120    for c in key.chars() {
121        if c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-') {
122            hint.push(c.to_ascii_lowercase());
123            prev_dash = false;
124        } else if !prev_dash {
125            hint.push('-');
126            prev_dash = true;
127        }
128    }
129    let hint = hint.trim_matches('-');
130    // Truncation is only ever cosmetic now: the hash, not the hint, is what keeps
131    // a 300-character grouped `use` distinct from its neighbour.
132    let hint = hint[..hint.len().min(HINT)].trim_end_matches('-');
133    let hash = fnv1a64(key.as_bytes());
134    if hint.is_empty() {
135        // A key of nothing but separators. Bare hex, and it cannot be confused
136        // with a hinted name: those are `<hint>-<16 hex>`, so at least 18
137        // characters, and this is exactly 16 with no `-` in it.
138        format!("{hash:016x}")
139    } else {
140        format!("{hint}-{hash:016x}")
141    }
142}
143
144/// FNV-1a (64-bit) — a dependency-free, deterministic hash carrying everything
145/// [`note_name`]'s hint discards. No cryptographic properties needed: nothing
146/// here defends against a chosen collision, only against an accidental one.
147///
148/// 64 bits rather than fewer because the cost of a collision is exactly the
149/// defect this suffix exists to fix — a note silently overwritten. At 8k keys a
150/// 32-bit hash collides about 0.8% of the time and a 48-bit one about 1e-5;
151/// 64 bits is 2e-12, and stays under 1e-10 for a workspace vault an order of
152/// magnitude larger.
153fn fnv1a64(bytes: &[u8]) -> u64 {
154    let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
155    for &b in bytes {
156        hash ^= u64::from(b);
157        hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
158    }
159    hash
160}
161
162/// Emit `value` as a YAML **double-quoted** scalar, `"`-delimited and escaped so
163/// it parses back to exactly `value`.
164///
165/// The one escaping rule for this module's frontmatter. It exists because the
166/// three hand-rolled variants it replaced disagreed with each other — `key:` and
167/// `project:` turned a `"` into an apostrophe, and `path:` escaped nothing — and
168/// two of the three could emit YAML that does not mean what it says:
169///
170/// | value | was emitted | parsed back as |
171/// | --- | --- | --- |
172/// | `foo\bar` | `"foo\bar"` | `foo<BS>ar` — `\b` is YAML's **backspace** escape |
173/// | `foo\dir` | `"foo\dir"` | *parse error* — `\d` is not a YAML escape |
174/// | `say"hi".rs` | `"say"hi".rs"` | *parse error* — the scalar ends at the `"` |
175///
176/// The first is the dangerous one: seven characters silently become six, and
177/// nothing anywhere reports it. The other two cost the reader every property on
178/// the note, because Obsidian parses this block as the note's properties and a
179/// block that does not parse yields no properties at all rather than an error.
180///
181/// All three inputs are legal path components on Linux and macOS. None occurs in
182/// this repository today, so this is a latent defect rather than an observed one.
183///
184/// Escapes, per YAML 1.2 §7.3.1: the two structural characters `\` and `"`, then
185/// anything a parser is not obliged to accept literally — C0 controls, `DEL`, the
186/// C1 range, and the three separators (`U+2028`, `U+2029`, `U+FEFF`) that some
187/// parsers treat as line breaks. Short escapes where YAML defines one, so the
188/// common cases stay readable, and `\uXXXX` otherwise.
189fn yaml_double_quoted(value: &str) -> String {
190    let mut out = String::with_capacity(value.len() + 2);
191    out.push('"');
192    for ch in value.chars() {
193        match ch {
194            '\\' => out.push_str(r"\\"),
195            '"' => out.push_str("\\\""),
196            '\n' => out.push_str(r"\n"),
197            '\r' => out.push_str(r"\r"),
198            '\t' => out.push_str(r"\t"),
199            '\u{0}' => out.push_str(r"\0"),
200            '\u{7}' => out.push_str(r"\a"),
201            '\u{8}' => out.push_str(r"\b"),
202            '\u{b}' => out.push_str(r"\v"),
203            '\u{c}' => out.push_str(r"\f"),
204            '\u{1b}' => out.push_str(r"\e"),
205            // Everything else a YAML parser may reject or fold: the rest of C0,
206            // DEL, the C1 range, and the separators that can read as line breaks.
207            c if (c < ' ')
208                || c == '\u{7f}'
209                || ('\u{80}'..='\u{9f}').contains(&c)
210                || matches!(c, '\u{2028}' | '\u{2029}' | '\u{feff}') =>
211            {
212                let _ = write!(out, "\\u{:04x}", c as u32);
213            }
214            c => out.push(c),
215        }
216    }
217    out.push('"');
218    out
219}
220
221/// Emit `value` in YAML **plain** (unquoted) style when that round-trips, and as
222/// [`yaml_double_quoted`] when it would not.
223///
224/// For the frontmatter fields that are written bare today — `kind`, `lang`,
225/// `status`. Those are constrained by *today's* producers (an ADR's status is
226/// validated against the house states; kinds and languages come from extraction),
227/// but `roteiro load` installs a caller-supplied graph artifact whose nodes carry
228/// whatever JSON they carry, so "the producer is careful" is not a property this
229/// renderer can rely on. A `status:` of `Accepted: superseded by 0012` emitted
230/// bare is a parse error, and `Accepted # pending` silently truncates to
231/// `Accepted`.
232///
233/// Escalating only when needed is what keeps the bytes of an existing vault
234/// unchanged — every `kind`, `lang` and `status` in this repository is plain-safe
235/// and stays bare. [`is_plain_safe`] is deliberately stricter than YAML's plain
236/// grammar for the same reason it is safe: a value it rejects is merely quoted.
237fn yaml_scalar(value: &str) -> String {
238    if is_plain_safe(value) {
239        value.to_owned()
240    } else {
241        yaml_double_quoted(value)
242    }
243}
244
245/// Whether `value` can be written as a bare YAML scalar and read back unchanged.
246///
247/// A conservative allowlist rather than YAML's actual plain-scalar grammar, which
248/// is subtle enough (indicator characters, `: ` and ` #` only in some positions,
249/// leading and trailing space, implicit typing) that implementing it is how the
250/// bug this replaces gets written a second time. Getting this wrong in the
251/// strict direction costs a pair of quotation marks; getting it wrong in the
252/// permissive direction costs the note's properties.
253///
254/// So: a leading ASCII letter, then letters, digits, `_`, `-`, `.` and `/` — which
255/// covers every kind, language and status this renderer emits — and never a word
256/// YAML resolves to a boolean or null. That last exclusion is not hypothetical:
257/// `no` is the ISO 639-1 code for Norwegian, and YAML 1.1 parsers read a bare `no`
258/// as `false`.
259fn is_plain_safe(value: &str) -> bool {
260    const NOT_STRINGS: [&str; 11] = [
261        "true", "false", "yes", "no", "on", "off", "null", "nil", "none", "y", "n",
262    ];
263    !value.is_empty()
264        && value.starts_with(|c: char| c.is_ascii_alphabetic())
265        && value
266            .chars()
267            .all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.' | '/'))
268        && !NOT_STRINGS.contains(&value.to_ascii_lowercase().as_str())
269}
270
271/// Which vault a note is being rendered into: a single project's, or one member
272/// of a **workspace** vault spanning several repositories.
273///
274/// This is the whole of the workspace-vault naming rule, in one place. Node keys
275/// are **repository-relative** (`file:README.md` names no repo), so every member
276/// of a workspace produces the same note name for its `README.md` and one would
277/// silently overwrite the rest. Qualifying the key with its project fixes that.
278///
279/// [`VaultScope::PROJECT`] (`project: None`) is not a degenerate case but the
280/// contract: it makes every name in this module reduce to exactly [`note_name`]
281/// of the bare key, with nothing qualified and no `project:` frontmatter.
282///
283/// That reduction is *still* the promise; what it no longer implies is stability
284/// against `main`. #570 could say "a single-project vault's names do not move",
285/// because the only thing moving them would have been workspace qualification.
286/// #574 moves them all, on purpose: the old names were not injective under
287/// filename case folding and the vault lost 104 notes to that. The promise here
288/// was always about **this axis** — turning workspace mode on must not rename a
289/// project's notes — and it holds unchanged. See [`note_name`] for the rename and
290/// what it bought.
291#[derive(Debug, Clone, Copy)]
292pub struct VaultScope<'a> {
293    /// The member project this note belongs to, qualifying its name as
294    /// `<project>::<key>` — the same form ADR-0009's cross-repo links already use.
295    /// `None` ⇒ a single-project vault, and names are unqualified exactly as
296    /// before.
297    pub project: Option<&'a str>,
298    /// The workspace's member project names. An external-ref placeholder whose
299    /// target names one of these is a cross-repo edge the vault can actually
300    /// follow, so it is rendered as a link straight to that member's note. Empty
301    /// for a single-project vault.
302    pub members: &'a std::collections::BTreeSet<String>,
303}
304
305/// The empty member set backing [`VaultScope::PROJECT`] — a single-project vault
306/// has no other members to resolve a cross-repo reference against.
307static NO_MEMBERS: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
308
309impl VaultScope<'_> {
310    /// A single-project vault: names are unqualified, and no cross-repo reference
311    /// resolves. Every name this produces is byte-identical to [`note_name`] of
312    /// the bare key — see the type's documentation for why that reduction is
313    /// load-bearing, and for what it does *not* promise.
314    pub const PROJECT: Self = Self {
315        project: None,
316        members: &NO_MEMBERS,
317    };
318}
319
320impl Default for VaultScope<'_> {
321    fn default() -> Self {
322        Self::PROJECT
323    }
324}
325
326impl VaultScope<'_> {
327    /// Whether an external-ref placeholder `key` is one this vault resolves for
328    /// itself — its target names a member, so every edge to it points at the real
329    /// note and the placeholder need not be rendered at all.
330    ///
331    /// The single rule behind both halves of that: [`link_target`] redirects
332    /// exactly the keys this accepts, and the caller skips writing exactly the
333    /// notes this accepts. They cannot disagree.
334    #[must_use]
335    pub fn redirects_external_ref(&self, key: &str) -> bool {
336        key.strip_prefix("extref:")
337            .and_then(rto_graph::parse_qualified)
338            .is_some_and(|(project, _)| self.members.contains(project))
339    }
340}
341
342/// The note name for a node `key` owned by `scope`'s project.
343///
344/// In a single-project vault (`scope.project == None`) this *is* [`note_name`].
345/// In a workspace vault it is [`note_name`] of the project-qualified key
346/// `<project>::<key>` — reusing ADR-0009's qualified form rather than inventing a
347/// second one, which is what lets a cross-repo external-ref target (already
348/// stored qualified) map to its note by the very same call.
349#[must_use]
350pub fn scoped_note_name(scope: &VaultScope<'_>, key: &str) -> String {
351    match scope.project {
352        None => note_name(key),
353        Some(project) => note_name(&format!("{project}::{key}")),
354    }
355}
356
357/// The note an edge pointing at `key` should link to.
358///
359/// Almost always [`scoped_note_name`]. The exception is the one cross-repo edge
360/// the graph already models: a spoke's inferred link to a hub is stored as an
361/// edge to a **local external-ref placeholder** (`extref:<project>::<key>`,
362/// [`rto_graph::external_ref_key`]) because store integrity requires both ends of
363/// an edge in one store. A workspace vault holds both repos' notes, so when the
364/// placeholder's target names a member the link is pointed at the **real** note
365/// instead of the stand-in.
366///
367/// This invents no edge. It renders the edge that is there, following the
368/// placeholder exactly as [`rto_graph::Workspace::follow_external_ref`] does at
369/// query time — the cross-repo graph has only ever been *rendered* one repo at a
370/// time.
371fn link_target(scope: &VaultScope<'_>, key: &str) -> String {
372    if scope.redirects_external_ref(key) {
373        // `note_name(qualified)` is by construction the same string
374        // `scoped_note_name` produces for that member's own copy of the node.
375        // `strip_prefix`, not `trim_start_matches`: the latter strips the prefix
376        // repeatedly, which would mangle a target that legitimately starts with it.
377        return note_name(key.strip_prefix("extref:").unwrap_or(key));
378    }
379    scoped_note_name(scope, key)
380}
381
382/// Render a node's [`Explanation`] into an Obsidian note: YAML frontmatter (with
383/// `tags` for the graph view and an ADR's `status`), a clickable **Source** link
384/// (when `source_base` — a web "blob" base like
385/// `https://github.com/org/repo/blob/<sha>` — is known and the node has a path),
386/// the content as the knowledge base, and its edges as provenance-labelled
387/// wikilinks.
388///
389/// `body` is the node's **full source text**, which only the caller can fetch:
390/// this function is a pure function of the `Explanation`, and an `Explanation`
391/// carries no repository, store or blob. When it is `Some`, it replaces
392/// `meta.content` in the note's `## Content` section — see [`note_body`] for why
393/// replacing is the only correct combination of the two.
394#[must_use]
395pub fn render_note(ex: &Explanation, source_base: Option<&str>, body: Option<&str>) -> VaultNote {
396    render_note_scoped(ex, source_base, body, &VaultScope::PROJECT)
397}
398
399/// [`render_note`], for one member of a **workspace** vault: identical except
400/// that the note's own name and every link it emits are resolved through `scope`
401/// (see [`VaultScope`]).
402///
403/// With [`VaultScope::PROJECT`] this is [`render_note`] byte for byte, which is
404/// how the single-project vault's compatibility promise is kept by construction
405/// rather than by a parallel code path that has to be kept in step.
406#[must_use]
407pub fn render_note_scoped(
408    ex: &Explanation,
409    source_base: Option<&str>,
410    body: Option<&str>,
411    scope: &VaultScope<'_>,
412) -> VaultNote {
413    let meta = &ex.meta;
414    let status = meta.get("status").and_then(|v| v.as_str());
415    let content = note_body(meta.get("content").and_then(|v| v.as_str()), body);
416
417    let mut c = String::new();
418    c.push_str("---\n");
419    let _ = writeln!(c, "key: {}", yaml_double_quoted(&ex.node.key));
420    let _ = writeln!(c, "kind: {}", yaml_scalar(ex.node.kind.as_str()));
421    // Which member this note came from. Absent in a single-project vault, where
422    // it would be one constant repeated on every note — and where adding it would
423    // change every note's bytes.
424    if let Some(project) = scope.project {
425        let _ = writeln!(c, "project: {}", yaml_double_quoted(project));
426    }
427    if let Some(path) = &ex.node.path {
428        let _ = writeln!(c, "path: {}", yaml_double_quoted(path));
429    }
430    if let Some(lang) = &ex.node.lang {
431        let _ = writeln!(c, "lang: {}", yaml_scalar(lang));
432    }
433    if let Some(status) = status {
434        let _ = writeln!(c, "status: {}", yaml_scalar(status));
435    }
436    // Nested tags group in Obsidian's tag pane and colour the graph view.
437    c.push_str("tags:\n");
438    let _ = writeln!(c, "  - roteiro/kind/{}", tag_slug(&ex.node.kind));
439    // Colours the graph view by member, which is the one thing a workspace vault
440    // is for and a per-project vault has no use for.
441    if let Some(project) = scope.project {
442        let _ = writeln!(c, "  - roteiro/project/{}", tag_slug(project));
443    }
444    if let Some(lang) = &ex.node.lang {
445        let _ = writeln!(c, "  - roteiro/lang/{}", tag_slug(lang));
446    }
447    if let Some(status) = status {
448        let _ = writeln!(c, "  - roteiro/status/{}", tag_slug(status));
449    }
450    c.push_str("---\n\n");
451
452    let _ = writeln!(c, "# {}", ex.node.name);
453    if let Some(status) = status {
454        let _ = writeln!(c, "\n> **Status:** {status}");
455    }
456
457    // A clickable link to the file this node comes from. An absolute URL, so it
458    // works from the downloaded vault too (which has no repo files beside it).
459    if let (Some(base), Some(path)) = (source_base, ex.node.path.as_deref()) {
460        let _ = writeln!(
461            c,
462            "\n**Source:** [`{path}`]({}/{path})",
463            base.trim_end_matches('/')
464        );
465    }
466
467    // The knowledge base: the full source text, or the captured doc comment /
468    // prose / PDF / image text.
469    if let Some(content) = content.map(str::trim).filter(|s| !s.is_empty()) {
470        c.push_str("\n## Content\n\n");
471        c.push_str(content);
472        c.push('\n');
473    }
474
475    if !ex.outgoing.is_empty() {
476        c.push_str("\n## Outgoing\n\n");
477        for e in &ex.outgoing {
478            let _ = writeln!(
479                c,
480                "- {} ({}){} → [[{}]]",
481                e.kind,
482                e.provenance,
483                confidence(e.confidence),
484                link_target(scope, &e.node)
485            );
486        }
487    }
488    if !ex.incoming.is_empty() {
489        c.push_str("\n## Incoming\n\n");
490        for e in &ex.incoming {
491            let _ = writeln!(
492                c,
493                "- [[{}]] {} ({}){} →",
494                link_target(scope, &e.node),
495                e.kind,
496                e.provenance,
497                confidence(e.confidence)
498            );
499        }
500    }
501
502    VaultNote {
503        filename: format!("{}.md", scoped_note_name(scope, &ex.node.key)),
504        content: c,
505    }
506}
507
508/// Choose the text a note shows: the caller's full `body` when it has one, else
509/// the node's stored `content`.
510///
511/// The two are **not** complementary, they are the same text at two fidelities,
512/// so a note shows one of them and never both. `meta.content` is an embedding
513/// budget — extraction caps it (1500 chars) and collapses every whitespace run to
514/// a single space, which is right for a store that ships with the graph and wrong
515/// for a note: a 23 KB document arrives as one 1500-character line with every
516/// heading, table and code fence flattened into it. Where the caller can supply
517/// the source, that is what a reader wants; appending the capped rendering
518/// underneath it would only restate its first 6% badly.
519fn note_body<'a>(content: Option<&'a str>, body: Option<&'a str>) -> Option<&'a str> {
520    body.or(content)
521}
522
523/// `" (0.82)"` for an inferred edge's confidence, else empty.
524fn confidence(c: Option<f64>) -> String {
525    c.map_or_else(String::new, |c| format!(" ({c:.2})"))
526}
527
528/// A tag-safe slug: lowercase, non-alphanumeric runs → `-`. Keeps Obsidian tags
529/// (`roteiro/kind/adr-section`) valid and stable.
530fn tag_slug(s: &str) -> String {
531    let mut out = String::with_capacity(s.len());
532    let mut prev_dash = false;
533    for ch in s.chars() {
534        if ch.is_ascii_alphanumeric() {
535            out.push(ch.to_ascii_lowercase());
536            prev_dash = false;
537        } else if !prev_dash {
538            out.push('-');
539            prev_dash = true;
540        }
541    }
542    out.trim_matches('-').to_owned()
543}
544
545/// One ADR in the overview, with its lifecycle status.
546#[derive(Debug, Clone)]
547pub struct AdrEntry {
548    /// The ADR node key (`adr:<id>`).
549    pub key: String,
550    /// The ADR title.
551    pub name: String,
552    /// Lifecycle status (`Accepted`, …), if recorded.
553    pub status: Option<String>,
554}
555
556/// The `_Home` overview's config-secret inventory figures.
557///
558/// Counts and file paths only — deliberately not the key names, which belong in
559/// `roteiro config-secrets` where the caveat can be stated at length. A vault note
560/// is read casually and out of context, which is exactly the wrong place for a
561/// list that looks like a secret scan's output.
562#[derive(Debug, Clone, Default)]
563pub struct ConfigSecretSummary {
564    /// Config keys whose **name** matched the secret-name heuristic.
565    pub secret_named: usize,
566    /// Of those, how many had their value redacted before persistence.
567    pub redacted: usize,
568    /// Of those, how many are declared in code with no literal value.
569    pub declared: usize,
570    /// Of those, how many carry an unredacted value. Expected to be zero.
571    pub unredacted: usize,
572    /// Distinct files carrying at least one secret-named key, ordered and capped
573    /// by the caller.
574    pub files: Vec<String>,
575}
576
577/// One file in the `_Home` overview's intent-debt density table.
578#[derive(Debug, Clone)]
579pub struct DensityEntry {
580    /// Repository-relative path, used for both the wikilink and the label.
581    pub path: String,
582    /// Retained markers in the file.
583    pub markers: u32,
584    /// The file's length in lines — the denominator.
585    pub lines: u32,
586    /// Markers per 1,000 lines.
587    pub per_kloc: f64,
588}
589
590/// One node in the `_Home` overview's directed-coupling table.
591#[derive(Debug, Clone)]
592pub struct CouplingEntry {
593    /// The node key, for the wikilink.
594    pub key: String,
595    /// The symbol name.
596    pub name: String,
597    /// Distinct callers.
598    pub fan_in: u32,
599    /// Distinct callees.
600    pub fan_out: u32,
601}
602
603/// Aggregate figures for the vault's `_Home` overview note.
604#[derive(Debug, Clone, Default)]
605pub struct VaultSummary {
606    /// Name of the scanned project (repository directory).
607    pub project: String,
608    /// Total node and edge counts.
609    pub total_nodes: usize,
610    /// Total edge count.
611    pub total_edges: usize,
612    /// `(kind, count)` for each node kind, most-frequent first.
613    pub node_counts: Vec<(String, usize)>,
614    /// `(provenance, edge count)` — `derived` / `authored` / `inferred`.
615    pub edge_provenance: Vec<(String, usize)>,
616    /// The ADRs, with status.
617    pub adrs: Vec<AdrEntry>,
618    /// `(category, count)` of intent-debt markers.
619    pub debt: Vec<(String, usize)>,
620    /// The files where that debt is most **concentrated**, already ranked and
621    /// capped by the caller. Empty when the graph has no markers, or when no
622    /// file carrying one has a recorded length.
623    pub densest_files: Vec<DensityEntry>,
624    /// Secret-named config keys and their redaction state. `None` when the graph
625    /// holds no secret-named config key — the section is then absent rather than
626    /// rendering a row of zeroes, which would read as a clean bill of health this
627    /// lens cannot give.
628    pub config_secrets: Option<ConfigSecretSummary>,
629    /// The most depended-on symbols by **directed** call fan-in, already ranked
630    /// and capped by the caller. Empty when the graph has no `calls` edges.
631    pub most_called: Vec<CouplingEntry>,
632    /// Web root of the repository (`https://host/owner/repo`), if derivable from
633    /// the git remote — for a "Repository" link in the overview.
634    pub repo_url: Option<String>,
635    /// Hex commit the graph was rendered from, for a permalink note.
636    pub commit: Option<String>,
637}
638
639/// Render the vault's overview note: what was scanned, the structure by kind,
640/// the provenance breakdown, the decisions (ADRs) and their status, the
641/// intent-debt summary, and how to navigate. The entry point for the vault.
642#[must_use]
643pub fn render_home(s: &VaultSummary) -> VaultNote {
644    let mut c = String::new();
645    c.push_str("---\ntags:\n  - roteiro/home\n---\n\n");
646    let _ = writeln!(c, "# {} — knowledge graph", s.project);
647    c.push_str(
648        "\n*A browsable snapshot of this codebase as one **knowledge graph**, \
649         generated by [Roteiro](https://roteiro.dev). Every symbol, document and \
650         decision is a note, linked to the things it relates to.*\n",
651    );
652    c.push_str(HOW_TO_READ);
653    let _ = writeln!(
654        c,
655        "\n**{} nodes**, **{} edges** across the project.",
656        s.total_nodes, s.total_edges
657    );
658    write_repo_line(&mut c, s);
659    write_summary_sections(&mut c, s, &VaultScope::PROJECT, 2);
660    c.push_str(NAVIGATING);
661
662    VaultNote {
663        filename: HOME_NOTE.to_owned(),
664        content: c,
665    }
666}
667
668/// The "how to read a note" paragraph. Shared verbatim by the single-project and
669/// workspace overviews — the notes themselves are identical in both, so a reader
670/// who learns the format once has learned it for either.
671const HOW_TO_READ: &str = "\n**How to read it.** Open any note to see what a thing is, the intent or \
672     docs behind it (its **Content**), where it lives (its **Source** link), \
673     and how it connects (**Outgoing**/**Incoming** links). Each link is \
674     labelled with how the fact was established — `derived` (extracted from \
675     code), `authored` (human intent: ADRs, blueprints, annotations), or \
676     `inferred` (a scored suggestion). Open Obsidian's **graph view** to see \
677     the whole thing at once.\n";
678
679/// The closing navigation section.
680const NAVIGATING: &str = "\n## Navigating this vault\n\n\
681     - Open the **graph view** to see the whole codebase; notes are coloured/\
682     filterable by their `roteiro/kind/*`, `roteiro/lang/*` and \
683     `roteiro/status/*` tags.\n\
684     - Each note carries its captured **content** (doc comments, prose, PDF/\
685     image text) and its provenance-labelled incoming/outgoing links.\n\
686     - Start from an ADR above, or search the tag pane for a kind.\n";
687
688/// `**Repository:** …` — the web root and the commit the graph was rendered from.
689fn write_repo_line(c: &mut String, s: &VaultSummary) {
690    if let Some(repo) = &s.repo_url {
691        let _ = write!(c, "\n**Repository:** [{repo}]({repo})");
692        if let Some(commit) = &s.commit {
693            let short = &commit[..commit.len().min(12)];
694            let _ = write!(c, " · rendered at commit `{short}`");
695        }
696        c.push('\n');
697    }
698}
699
700/// Every aggregate the overview carries for **one project**: structure by kind,
701/// provenance, ADRs, intent debt (and where it is densest), the config-secret
702/// inventory and directed call coupling.
703///
704/// Factored out of [`render_home`] so a workspace vault's per-member section is
705/// *the same code*, not a reimplementation that can drift: the promise in issue
706/// #442 is that today's per-project view stays a **subset** of the workspace one
707/// rather than a casualty of it. `level` is the markdown heading depth — 2 for a
708/// single-project `_Home`, 3 inside a member's section — and `scope` decides
709/// whether the wikilinks point at bare or project-qualified notes.
710fn write_summary_sections(c: &mut String, s: &VaultSummary, scope: &VaultScope<'_>, level: usize) {
711    let hd = &"#".repeat(level);
712    let sub = &"#".repeat(level + 1);
713    write_structure(c, s, hd);
714    write_decisions(c, s, scope, hd);
715    write_debt(c, s, scope, hd, sub);
716    write_config_secrets(c, s, scope, hd);
717    write_coupling(c, s, scope, hd);
718}
719
720/// `Structure` (nodes by kind) and `Provenance` (edges by how they were established).
721fn write_structure(c: &mut String, s: &VaultSummary, hd: &str) {
722    let _ = write!(c, "\n{hd} Structure\n\n| Kind | Count |\n| --- | --- |\n");
723    for (kind, n) in &s.node_counts {
724        let _ = writeln!(c, "| {kind} | {n} |");
725    }
726
727    if !s.edge_provenance.is_empty() {
728        let _ = write!(
729            c,
730            "\n{hd} Provenance\n\n| Provenance | Edges |\n| --- | --- |\n"
731        );
732        for (prov, n) in &s.edge_provenance {
733            let _ = writeln!(c, "| {prov} | {n} |");
734        }
735    }
736}
737
738/// `Decisions (ADRs)` — the recorded decisions and their lifecycle status.
739fn write_decisions(c: &mut String, s: &VaultSummary, scope: &VaultScope<'_>, hd: &str) {
740    let _ = write!(c, "\n{hd} Decisions (ADRs)\n\n");
741    if s.adrs.is_empty() {
742        c.push_str("*No ADRs found.*\n");
743    } else {
744        for adr in &s.adrs {
745            let status = adr.status.as_deref().unwrap_or("—");
746            let _ = writeln!(
747                c,
748                "- **{status}** — [[{}|{}]]",
749                scoped_note_name(scope, &adr.key),
750                adr.name
751            );
752        }
753    }
754}
755
756/// `Intent debt` — the marker categories, and the files the debt is densest in.
757fn write_debt(c: &mut String, s: &VaultSummary, scope: &VaultScope<'_>, hd: &str, sub: &str) {
758    let _ = write!(c, "\n{hd} Intent debt\n\n");
759    if s.debt.is_empty() {
760        c.push_str("*None recorded.*\n");
761    } else {
762        c.push_str("| Category | Count |\n| --- | --- |\n");
763        for (cat, n) in &s.debt {
764            let _ = writeln!(c, "| {cat} | {n} |");
765        }
766    }
767
768    if !s.densest_files.is_empty() {
769        let _ = write!(
770            c,
771            "\n{sub} Densest files (markers per 1,000 lines)\n\n\
772             *Where the debt above is concentrated, rather than where there is \
773             most of it — a raw count ranks the biggest file first by \
774             construction. The denominator is file length: every line, blanks and \
775             comments included, not source lines of code. Prose matches (`for \
776             now`, `tbd`) count too, so a design document can rank high.*\n\n"
777        );
778        c.push_str("| File | Markers | Lines | Per 1k |\n| --- | --- | --- | --- |\n");
779        for e in &s.densest_files {
780            let _ = writeln!(
781                c,
782                "| [[{}\\|{}]] | {} | {} | {:.2} |",
783                scoped_note_name(scope, &format!("file:{}", e.path)),
784                e.path,
785                e.markers,
786                e.lines,
787                e.per_kloc
788            );
789        }
790    }
791}
792
793/// `Config keys named like secrets` — an inventory and its unconditional caveat.
794fn write_config_secrets(c: &mut String, s: &VaultSummary, scope: &VaultScope<'_>, hd: &str) {
795    if let Some(cs) = &s.config_secrets {
796        let _ = write!(c, "\n{hd} Config keys named like secrets\n\n");
797        let _ = writeln!(
798            c,
799            "**{}** secret-named config key(s): {} redacted before storage, {} \
800             declared in code without a value, {} unredacted.",
801            cs.secret_named, cs.redacted, cs.declared, cs.unredacted
802        );
803        if cs.unredacted > 0 {
804            let _ = writeln!(
805                c,
806                "\n> [!warning] {} key(s) carry an **unredacted** value. Extraction \
807                 always redacts, so these came from an import layer — inspect the \
808                 importing tool, not this repository.",
809                cs.unredacted
810            );
811        }
812        if !cs.files.is_empty() {
813            c.push_str("\nIn:\n");
814            for path in &cs.files {
815                let _ = writeln!(
816                    c,
817                    "- [[{}\\|{path}]]",
818                    scoped_note_name(scope, &format!("file:{path}"))
819                );
820            }
821        }
822        // The caveat is unconditional and comes last, so it is the final thing read
823        // in this section. A vault note is browsed out of context; this is exactly
824        // where "config keys named like secrets" would otherwise be misread as a
825        // secret scan that came back clean.
826        c.push_str(
827            "\n*An inventory of config keys whose **names** look secret, not a secret \
828             scan. Values are redacted before they are stored, so this reports that \
829             such keys exist and were redacted — never a value. It cannot see a \
830             hardcoded credential in source code, cannot judge whether a value is \
831             valid, and cannot tell a real secret from a placeholder. A credential \
832             under an innocuous key name (`dsn`, `endpoint`) does not appear here at \
833             all, so this section being small says nothing about whether this \
834             repository leaks secrets.*\n",
835        );
836    }
837}
838
839/// `Most depended-on (call fan-in)` — directed call coupling, capped.
840fn write_coupling(c: &mut String, s: &VaultSummary, scope: &VaultScope<'_>, hd: &str) {
841    if !s.most_called.is_empty() {
842        let _ = write!(
843            c,
844            "\n{hd} Most depended-on (call fan-in)\n\n\
845             *Distinct callers and callees over `calls` edges — direction kept, so \
846             \"everything calls this\" and \"this calls everything\" are not the same \
847             row. Call targets are resolved by simple name, so a short, generically-\
848             named function can absorb every call to that name: read a large fan-in on \
849             one as a question, not a finding.*\n\n"
850        );
851        c.push_str("| Symbol | Called by | Calls |\n| --- | --- | --- |\n");
852        for e in &s.most_called {
853            let _ = writeln!(
854                c,
855                "| [[{}\\|{}]] | {} | {} |",
856                scoped_note_name(scope, &e.key),
857                e.name,
858                e.fan_in,
859                e.fan_out
860            );
861        }
862    }
863}
864
865/// One cross-repo edge the workspace vault can actually follow: a spoke's node
866/// linking to a hub's, through the external-ref placeholder ADR-0009 persists.
867///
868/// Collected by the caller, which has every member's store open; the renderer
869/// only lays them out. Nothing here is a new edge — these are the `inferred`
870/// links `roteiro links` already reports, rendered for the first time.
871#[derive(Debug, Clone)]
872pub struct CrossLink {
873    /// The member the edge starts in.
874    pub from_project: String,
875    /// The source node's key, within `from_project`.
876    pub from_key: String,
877    /// The source node's display name.
878    pub from_name: String,
879    /// The edge kind (`links`, …).
880    pub kind: String,
881    /// Confidence, for an `inferred` edge.
882    pub confidence: Option<f64>,
883    /// The project-qualified target, `<project>::<key>` (ADR-0009).
884    pub to_qualified: String,
885    /// Whether `to_qualified`'s project is a member of this workspace — and so
886    /// whether the link resolves to a note in this vault, or dangles because the
887    /// target repository is outside it.
888    pub resolves: bool,
889}
890
891/// Aggregate figures for a **workspace** vault's `_Home` overview: the members,
892/// each with exactly the aggregates a single-project `_Home` carries, plus the
893/// cross-repo links between them.
894#[derive(Debug, Clone, Default)]
895pub struct WorkspaceSummary {
896    /// The workspace name (`--workspace-name`).
897    pub name: String,
898    /// One entry per member repository, in stable name order. Each is the very
899    /// same [`VaultSummary`] a per-project vault would render.
900    pub members: Vec<VaultSummary>,
901    /// Cross-repo links between members, already ordered and capped by the caller.
902    pub cross_links: Vec<CrossLink>,
903    /// Cross-repo links found in total, which `cross_links` may be a capped view
904    /// of — so the section can say what it is not showing.
905    pub cross_links_total: usize,
906}
907
908/// Render a **workspace** vault's overview: the members and their scale, the
909/// cross-repo links between them, and then each member's own aggregates —
910/// structure, provenance, ADRs, intent debt, config-secret inventory and call
911/// coupling — under its own heading.
912///
913/// The per-member sections are rendered by the same [`write_summary_sections`]
914/// the single-project `_Home` uses, so the existing view is a **subset** of this
915/// one: someone who came for their repository's coupling and debt tables finds
916/// them, rather than a workspace total that averages them away.
917#[must_use]
918pub fn render_workspace_home(ws: &WorkspaceSummary) -> VaultNote {
919    let members: std::collections::BTreeSet<String> =
920        ws.members.iter().map(|m| m.project.clone()).collect();
921
922    let mut c = String::new();
923    c.push_str("---\ntags:\n  - roteiro/home\n  - roteiro/workspace\n---\n\n");
924    let _ = writeln!(c, "# {} — workspace knowledge graph", ws.name);
925    c.push_str(
926        "\n*A browsable snapshot of a whole **workspace** as one **knowledge \
927         graph**, generated by [Roteiro](https://roteiro.dev). Every symbol, \
928         document and decision in every member repository is a note, linked to the \
929         things it relates to — including across repositories.*\n",
930    );
931    c.push_str(HOW_TO_READ);
932    c.push_str(
933        "\n**Notes are named `<project>-<key>`**, because a node key is \
934         repository-relative: every member has a `README.md`, and without the \
935         project each would overwrite the last. Filter the graph view by a \
936         member's `roteiro/project/*` tag to see one repository at a time.\n",
937    );
938
939    let total_nodes: usize = ws.members.iter().map(|m| m.total_nodes).sum();
940    let total_edges: usize = ws.members.iter().map(|m| m.total_edges).sum();
941    let _ = writeln!(
942        c,
943        "\n**{total_nodes} nodes**, **{total_edges} edges** across **{}** member \
944         repositor{}.",
945        ws.members.len(),
946        if ws.members.len() == 1 { "y" } else { "ies" }
947    );
948
949    c.push_str("\n## Members\n\n| Project | Nodes | Edges | Repository | Commit |\n| --- | --- | --- | --- | --- |\n");
950    for m in &ws.members {
951        let repo = m
952            .repo_url
953            .as_ref()
954            .map_or_else(|| "—".to_owned(), |u| format!("[{u}]({u})"));
955        let commit = m.commit.as_ref().map_or_else(
956            || "—".to_owned(),
957            |c| format!("`{}`", &c[..c.len().min(12)]),
958        );
959        let _ = writeln!(
960            c,
961            "| [[#{}\\|{}]] | {} | {} | {repo} | {commit} |",
962            m.project, m.project, m.total_nodes, m.total_edges
963        );
964    }
965    c.push_str(
966        "\n*The `Repository` and `Commit` columns say where each member came from \
967         and what was read. They are **not** a replication manifest — reconstructing \
968         a workspace from a vault is issue #442 part 2, and nothing here is designed \
969         to be handed to someone else.*\n",
970    );
971
972    write_cross_links(&mut c, ws);
973
974    for m in &ws.members {
975        let _ = writeln!(c, "\n## {}", m.project);
976        let _ = writeln!(
977            c,
978            "\n**{} nodes**, **{} edges** in this member.",
979            m.total_nodes, m.total_edges
980        );
981        write_repo_line(&mut c, m);
982        let scope = VaultScope {
983            project: Some(&m.project),
984            members: &members,
985        };
986        write_summary_sections(&mut c, m, &scope, 3);
987    }
988
989    c.push_str(NAVIGATING);
990
991    VaultNote {
992        filename: HOME_NOTE.to_owned(),
993        content: c,
994    }
995}
996
997/// The `## Cross-repo links` section: the edges that only a workspace vault can
998/// show, and the honest statement of what is missing from them.
999fn write_cross_links(c: &mut String, ws: &WorkspaceSummary) {
1000    c.push_str("\n## Cross-repo links\n\n");
1001    if ws.cross_links.is_empty() {
1002        c.push_str(
1003            "*None. These are the `inferred` cross-repo links `roteiro links \
1004             --infer --write` persists (ADR-0009); a workspace whose members have \
1005             never been inferred over has none recorded yet.*\n",
1006        );
1007        return;
1008    }
1009    c.push_str(
1010        "*A spoke's config key and the hub key it corresponds to, across \
1011         repositories — the one thing a per-project vault structurally cannot show. \
1012         These are `inferred` matches persisted by `roteiro links --infer --write` \
1013         (ADR-0009), not authored facts: read a row as a candidate correspondence.*\n\n",
1014    );
1015    c.push_str("| From | | To | Kind |\n| --- | --- | --- | --- |\n");
1016    for l in &ws.cross_links {
1017        let from_scope = VaultScope {
1018            project: Some(&l.from_project),
1019            members: &NO_MEMBERS,
1020        };
1021        let to = if l.resolves {
1022            format!("[[{}\\|{}]]", note_name(&l.to_qualified), l.to_qualified)
1023        } else {
1024            // Outside this workspace: there is no note to link to, and a wikilink
1025            // to a note that does not exist reads in Obsidian as one that is
1026            // merely unwritten.
1027            format!("`{}` *(outside this workspace)*", l.to_qualified)
1028        };
1029        let _ = writeln!(
1030            c,
1031            "| [[{}\\|{}]] | {} | {to} | {}{} |",
1032            scoped_note_name(&from_scope, &l.from_key),
1033            l.from_name,
1034            l.from_project,
1035            l.kind,
1036            confidence(l.confidence)
1037        );
1038    }
1039    if ws.cross_links_total > ws.cross_links.len() {
1040        let _ = writeln!(
1041            c,
1042            "\n*Showing {} of {} — the full report is `roteiro links --matrix`.*",
1043            ws.cross_links.len(),
1044            ws.cross_links_total
1045        );
1046    }
1047    c.push_str(
1048        "\n*Shown in one direction only. The edge lives in the spoke's store, \
1049         pointing at a local placeholder for the hub's node, so the hub's own note \
1050         carries no matching **Incoming** entry — Obsidian's **Backlinks** pane \
1051         still shows it, because the link is in the vault.*\n",
1052    );
1053}
1054
1055#[cfg(test)]
1056mod tests {
1057    use super::{
1058        AdrEntry, ConfigSecretSummary, CouplingEntry, CrossLink, DensityEntry, HOME_NOTE,
1059        VaultScope, VaultSummary, WorkspaceSummary, note_name, render_home, render_note,
1060        render_note_scoped, render_workspace_home, scoped_note_name,
1061    };
1062    use rto_graph::{EdgeRef, Explanation, NodeSummary};
1063
1064    /// The shape of a name, pinned once so a change to it is a deliberate edit
1065    /// here rather than a diff spread over twenty other assertions.
1066    ///
1067    /// Everything else in this module composes `note_name` instead of repeating
1068    /// its output, because those tests are about *which key a link points at* and
1069    /// were never about the spelling.
1070    #[test]
1071    fn note_name_is_a_lowercase_hint_and_a_hash_of_the_whole_key() {
1072        assert_eq!(
1073            note_name("sym:rust:src/a.rs#Store"),
1074            "sym-rust-src-a.rs-store-b4cbf6633003361f"
1075        );
1076        assert_eq!(note_name("adr:0001"), "adr-0001-559a2e837953b2ff");
1077        assert_eq!(
1078            note_name("file:src/main.rs"),
1079            "file-src-main.rs-4a72627453f6780e"
1080        );
1081        // Deterministic: the suffix is a pure function of the key, so a vault
1082        // renders the same names on every machine and every run.
1083        assert_eq!(note_name("adr:0001"), note_name("adr:0001"));
1084    }
1085
1086    /// **The property `note_name` exists to have** (issue #574): distinct keys
1087    /// give distinct notes *on a case-folding filesystem*, which is where the
1088    /// vault was losing them.
1089    ///
1090    /// Asserted over lowercased names, not names. On macOS and Windows two names
1091    /// differing only in case are one file, so a name set that is distinct as
1092    /// strings can still be a vault with notes missing — and Linux CI cannot see
1093    /// it. Folding here makes the assertion say what the filesystem says, on
1094    /// every platform.
1095    ///
1096    /// The keys are the two mechanisms that were actually losing notes, taken
1097    /// from this repository's own render rather than invented: the vendored
1098    /// `cytoscape.min.js` bundle whose minified single-letter symbols differ only
1099    /// by a sigil or by case, and a pair of grouped Rust `use` keys differing
1100    /// only by a trailing comma. `render_cli` runs the same assertion end to end
1101    /// over a rendered vault; this is the unit-level statement of it.
1102    #[test]
1103    fn distinct_keys_give_distinct_notes_even_after_case_folding() {
1104        const JS: &str = "sym:javascript:crates/roteiro/src/assets/cytoscape.min.js";
1105        let keys: Vec<String> = [
1106            // Slug lossiness: the sigil and the letter both slugged to the same
1107            // thing (9 notes lost this way, on every platform).
1108            format!("{JS}#$a"),
1109            format!("{JS}#a"),
1110            format!("{JS}#$o"),
1111            format!("{JS}#o"),
1112            // Case folding: distinct names, one file (95 notes lost this way, and
1113            // only on macOS and Windows).
1114            format!("{JS}#A"),
1115            format!("{JS}#O"),
1116            format!("{JS}#S"),
1117            format!("{JS}#s"),
1118            // Real source symbols, same shape.
1119            "sym:rust:crates/rto-exec/src/sandbox_store.rs#Store".into(),
1120            "sym:rust:crates/rto-exec/src/sandbox_store.rs#store".into(),
1121            // A trailing comma is the whole difference between these two.
1122            "import:rust:crate::engine::{ChatRequest,Engine,ModelInfo,}".into(),
1123            "import:rust:crate::engine::{ChatRequest,Engine,ModelInfo}".into(),
1124            // Nothing but separators: no hint at all, so the name is bare hash.
1125            "::".into(),
1126            "##".into(),
1127            // Over the length bound, differing only past the truncation point —
1128            // the case truncation alone used to merge.
1129            format!("import:rust:{}A", "a::b::c,".repeat(60)),
1130            format!("import:rust:{}a", "a::b::c,".repeat(60)),
1131        ]
1132        .into();
1133
1134        let folded: std::collections::BTreeSet<String> =
1135            keys.iter().map(|k| note_name(k).to_lowercase()).collect();
1136        assert_eq!(
1137            folded.len(),
1138            keys.len(),
1139            "two keys share a note after case folding; the vault would hold one \
1140             file for both and report two"
1141        );
1142    }
1143
1144    /// Case folding is the identity on a note name, so the assertion above is not
1145    /// weaker than the filesystem it stands in for.
1146    ///
1147    /// This is the reason the hint is lowercased rather than case-preserved: it
1148    /// makes "distinct names" and "distinct files on macOS" the same statement,
1149    /// so there is no version of this module that passes on Linux and loses notes
1150    /// on a Mac. Without it, the two assertions could drift apart and only the
1151    /// weaker one would ever run in CI.
1152    #[test]
1153    fn a_note_name_is_already_lowercase() {
1154        for key in [
1155            "sym:rust:src/a.rs#Store",
1156            "file:README.md",
1157            "app::file:CHANGELOG.md",
1158            "sym:javascript:a.js#ABC",
1159        ] {
1160            let name = note_name(key);
1161            assert_eq!(name, name.to_lowercase(), "`{key}` kept case in its name");
1162        }
1163    }
1164
1165    /// `_Home` is a name in the same namespace as every note, and it is not
1166    /// derived from a key — so nothing must be able to collide with it. The
1167    /// mandatory suffix gives that for free: every generated name either ends in
1168    /// `-<16 hex>` or *is* 16 hex digits, and `_home` is neither.
1169    #[test]
1170    fn no_key_can_claim_the_home_note() {
1171        for key in ["_Home", "file:_Home", "_home", "::_Home::"] {
1172            assert_ne!(
1173                format!("{}.md", note_name(key)).to_lowercase(),
1174                HOME_NOTE.to_lowercase(),
1175                "`{key}` would overwrite the overview note"
1176            );
1177        }
1178    }
1179
1180    #[test]
1181    fn render_note_emits_frontmatter_and_wikilinks() {
1182        let ex = Explanation {
1183            schema: rto_graph::SCHEMA,
1184            node: NodeSummary {
1185                key: "sym:rust:a.rs#main".into(),
1186                kind: "fn".into(),
1187                name: "main".into(),
1188                path: Some("a.rs".into()),
1189                lang: Some("rust".into()),
1190            },
1191            meta: serde_json::Value::Null,
1192            outgoing: vec![EdgeRef {
1193                kind: "calls".into(),
1194                provenance: "derived",
1195                confidence: None,
1196                node: "sym:rust:a.rs#helper".into(),
1197            }],
1198            incoming: vec![EdgeRef {
1199                kind: "references".into(),
1200                provenance: "authored",
1201                confidence: None,
1202                node: "adr:0001".into(),
1203            }],
1204        };
1205        let note = render_note(&ex, None, None);
1206        assert_eq!(
1207            note.filename,
1208            format!("{}.md", note_name("sym:rust:a.rs#main"))
1209        );
1210        assert!(note.content.contains("kind: fn"));
1211        // No source base → no Source link.
1212        assert!(!note.content.contains("**Source:**"));
1213        assert!(note.content.contains("# main"));
1214        assert!(note.content.contains(&format!(
1215            "- calls (derived) → [[{}]]",
1216            note_name("sym:rust:a.rs#helper")
1217        )));
1218        assert!(note.content.contains(&format!(
1219            "- [[{}]] references (authored) →",
1220            note_name("adr:0001")
1221        )));
1222        // Tags for the graph view.
1223        assert!(note.content.contains("- roteiro/kind/fn"));
1224        assert!(note.content.contains("- roteiro/lang/rust"));
1225    }
1226
1227    #[test]
1228    fn note_name_bounds_long_keys_deterministically() {
1229        let long = format!("import:rust:{}", "a::b::c,".repeat(60));
1230        let a = note_name(&long);
1231        let b = note_name(&long);
1232        assert_eq!(a, b, "deterministic");
1233        assert!(
1234            a.len() <= 205,
1235            "bounded under the filename limit: {}",
1236            a.len()
1237        );
1238        assert_ne!(
1239            note_name(&format!("{long}x")),
1240            a,
1241            "different keys stay distinct after truncation"
1242        );
1243        // Truncation must not leave a doubled separator before the suffix — the
1244        // hint is trimmed after cutting, not before.
1245        assert!(!a.contains("--"), "{a}");
1246    }
1247
1248    /// A short key is bounded too, and every name carries the suffix — the hash
1249    /// is no longer reached for only when the hint overruns.
1250    ///
1251    /// That gating was the defect (#574): two keys short enough to skip the hash
1252    /// had nothing left to tell them apart once the slug had flattened them.
1253    #[test]
1254    fn every_name_carries_the_hash_however_short_the_key() {
1255        for key in ["a", "adr:0001", "file:README.md"] {
1256            let name = note_name(key);
1257            let (hint, hash) = name.rsplit_once('-').expect("a suffixed name");
1258            assert!(!hint.is_empty(), "{name}");
1259            assert_eq!(hash.len(), 16, "{name}");
1260            assert!(
1261                hash.chars().all(|c| c.is_ascii_hexdigit()),
1262                "the suffix is the key's hash, not part of the hint: {name}"
1263            );
1264        }
1265        // A key with no hint at all is the bare hash, which cannot be mistaken
1266        // for a hinted name (those are at least 18 characters).
1267        let bare = note_name("::");
1268        assert_eq!(bare.len(), 16, "{bare}");
1269        assert!(!bare.contains('-'), "{bare}");
1270    }
1271
1272    #[test]
1273    fn render_note_surfaces_content_and_status() {
1274        let ex = Explanation {
1275            schema: rto_graph::SCHEMA,
1276            node: NodeSummary {
1277                key: "adr:0001".into(),
1278                kind: "adr".into(),
1279                name: "Build Roteiro".into(),
1280                path: Some("docs/adr/0001.md".into()),
1281                lang: None,
1282            },
1283            meta: serde_json::json!({ "status": "Accepted", "content": "The decision text." }),
1284            outgoing: vec![],
1285            incoming: vec![],
1286        };
1287        let note = render_note(&ex, Some("https://github.com/org/repo/blob/abc123"), None);
1288        assert!(note.content.contains("status: Accepted"));
1289        assert!(note.content.contains("- roteiro/status/accepted"));
1290        assert!(note.content.contains("> **Status:** Accepted"));
1291        assert!(note.content.contains("## Content\n\nThe decision text."));
1292        // A clickable link to the actual ADR file on the repository host.
1293        assert!(
1294            note.content.contains(
1295                "**Source:** [`docs/adr/0001.md`](https://github.com/org/repo/blob/abc123/docs/adr/0001.md)"
1296            ),
1297            "{}",
1298            note.content
1299        );
1300    }
1301
1302    /// The structured document a prose note is supposed to reproduce: headings, a
1303    /// table and a fenced code block, none of which survive whitespace collapse.
1304    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";
1305
1306    fn prose_note(content: Option<&str>) -> Explanation {
1307        Explanation {
1308            schema: rto_graph::SCHEMA,
1309            node: NodeSummary {
1310                key: "file:docs/OFFLINE_SETUP.md".into(),
1311                kind: "file".into(),
1312                name: "OFFLINE_SETUP.md".into(),
1313                path: Some("docs/OFFLINE_SETUP.md".into()),
1314                lang: None,
1315            },
1316            meta: content.map_or(
1317                serde_json::Value::Null,
1318                |c| serde_json::json!({ "content": c }),
1319            ),
1320            outgoing: vec![],
1321            incoming: vec![],
1322        }
1323    }
1324
1325    /// The whole readability defect, in one assertion pair: a note built from
1326    /// `meta.content` alone is the document whitespace-collapsed onto one line,
1327    /// and a note built from the source is the document.
1328    ///
1329    /// The newline count is the claim. A character count alone would pass on a
1330    /// note that had merely grown longer while staying flat, which is exactly the
1331    /// failure being fixed — `meta.content` is capped *and* collapsed, and only
1332    /// the collapse is what makes it unreadable.
1333    #[test]
1334    fn a_supplied_body_supersedes_the_collapsed_stored_content() {
1335        // What extraction stores: the same text, whitespace-collapsed.
1336        let collapsed = DOC.split_whitespace().collect::<Vec<_>>().join(" ");
1337        let ex = prose_note(Some(&collapsed));
1338
1339        let note = render_note(&ex, None, Some(DOC));
1340        assert!(
1341            note.content.contains(DOC.trim()),
1342            "the source document is reproduced verbatim: {}",
1343            note.content
1344        );
1345        assert!(
1346            !note.content.contains(&collapsed),
1347            "the collapsed rendering is replaced, not appended: {}",
1348            note.content
1349        );
1350        assert!(
1351            note.content.contains("\n| Host | What |\n"),
1352            "a table needs its own lines to be a table: {}",
1353            note.content
1354        );
1355        assert!(
1356            note.content.contains("\n```sh\n"),
1357            "a fenced block needs its own lines to be a fence: {}",
1358            note.content
1359        );
1360
1361        // The flat control: the same node with no body is the one-line note.
1362        let flat = render_note(&ex, None, None);
1363        assert!(
1364            flat.content.contains(&collapsed),
1365            "without a body the stored content is still shown: {}",
1366            flat.content
1367        );
1368        assert!(
1369            content_lines(&note.content) > content_lines(&flat.content),
1370            "structure restored: {} line(s) with a body vs {} without",
1371            content_lines(&note.content),
1372            content_lines(&flat.content)
1373        );
1374        assert_eq!(
1375            content_lines(&flat.content),
1376            1,
1377            "the defect: the stored content is a single line"
1378        );
1379    }
1380
1381    /// A doc comment is a summary of a definition, not a document, and its note is
1382    /// correct as it stands. The caller supplies no body for these, so this pins
1383    /// the unchanged path — the fix must not depend on every node gaining one.
1384    #[test]
1385    fn a_note_with_no_body_is_unchanged() {
1386        let ex = Explanation {
1387            schema: rto_graph::SCHEMA,
1388            node: NodeSummary {
1389                key: "sym:rust:a.rs#main".into(),
1390                kind: "fn".into(),
1391                name: "main".into(),
1392                path: Some("a.rs".into()),
1393                lang: Some("rust".into()),
1394            },
1395            meta: serde_json::json!({ "content": "Entry point." }),
1396            outgoing: vec![],
1397            incoming: vec![],
1398        };
1399        assert!(
1400            render_note(&ex, None, None)
1401                .content
1402                .contains("## Content\n\nEntry point.")
1403        );
1404    }
1405
1406    /// Lines in the note's `## Content` section.
1407    fn content_lines(note: &str) -> usize {
1408        let body = note
1409            .split_once("## Content\n\n")
1410            .map_or("", |(_, rest)| rest);
1411        let body = body.split_once("\n## ").map_or(body, |(head, _)| head);
1412        body.trim_end().lines().count()
1413    }
1414
1415    #[test]
1416    fn render_note_shows_inferred_confidence() {
1417        let ex = Explanation {
1418            schema: rto_graph::SCHEMA,
1419            node: NodeSummary {
1420                key: "file:a.md".into(),
1421                kind: "file".into(),
1422                name: "a.md".into(),
1423                path: Some("a.md".into()),
1424                lang: None,
1425            },
1426            meta: serde_json::Value::Null,
1427            outgoing: vec![EdgeRef {
1428                kind: "related".into(),
1429                provenance: "inferred",
1430                confidence: Some(0.82),
1431                node: "file:b.md".into(),
1432            }],
1433            incoming: vec![],
1434        };
1435        let note = render_note(&ex, None, None);
1436        assert!(
1437            note.content.contains(&format!(
1438                "related (inferred) (0.82) → [[{}]]",
1439                note_name("file:b.md")
1440            )),
1441            "{}",
1442            note.content
1443        );
1444    }
1445
1446    #[test]
1447    fn render_home_summarises_the_graph() {
1448        let summary = VaultSummary {
1449            project: "demo".into(),
1450            total_nodes: 3,
1451            total_edges: 2,
1452            node_counts: vec![("fn".into(), 2), ("adr".into(), 1)],
1453            edge_provenance: vec![("derived".into(), 1), ("authored".into(), 1)],
1454            adrs: vec![AdrEntry {
1455                key: "adr:0001".into(),
1456                name: "First".into(),
1457                status: Some("Accepted".into()),
1458            }],
1459            debt: vec![("todo".into(), 4)], // roteiro:ignore
1460            densest_files: vec![DensityEntry {
1461                path: "src/small.rs".into(),
1462                markers: 3,
1463                lines: 120,
1464                per_kloc: 25.0,
1465            }],
1466            config_secrets: Some(ConfigSecretSummary {
1467                secret_named: 4,
1468                redacted: 3,
1469                declared: 1,
1470                unredacted: 0,
1471                files: vec![".env".into()],
1472            }),
1473            most_called: vec![CouplingEntry {
1474                key: "sym:rust:a.rs#helper".into(),
1475                name: "helper".into(),
1476                fan_in: 7,
1477                fan_out: 1,
1478            }],
1479            repo_url: Some("https://github.com/org/repo".into()),
1480            commit: Some("abcdef0123456789".into()),
1481        };
1482        let note = render_home(&summary);
1483        assert_eq!(note.filename, HOME_NOTE);
1484        assert!(note.content.contains("# demo — knowledge graph"));
1485        assert!(note.content.contains("**3 nodes**, **2 edges**"));
1486        assert!(note.content.contains("| fn | 2 |"));
1487        assert!(note.content.contains("| derived | 1 |"));
1488        assert!(note.content.contains(&format!(
1489            "**Accepted** — [[{}|First]]",
1490            note_name("adr:0001")
1491        )));
1492        assert!(note.content.contains("| todo | 4 |")); // roteiro:ignore
1493        // Directed coupling: the two fans are separate columns, and the wikilink's
1494        // own `|` is escaped so it cannot break the table it sits in.
1495        assert!(
1496            note.content.contains(&format!(
1497                "| [[{}\\|helper]] | 7 | 1 |",
1498                note_name("sym:rust:a.rs#helper")
1499            )),
1500            "{}",
1501            note.content
1502        );
1503        assert!(
1504            note.content.contains("resolved by simple name"),
1505            "the precision caveat travels with the figures"
1506        );
1507        // Density: the count and the denominator are both shown, so the ratio can
1508        // be checked rather than taken on trust, and the wikilink's own `|` is
1509        // escaped so it cannot break the table it sits in.
1510        assert!(
1511            note.content.contains(&format!(
1512                "| [[{}\\|src/small.rs]] | 3 | 120 | 25.00 |",
1513                note_name("file:src/small.rs")
1514            )),
1515            "{}",
1516            note.content
1517        );
1518        assert!(
1519            note.content.contains("not source lines of code"),
1520            "the denominator caveat travels with the figures"
1521        );
1522        // Config secrets: counts and files, and no key names — a vault note is
1523        // browsed out of context, which is the wrong place for a list that would
1524        // read as a secret scan's output.
1525        assert!(
1526            note.content.contains(
1527                "**4** secret-named config key(s): 3 redacted before storage, 1 \
1528                 declared in code without a value, 0 unredacted."
1529            ),
1530            "{}",
1531            note.content
1532        );
1533        assert!(
1534            note.content
1535                .contains(&format!("- [[{}\\|.env]]", note_name("file:.env"))),
1536            "{}",
1537            note.content
1538        );
1539        assert!(
1540            note.content.contains("not a secret scan")
1541                && note.content.contains("cannot see a hardcoded credential"),
1542            "the limitation travels with the figures: {}",
1543            note.content
1544        );
1545        assert!(
1546            !note.content.contains("[!warning]"),
1547            "no warning when nothing is unredacted: {}",
1548            note.content
1549        );
1550        // A repository link + short-commit permalink note.
1551        assert!(
1552            note.content
1553                .contains("**Repository:** [https://github.com/org/repo](https://github.com/org/repo) · rendered at commit `abcdef012345`"),
1554            "{}",
1555            note.content
1556        );
1557    }
1558
1559    #[test]
1560    fn render_home_omits_density_for_a_graph_with_no_markers() {
1561        // A clean repository has no markers, so there is no density to rank. An
1562        // empty table under a heading reads as "measured, and there is nothing";
1563        // the section is absent instead. Same rule as the coupling table below.
1564        let note = render_home(&VaultSummary {
1565            project: "clean".into(),
1566            total_nodes: 1,
1567            ..VaultSummary::default()
1568        });
1569        assert!(
1570            !note.content.contains("Densest files"),
1571            "no heading without rows: {}",
1572            note.content
1573        );
1574        // The intent-debt section itself still renders — density is an addition
1575        // to it, not a replacement.
1576        assert!(note.content.contains("## Intent debt"));
1577        assert!(note.content.contains("*None recorded.*"));
1578    }
1579
1580    #[test]
1581    fn render_home_omits_config_secrets_rather_than_rendering_zeroes() {
1582        // A row of zeroes under this heading would read as "scanned, and clean" —
1583        // a conclusion the lens cannot support, since a credential under an
1584        // innocuous key name never appears in it. The section is absent instead.
1585        let note = render_home(&VaultSummary {
1586            project: "clean".into(),
1587            total_nodes: 1,
1588            ..VaultSummary::default()
1589        });
1590        assert!(
1591            !note.content.contains("named like secrets"),
1592            "no heading without figures: {}",
1593            note.content
1594        );
1595    }
1596
1597    #[test]
1598    fn render_home_warns_loudly_about_an_unredacted_value() {
1599        // Extraction cannot produce this state, so if it appears something else
1600        // put an unredacted value in the store — and the note must say where to
1601        // look rather than implicating the repository.
1602        let note = render_home(&VaultSummary {
1603            project: "imported".into(),
1604            total_nodes: 1,
1605            config_secrets: Some(ConfigSecretSummary {
1606                secret_named: 1,
1607                redacted: 0,
1608                declared: 0,
1609                unredacted: 1,
1610                files: vec!["imported.env".into()],
1611            }),
1612            ..VaultSummary::default()
1613        });
1614        assert!(
1615            note.content.contains("[!warning]") && note.content.contains("**unredacted**"),
1616            "{}",
1617            note.content
1618        );
1619        assert!(
1620            note.content.contains("came from an import layer"),
1621            "and it points at the importing tool, not the repository: {}",
1622            note.content
1623        );
1624    }
1625
1626    #[test]
1627    fn render_home_omits_coupling_for_a_graph_with_no_calls() {
1628        // A prose-only vault has no `calls` edges. An empty table under a heading
1629        // reads as "measured, and there is nothing" — the section is absent instead.
1630        let note = render_home(&VaultSummary {
1631            project: "docs".into(),
1632            total_nodes: 1,
1633            ..VaultSummary::default()
1634        });
1635        assert!(
1636            !note.content.contains("Most depended-on"),
1637            "no heading without rows: {}",
1638            note.content
1639        );
1640        // The rest of the overview is unaffected.
1641        assert!(note.content.contains("# docs — knowledge graph"));
1642    }
1643
1644    // ---- Workspace vaults (issue #442 part 1) --------------------------------
1645
1646    /// A `Explanation` for `key`, with one outgoing edge to `to`.
1647    fn node_linking_to(key: &str, name: &str, to: &str) -> Explanation {
1648        Explanation {
1649            schema: rto_graph::SCHEMA,
1650            node: NodeSummary {
1651                key: key.into(),
1652                kind: "config_key".into(),
1653                name: name.into(),
1654                path: Some("config.toml".into()),
1655                lang: None,
1656            },
1657            meta: serde_json::Value::Null,
1658            outgoing: vec![EdgeRef {
1659                kind: "links".into(),
1660                provenance: "inferred",
1661                confidence: Some(0.91),
1662                node: to.into(),
1663            }],
1664            incoming: vec![],
1665        }
1666    }
1667
1668    fn members(names: &[&str]) -> std::collections::BTreeSet<String> {
1669        names.iter().map(|s| (*s).to_owned()).collect()
1670    }
1671
1672    /// **Rewritten deliberately under #574.** #570 landed this as "a project
1673    /// scope leaves every note name exactly as it was", and read that two ways at
1674    /// once: `PROJECT` reduces to `note_name`, *and* `note_name` itself does not
1675    /// move. #574 breaks the second half on purpose — the old names were not
1676    /// injective under filename case folding and this repository's vault lost 104
1677    /// notes to it — so the two halves are separated here rather than having
1678    /// expected values quietly updated underneath the old title.
1679    ///
1680    /// What survives is the half #570 was actually about, and it is unweakened:
1681    /// **turning workspace mode on must not rename a project's notes.** Names may
1682    /// move when `note_name` changes, for a reason argued at `note_name`; they may
1683    /// never move because a repository happens to sit inside a configured
1684    /// workspace, because that would happen by inference rather than by a release.
1685    ///
1686    /// The other half of #570's promise — that a project render is byte-identical
1687    /// apart from names — is now [`render_note_is_the_project_scoped_render_byte_for_byte`]
1688    /// and `render_cli`'s end-to-end pair.
1689    #[test]
1690    fn a_project_scope_never_qualifies_a_name() {
1691        // A user's own notes live outside the vault and link into it *by name*
1692        // (#442), so a rename breaks them silently, with no error and nothing to
1693        // grep for. Whatever workspace mode does, `VaultScope::PROJECT` must
1694        // reduce to `note_name` of the bare key.
1695        for key in [
1696            "file:README.md",
1697            "adr:0001",
1698            "sym:rust:src/a.rs#Store",
1699            "extref:other::file:README.md",
1700            "cfgkey:config.toml#serve.addr",
1701        ] {
1702            assert_eq!(
1703                scoped_note_name(&VaultScope::PROJECT, key),
1704                note_name(key),
1705                "single-project name moved for `{key}`"
1706            );
1707            // And the qualified form really is a different name, so the assertion
1708            // above is not vacuously true of every scope.
1709            let ms = members(&["app"]);
1710            assert_ne!(
1711                scoped_note_name(
1712                    &VaultScope {
1713                        project: Some("app"),
1714                        members: &ms,
1715                    },
1716                    key
1717                ),
1718                note_name(key),
1719                "qualification must move the name for `{key}`, or nothing above holds"
1720            );
1721        }
1722    }
1723
1724    #[test]
1725    fn render_note_is_the_project_scoped_render_byte_for_byte() {
1726        let ex = node_linking_to("cfgkey:config.toml#addr", "addr", "sym:rust:a.rs#A");
1727        assert_eq!(
1728            render_note(&ex, Some("https://h/b"), Some("body")),
1729            render_note_scoped(&ex, Some("https://h/b"), Some("body"), &VaultScope::PROJECT),
1730            "the unscoped entry point must stay the scoped one at PROJECT, so the \
1731             two cannot drift apart"
1732        );
1733    }
1734
1735    #[test]
1736    fn each_member_gets_its_own_note_for_the_same_key() {
1737        // The collision the whole feature exists for: node keys are
1738        // repository-relative, so every member's `README.md` is `file:README.md`.
1739        let ms = members(&["api", "sdk"]);
1740        let names: Vec<String> = ["api", "sdk"]
1741            .iter()
1742            .map(|p| {
1743                scoped_note_name(
1744                    &VaultScope {
1745                        project: Some(p),
1746                        members: &ms,
1747                    },
1748                    "file:README.md",
1749                )
1750            })
1751            .collect();
1752        assert_eq!(
1753            names,
1754            [
1755                note_name("api::file:README.md"),
1756                note_name("sdk::file:README.md")
1757            ]
1758        );
1759        assert_ne!(names[0], names[1], "two members must not share one note");
1760    }
1761
1762    /// The two names this feature has, pinned together in one place.
1763    ///
1764    /// They are easy to conflate and were, in this PR, described inconsistently
1765    /// in two doc comments — the **key** is `<project>::<key>` (ADR-0009's
1766    /// cross-repo form, which is why cross-repo links resolve), and the **note
1767    /// name** is [`note_name`] of that key, in which `::` has become `-`. A
1768    /// reader told the wrong one goes looking for a file with `::` in it.
1769    ///
1770    /// Asserting both here means the next description that drifts has something
1771    /// to disagree with, rather than waiting for a reviewer to read two comments
1772    /// side by side.
1773    #[test]
1774    fn the_qualified_key_and_the_note_name_are_different_strings() {
1775        let ms = members(&["app"]);
1776        let scope = VaultScope {
1777            project: Some("app"),
1778            members: &ms,
1779        };
1780        // The key: project-qualified, `::` intact — this is what the graph and
1781        // ADR-0009's external refs use.
1782        let qualified = "app::file:README.md";
1783        // The note name: `note_name` of exactly that key, `::` slugged to `-`,
1784        // the whole hint lowercased, and the key's own hash appended.
1785        assert_eq!(
1786            scoped_note_name(&scope, "file:README.md"),
1787            "app-file-readme.md-a114bde6dcaba1c1"
1788        );
1789        assert_eq!(note_name(qualified), "app-file-readme.md-a114bde6dcaba1c1");
1790        assert!(
1791            !scoped_note_name(&scope, "file:README.md").contains("::"),
1792            "no note name ever contains `::`"
1793        );
1794        // And on disk the stem gains the extension, which is the string a reader
1795        // actually looks for.
1796        let note = render_note_scoped(
1797            &node_with("file:README.md", Some("README.md"), None),
1798            None,
1799            None,
1800            &scope,
1801        );
1802        assert_eq!(note.filename, "app-file-readme.md-a114bde6dcaba1c1.md");
1803    }
1804
1805    #[test]
1806    fn a_member_note_declares_which_member_it_came_from() {
1807        let ms = members(&["api"]);
1808        let ex = node_linking_to("cfgkey:config.toml#addr", "addr", "sym:rust:a.rs#A");
1809        let note = render_note_scoped(
1810            &ex,
1811            None,
1812            None,
1813            &VaultScope {
1814                project: Some("api"),
1815                members: &ms,
1816            },
1817        );
1818        assert_eq!(
1819            note.filename,
1820            format!("{}.md", note_name("api::cfgkey:config.toml#addr"))
1821        );
1822        assert!(
1823            note.content.contains("project: \"api\""),
1824            "{}",
1825            note.content
1826        );
1827        assert!(
1828            note.content.contains("- roteiro/project/api"),
1829            "the tag is what filters the graph view to one repository: {}",
1830            note.content
1831        );
1832        // A within-member edge is qualified to the same member, not left bare.
1833        assert!(
1834            note.content
1835                .contains(&format!("→ [[{}]]", note_name("api::sym:rust:a.rs#A"))),
1836            "{}",
1837            note.content
1838        );
1839    }
1840
1841    #[test]
1842    fn a_project_note_declares_no_project() {
1843        let ex = node_linking_to("cfgkey:config.toml#addr", "addr", "sym:rust:a.rs#A");
1844        let note = render_note(&ex, None, None);
1845        assert!(!note.content.contains("project:"), "{}", note.content);
1846        assert!(
1847            !note.content.contains("roteiro/project/"),
1848            "a per-project vault would carry one constant on every note — and \
1849             adding it would change every note's bytes: {}",
1850            note.content
1851        );
1852    }
1853
1854    #[test]
1855    fn a_cross_repo_edge_links_straight_to_the_other_members_note() {
1856        // ADR-0009: the spoke's edge points at a *local placeholder* for the hub's
1857        // node, because store integrity needs both ends in one store. A workspace
1858        // vault holds both, so the link goes to the real note. No new edge — the
1859        // resolver already follows this placeholder at query time.
1860        let ms = members(&["spoke", "hub"]);
1861        let scope = VaultScope {
1862            project: Some("spoke"),
1863            members: &ms,
1864        };
1865        let ex = node_linking_to(
1866            "cfgkey:config.toml#addr",
1867            "addr",
1868            &rto_graph::external_ref_key("hub::cfgkey:config.toml#addr"),
1869        );
1870        let note = render_note_scoped(&ex, None, None, &scope);
1871        assert!(
1872            note.content.contains(&format!(
1873                "→ [[{}]]",
1874                note_name("hub::cfgkey:config.toml#addr")
1875            )),
1876            "the edge must land on the hub's own note: {}",
1877            note.content
1878        );
1879        assert!(
1880            !note.content.contains("extref"),
1881            "and never on the placeholder: {}",
1882            note.content
1883        );
1884        // The same rule decides that the placeholder is not written as a note, so
1885        // the two halves cannot disagree.
1886        assert!(
1887            scope.redirects_external_ref(&rto_graph::external_ref_key(
1888                "hub::cfgkey:config.toml#addr"
1889            ))
1890        );
1891    }
1892
1893    #[test]
1894    fn a_cross_repo_edge_out_of_the_workspace_keeps_its_placeholder() {
1895        // The target repo is not in this vault, so there is no note to point at.
1896        // Redirecting anyway would produce a link that resolves to nothing —
1897        // Obsidian shows that as merely unwritten, which is a worse lie than a
1898        // placeholder that honestly says "elsewhere".
1899        let ms = members(&["spoke"]);
1900        let scope = VaultScope {
1901            project: Some("spoke"),
1902            members: &ms,
1903        };
1904        let key = rto_graph::external_ref_key("elsewhere::cfgkey:config.toml#addr");
1905        assert!(!scope.redirects_external_ref(&key));
1906        let ex = node_linking_to("cfgkey:config.toml#addr", "addr", &key);
1907        let note = render_note_scoped(&ex, None, None, &scope);
1908        assert!(
1909            note.content.contains(&format!(
1910                "→ [[{}]]",
1911                note_name("spoke::extref:elsewhere::cfgkey:config.toml#addr")
1912            )),
1913            "{}",
1914            note.content
1915        );
1916    }
1917
1918    #[test]
1919    fn a_single_project_vault_never_redirects_an_external_ref() {
1920        // No members ⇒ nothing to resolve against, so today's vault keeps rendering
1921        // the placeholder exactly as it does now.
1922        let key = rto_graph::external_ref_key("hub::cfgkey:config.toml#addr");
1923        assert!(!VaultScope::PROJECT.redirects_external_ref(&key));
1924        assert_eq!(
1925            scoped_note_name(&VaultScope::PROJECT, &key),
1926            note_name(&key)
1927        );
1928    }
1929
1930    fn member_summary(project: &str, fan_in: u32) -> VaultSummary {
1931        VaultSummary {
1932            project: project.to_owned(),
1933            total_nodes: 3,
1934            total_edges: 2,
1935            node_counts: vec![("fn".into(), 2)],
1936            edge_provenance: vec![("derived".into(), 2)],
1937            adrs: vec![AdrEntry {
1938                key: "adr:0001".into(),
1939                name: "First".into(),
1940                status: Some("Accepted".into()),
1941            }],
1942            debt: vec![("todo".into(), 4)], // roteiro:ignore
1943            densest_files: vec![DensityEntry {
1944                path: "src/small.rs".into(),
1945                markers: 3,
1946                lines: 120,
1947                per_kloc: 25.0,
1948            }],
1949            config_secrets: None,
1950            most_called: vec![CouplingEntry {
1951                key: "sym:rust:a.rs#helper".into(),
1952                name: "helper".into(),
1953                fan_in,
1954                fan_out: 1,
1955            }],
1956            repo_url: Some(format!("https://github.com/org/{project}")),
1957            commit: Some("abcdef0123456789".into()),
1958        }
1959    }
1960
1961    #[test]
1962    fn the_workspace_home_keeps_every_members_own_aggregates() {
1963        // The promise in issue #442: the existing per-project `_Home` view is a
1964        // *subset* of the workspace one, not a casualty of it. Someone who came for
1965        // their repository's coupling and debt tables must still find them —
1966        // not a workspace total that averages them away.
1967        let ws = WorkspaceSummary {
1968            name: "platform".into(),
1969            members: vec![member_summary("api", 7), member_summary("sdk", 4)],
1970            cross_links: vec![],
1971            cross_links_total: 0,
1972        };
1973        let note = render_workspace_home(&ws);
1974        assert_eq!(note.filename, HOME_NOTE);
1975        assert!(
1976            note.content
1977                .contains("# platform — workspace knowledge graph")
1978        );
1979        // Summed, and the members listed.
1980        assert!(
1981            note.content
1982                .contains("**6 nodes**, **4 edges** across **2** member")
1983        );
1984        assert!(note.content.contains("| [[#api\\|api]] | 3 | 2 |"));
1985
1986        for project in ["api", "sdk"] {
1987            assert!(
1988                note.content.contains(&format!("\n## {project}\n")),
1989                "each member gets its own section"
1990            );
1991        }
1992        // Today's sections, one level deeper, once per member.
1993        for section in [
1994            "### Structure",
1995            "### Provenance",
1996            "### Decisions (ADRs)",
1997            "### Intent debt",
1998            "#### Densest files",
1999            "### Most depended-on",
2000        ] {
2001            assert_eq!(
2002                note.content.matches(section).count(),
2003                2,
2004                "`{section}` must appear once per member: {}",
2005                note.content
2006            );
2007        }
2008        // And every link inside a member's section resolves within that member.
2009        assert!(note.content.contains(&format!(
2010            "**Accepted** — [[{}|First]]",
2011            note_name("api::adr:0001")
2012        )));
2013        assert!(note.content.contains(&format!(
2014            "**Accepted** — [[{}|First]]",
2015            note_name("sdk::adr:0001")
2016        )));
2017        assert!(note.content.contains(&format!(
2018            "[[{}\\|helper]] | 7 |",
2019            note_name("api::sym:rust:a.rs#helper")
2020        )));
2021        assert!(note.content.contains(&format!(
2022            "[[{}\\|src/small.rs]]",
2023            note_name("sdk::file:src/small.rs")
2024        )));
2025    }
2026
2027    #[test]
2028    fn the_workspace_home_renders_cross_repo_links_and_marks_the_ones_it_cannot_follow() {
2029        let ws = WorkspaceSummary {
2030            name: "platform".into(),
2031            members: vec![member_summary("api", 7), member_summary("sdk", 4)],
2032            cross_links: vec![
2033                CrossLink {
2034                    from_project: "sdk".into(),
2035                    from_key: "cfgkey:config.toml#addr".into(),
2036                    from_name: "addr".into(),
2037                    kind: "links".into(),
2038                    confidence: Some(0.91),
2039                    to_qualified: "api::cfgkey:config.toml#addr".into(),
2040                    resolves: true,
2041                },
2042                CrossLink {
2043                    from_project: "sdk".into(),
2044                    from_key: "cfgkey:config.toml#other".into(),
2045                    from_name: "other".into(),
2046                    kind: "links".into(),
2047                    confidence: None,
2048                    to_qualified: "absent::cfgkey:config.toml#other".into(),
2049                    resolves: false,
2050                },
2051            ],
2052            cross_links_total: 2,
2053        };
2054        let note = render_workspace_home(&ws);
2055        // Resolvable: a link to the other member's note, with its confidence.
2056        assert!(
2057            note.content.contains(&format!(
2058                "| [[{}\\|addr]] | sdk | [[{}\\|api::cfgkey:config.toml#addr]] | links (0.91) |",
2059                note_name("sdk::cfgkey:config.toml#addr"),
2060                note_name("api::cfgkey:config.toml#addr"),
2061            )),
2062            "{}",
2063            note.content
2064        );
2065        // Outside the workspace: stated as such, never as a wikilink — Obsidian
2066        // renders a link to a missing note as one that is merely unwritten.
2067        assert!(
2068            note.content
2069                .contains("`absent::cfgkey:config.toml#other` *(outside this workspace)*"),
2070            "{}",
2071            note.content
2072        );
2073        assert!(
2074            !note.content.contains("[[absent-"),
2075            "a dangling wikilink would read as a note someone forgot to write: {}",
2076            note.content
2077        );
2078    }
2079
2080    #[test]
2081    fn the_workspace_home_says_when_it_has_truncated_the_cross_links() {
2082        // A capped table that does not say it is capped reads as the whole set.
2083        let ws = WorkspaceSummary {
2084            name: "platform".into(),
2085            members: vec![member_summary("api", 7)],
2086            cross_links: vec![CrossLink {
2087                from_project: "api".into(),
2088                from_key: "cfgkey:config.toml#addr".into(),
2089                from_name: "addr".into(),
2090                kind: "links".into(),
2091                confidence: None,
2092                to_qualified: "api::cfgkey:config.toml#addr".into(),
2093                resolves: true,
2094            }],
2095            cross_links_total: 40,
2096        };
2097        let note = render_workspace_home(&ws);
2098        assert!(note.content.contains("Showing 1 of 40"), "{}", note.content);
2099        assert!(note.content.contains("roteiro links --matrix"));
2100    }
2101
2102    #[test]
2103    fn a_workspace_with_no_cross_repo_links_says_why_rather_than_showing_nothing() {
2104        let ws = WorkspaceSummary {
2105            name: "platform".into(),
2106            members: vec![member_summary("api", 7)],
2107            cross_links: vec![],
2108            cross_links_total: 0,
2109        };
2110        let note = render_workspace_home(&ws);
2111        assert!(note.content.contains("## Cross-repo links"));
2112        assert!(
2113            note.content.contains("links --infer --write"),
2114            "an empty section must name what would fill it, or it reads as \
2115             \"these repos are unrelated\": {}",
2116            note.content
2117        );
2118        // Singular, because getting this wrong on a one-member workspace is the
2119        // kind of thing nobody notices until it ships.
2120        assert!(note.content.contains("**1** member repository."));
2121    }
2122
2123    // ---- YAML frontmatter escaping -------------------------------------------
2124
2125    /// Parse a note's frontmatter block with a **real** YAML parser and return
2126    /// `field`'s value, or the parse error.
2127    ///
2128    /// Every assertion below goes through this rather than checking the emitted
2129    /// bytes. An escaper that is wrong in a self-consistent way passes a
2130    /// byte-comparison — that is precisely how `"foo\bar"` survived: it looks
2131    /// exactly like what was asked for, and means something else.
2132    fn frontmatter_field(note: &str, field: &str) -> Result<Option<String>, String> {
2133        let block = note
2134            .strip_prefix("---\n")
2135            .and_then(|rest| rest.split_once("\n---\n"))
2136            .map(|(block, _)| block)
2137            .expect("note must open with a frontmatter block");
2138        let docs = yaml_rust2::YamlLoader::load_from_str(block).map_err(|e| e.to_string())?;
2139        Ok(docs[0][field].as_str().map(ToOwned::to_owned))
2140    }
2141
2142    /// A node whose key, path and language are whatever the test needs.
2143    fn node_with(key: &str, path: Option<&str>, lang: Option<&str>) -> Explanation {
2144        Explanation {
2145            schema: rto_graph::SCHEMA,
2146            node: NodeSummary {
2147                key: key.into(),
2148                kind: "fn".into(),
2149                name: "n".into(),
2150                path: path.map(ToOwned::to_owned),
2151                lang: lang.map(ToOwned::to_owned),
2152            },
2153            meta: serde_json::Value::Null,
2154            outgoing: vec![],
2155            incoming: vec![],
2156        }
2157    }
2158
2159    /// The three measured failure modes of the escaping this replaced, each
2160    /// asserted on the **parsed** value.
2161    ///
2162    /// Before the fix: `foo\bar` parsed back as `foo<BS>ar` (silently six
2163    /// characters, not seven), and the other two made the whole block
2164    /// unparseable — which in Obsidian costs the note *every* property, with no
2165    /// error shown.
2166    #[test]
2167    fn a_backslash_or_quote_in_a_path_still_parses_back_to_itself() {
2168        for path in [
2169            r"foo\bar",     // `\b` was YAML's backspace escape: silent corruption
2170            r"foo\dir",     // `\d` is not a YAML escape at all: parse error
2171            "say\"hi\".rs", // an unescaped `"` ended the scalar early: parse error
2172            r"a\\b",
2173            "trailing-backslash\\",
2174        ] {
2175            let note = render_note(&node_with("file:x", Some(path), None), None, None);
2176            assert_eq!(
2177                frontmatter_field(&note.content, "path"),
2178                Ok(Some(path.to_owned())),
2179                "path {path:?} must round-trip"
2180            );
2181        }
2182    }
2183
2184    /// `key:` is not hypothetical for this: node keys already carry `:` and `#`,
2185    /// and a symbol name can contain a quotation mark.
2186    #[test]
2187    fn a_node_key_round_trips_whatever_punctuation_it_carries() {
2188        for key in [
2189            "sym:rust:src/a.rs#Store",
2190            r"sym:rust:src\weird.rs#Thing",
2191            "sym:rust:a.rs#say\"hi\"",
2192            "cfgkey:config.toml#serve.addr",
2193        ] {
2194            let note = render_note(&node_with(key, None, None), None, None);
2195            assert_eq!(
2196                frontmatter_field(&note.content, "key"),
2197                Ok(Some(key.to_owned())),
2198                "key {key:?} must round-trip"
2199            );
2200        }
2201        // The old rule turned a `"` into an apostrophe, so the note reported a key
2202        // that was not the node's key — parseable, and wrong.
2203        let note = render_note(
2204            &node_with("sym:rust:a.rs#say\"hi\"", None, None),
2205            None,
2206            None,
2207        );
2208        assert!(
2209            !note.content.contains("say'hi'"),
2210            "a quotation mark must be escaped, not rewritten: {}",
2211            note.content
2212        );
2213    }
2214
2215    /// A member directory name is a path component, so it reaches the same rule.
2216    #[test]
2217    fn a_member_project_name_round_trips() {
2218        let ms: std::collections::BTreeSet<String> =
2219            std::iter::once(r"odd\name".to_owned()).collect();
2220        let note = render_note_scoped(
2221            &node_with("file:x", None, None),
2222            None,
2223            None,
2224            &VaultScope {
2225                project: Some(r"odd\name"),
2226                members: &ms,
2227            },
2228        );
2229        assert_eq!(
2230            frontmatter_field(&note.content, "project"),
2231            Ok(Some(r"odd\name".to_owned()))
2232        );
2233    }
2234
2235    /// The **bare** fields are the other half of the same class, and were missed
2236    /// by the review that found the quoted ones: `status` is written unquoted, and
2237    /// `roteiro load` installs a caller-supplied artifact whose nodes carry
2238    /// whatever they carry.
2239    #[test]
2240    fn a_bare_field_is_quoted_only_when_being_bare_would_change_it() {
2241        let with_status = |status: &str| {
2242            let mut ex = node_with("adr:0001", None, None);
2243            ex.meta = serde_json::json!({ "status": status });
2244            render_note(&ex, None, None)
2245        };
2246
2247        // Would be a parse error bare; would silently truncate bare.
2248        for status in [
2249            "Accepted: superseded by 0012",
2250            "Accepted # pending",
2251            "{draft}",
2252            "",
2253        ] {
2254            let note = with_status(status);
2255            assert_eq!(
2256                frontmatter_field(&note.content, "status"),
2257                Ok(Some(status.to_owned())),
2258                "status {status:?} must round-trip"
2259            );
2260        }
2261
2262        // …and a safe one stays bare, which is what keeps an existing vault's
2263        // bytes unchanged.
2264        let note = with_status("Accepted");
2265        assert!(
2266            note.content.contains("\nstatus: Accepted\n"),
2267            "a plain-safe status must not gain quotes: {}",
2268            note.content
2269        );
2270    }
2271
2272    /// `no` is Norwegian, and a bare `no` reads as `false` to a YAML **1.1**
2273    /// parser.
2274    ///
2275    /// The only assertion here that pins emitted bytes, and deliberately so:
2276    /// `yaml-rust2` implements YAML 1.2, whose core schema resolves a bare `no`
2277    /// to the *string* `no`, so a round-trip through this test's own oracle
2278    /// cannot see the problem — it passes either way. The exposure is to the
2279    /// parser on the other side, and Obsidian's is not this one. Quoting costs
2280    /// two characters on a value that never occurs here; guessing which YAML
2281    /// version every downstream reader implements does not seem like the better
2282    /// bet.
2283    #[test]
2284    fn a_language_that_spells_a_yaml_boolean_is_quoted() {
2285        let note = render_note(&node_with("file:x", None, Some("no")), None, None);
2286        assert!(
2287            note.content.contains("\nlang: \"no\"\n"),
2288            "a bare `no` is `false` to a 1.1 parser and must be quoted: {}",
2289            note.content
2290        );
2291        assert_eq!(
2292            frontmatter_field(&note.content, "lang"),
2293            Ok(Some("no".to_owned())),
2294            "and it must still read back as the string: {}",
2295            note.content
2296        );
2297        // And an ordinary language is untouched.
2298        let rust = render_note(&node_with("file:x", None, Some("rust")), None, None);
2299        assert!(rust.content.contains("\nlang: rust\n"), "{}", rust.content);
2300    }
2301
2302    /// Control characters and the separators some parsers fold as line breaks.
2303    #[test]
2304    fn control_characters_cannot_break_out_of_the_block() {
2305        for path in [
2306            "a\nb",
2307            "a\tb",
2308            "a\u{0}b",
2309            "a\u{2028}b",
2310            "a\u{7f}b",
2311            "a\u{85}b",
2312        ] {
2313            let note = render_note(&node_with("file:x", Some(path), None), None, None);
2314            assert_eq!(
2315                frontmatter_field(&note.content, "path"),
2316                Ok(Some(path.to_owned())),
2317                "path {path:?} must round-trip"
2318            );
2319            // A raw newline would end the scalar and inject a sibling key.
2320            assert_eq!(
2321                note.content.matches("\npath: ").count(),
2322                1,
2323                "the value must stay on one line: {}",
2324                note.content
2325            );
2326        }
2327    }
2328
2329    /// The escaping is *only* an escaping: for a value with nothing to escape it
2330    /// must emit the same bytes it always did, or #442's promise that a
2331    /// single-project vault is byte-identical does not hold.
2332    #[test]
2333    fn an_ordinary_value_is_emitted_exactly_as_before() {
2334        let note = render_note(
2335            &node_with("sym:rust:src/a.rs#Store", Some("src/a.rs"), Some("rust")),
2336            None,
2337            None,
2338        );
2339        assert!(
2340            note.content
2341                .contains("\nkey: \"sym:rust:src/a.rs#Store\"\n")
2342        );
2343        assert!(note.content.contains("\nkind: fn\n"));
2344        assert!(note.content.contains("\npath: \"src/a.rs\"\n"));
2345        assert!(note.content.contains("\nlang: rust\n"));
2346    }
2347
2348    /// The plain-style decision is checked against a real parser rather than
2349    /// against itself: whatever `is_plain_safe` accepts must actually round-trip
2350    /// bare, and whatever it rejects must round-trip quoted.
2351    #[test]
2352    fn the_plain_style_decision_agrees_with_a_real_yaml_parser() {
2353        for value in [
2354            "fn",
2355            "config_key",
2356            "rust",
2357            "Accepted",
2358            "a.b",
2359            "a/b",
2360            "a-b_c",
2361            "no",
2362            "yes",
2363            "true",
2364            "null",
2365            "y",
2366            "N",
2367            "",
2368            " lead",
2369            "trail ",
2370            "a: b",
2371            "a #c",
2372            "{x}",
2373            "[x]",
2374            "*x",
2375            "&x",
2376            "!x",
2377            "#x",
2378            ">x",
2379            "|x",
2380            "%x",
2381            "@x",
2382            "`x",
2383            "\"x",
2384            "'x",
2385            ",x",
2386            "123",
2387            "1.5",
2388            "-x",
2389            ".x",
2390            "a\\b",
2391        ] {
2392            let emitted = super::yaml_scalar(value);
2393            let doc = format!("v: {emitted}");
2394            let parsed = yaml_rust2::YamlLoader::load_from_str(&doc)
2395                .unwrap_or_else(|e| panic!("{value:?} emitted {emitted:?}: {e}"));
2396            assert_eq!(
2397                parsed[0]["v"].as_str(),
2398                Some(value),
2399                "{value:?} emitted as {emitted:?} did not round-trip"
2400            );
2401        }
2402    }
2403}