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