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.
643 pub repo_url: Option<String>,
644 /// Hex commit the graph was rendered from, for a permalink note.
645 pub commit: Option<String>,
646}
647
648/// Render the vault's overview note: what was scanned, the structure by kind,
649/// the provenance breakdown, the decisions (ADRs) and their status, the
650/// intent-debt summary, and how to navigate. The entry point for the vault.
651#[must_use]
652pub fn render_home(s: &VaultSummary) -> VaultNote {
653 let mut c = String::new();
654 c.push_str("---\ntags:\n - roteiro/home\n---\n\n");
655 let _ = writeln!(c, "# {} — knowledge graph", s.project);
656 c.push_str(
657 "\n*A browsable snapshot of this codebase as one **knowledge graph**, \
658 generated by [Roteiro](https://roteiro.dev). Every symbol, document and \
659 decision is a note, linked to the things it relates to.*\n",
660 );
661 c.push_str(HOW_TO_READ);
662 let _ = writeln!(
663 c,
664 "\n**{} nodes**, **{} edges** across the project.",
665 s.total_nodes, s.total_edges
666 );
667 write_repo_line(&mut c, s);
668 write_summary_sections(&mut c, s, &VaultScope::PROJECT, 2);
669 c.push_str(NAVIGATING);
670
671 VaultNote {
672 filename: HOME_NOTE.to_owned(),
673 content: c,
674 }
675}
676
677/// The "how to read a note" paragraph. Shared verbatim by the single-project and
678/// workspace overviews — the notes themselves are identical in both, so a reader
679/// who learns the format once has learned it for either.
680const HOW_TO_READ: &str = "\n**How to read it.** Open any note to see what a thing is, the intent or \
681 docs behind it (its **Content**), where it lives (its **Source** link), \
682 and how it connects (**Outgoing**/**Incoming** links). Each link is \
683 labelled with how the fact was established — `derived` (extracted from \
684 code), `authored` (human intent: ADRs, blueprints, annotations), or \
685 `inferred` (a scored suggestion). Open Obsidian's **graph view** to see \
686 the whole thing at once.\n";
687
688/// The closing navigation section.
689const NAVIGATING: &str = "\n## Navigating this vault\n\n\
690 - Open the **graph view** to see the whole codebase; notes are coloured/\
691 filterable by their `roteiro/kind/*`, `roteiro/lang/*` and \
692 `roteiro/status/*` tags.\n\
693 - Each note carries its captured **content** (doc comments, prose, PDF/\
694 image text) and its provenance-labelled incoming/outgoing links.\n\
695 - Start from an ADR above, or search the tag pane for a kind.\n";
696
697/// `**Repository:** …` — the web root and the commit the graph was rendered from.
698fn write_repo_line(c: &mut String, s: &VaultSummary) {
699 if let Some(repo) = &s.repo_url {
700 let _ = write!(c, "\n**Repository:** [{repo}]({repo})");
701 if let Some(commit) = &s.commit {
702 let short = &commit[..commit.len().min(12)];
703 let _ = write!(c, " · rendered at commit `{short}`");
704 }
705 c.push('\n');
706 }
707}
708
709/// Every aggregate the overview carries for **one project**: structure by kind,
710/// provenance, ADRs, intent debt (and where it is densest), the config-secret
711/// inventory and directed call coupling.
712///
713/// Factored out of [`render_home`] so a workspace vault's per-member section is
714/// *the same code*, not a reimplementation that can drift: the promise in issue
715/// #442 is that today's per-project view stays a **subset** of the workspace one
716/// rather than a casualty of it. `level` is the markdown heading depth — 2 for a
717/// single-project `_Home`, 3 inside a member's section — and `scope` decides
718/// whether the wikilinks point at bare or project-qualified notes.
719fn write_summary_sections(c: &mut String, s: &VaultSummary, scope: &VaultScope<'_>, level: usize) {
720 let hd = &"#".repeat(level);
721 let sub = &"#".repeat(level + 1);
722 write_structure(c, s, hd);
723 write_decisions(c, s, scope, hd);
724 write_debt(c, s, scope, hd, sub);
725 write_config_secrets(c, s, scope, hd);
726 write_coupling(c, s, scope, hd);
727}
728
729/// `Structure` (nodes by kind) and `Provenance` (edges by how they were established).
730fn write_structure(c: &mut String, s: &VaultSummary, hd: &str) {
731 let _ = write!(c, "\n{hd} Structure\n\n| Kind | Count |\n| --- | --- |\n");
732 for (kind, n) in &s.node_counts {
733 let _ = writeln!(c, "| {kind} | {n} |");
734 }
735
736 if !s.edge_provenance.is_empty() {
737 let _ = write!(
738 c,
739 "\n{hd} Provenance\n\n| Provenance | Edges |\n| --- | --- |\n"
740 );
741 for (prov, n) in &s.edge_provenance {
742 let _ = writeln!(c, "| {prov} | {n} |");
743 }
744 }
745}
746
747/// `Decisions (ADRs)` — the recorded decisions and their lifecycle status.
748fn write_decisions(c: &mut String, s: &VaultSummary, scope: &VaultScope<'_>, hd: &str) {
749 let _ = write!(c, "\n{hd} Decisions (ADRs)\n\n");
750 if s.adrs.is_empty() {
751 c.push_str("*No ADRs found.*\n");
752 } else {
753 for adr in &s.adrs {
754 let status = adr.status.as_deref().unwrap_or("—");
755 let _ = writeln!(
756 c,
757 "- **{status}** — [[{}|{}]]",
758 scoped_note_name(scope, &adr.key),
759 adr.name
760 );
761 }
762 }
763}
764
765/// `Intent debt` — the marker categories, and the files the debt is densest in.
766fn write_debt(c: &mut String, s: &VaultSummary, scope: &VaultScope<'_>, hd: &str, sub: &str) {
767 let _ = write!(c, "\n{hd} Intent debt\n\n");
768 if s.debt.is_empty() {
769 c.push_str("*None recorded.*\n");
770 } else {
771 c.push_str("| Category | Count |\n| --- | --- |\n");
772 for (cat, n) in &s.debt {
773 let _ = writeln!(c, "| {cat} | {n} |");
774 }
775 }
776
777 if !s.densest_files.is_empty() {
778 let _ = write!(
779 c,
780 "\n{sub} Densest files (markers per 1,000 lines)\n\n\
781 *Where the debt above is concentrated, rather than where there is \
782 most of it — a raw count ranks the biggest file first by \
783 construction. The denominator is file length: every line, blanks and \
784 comments included, not source lines of code. Prose matches (`for \
785 now`, `tbd`) count too, so a design document can rank high.*\n\n"
786 );
787 c.push_str("| File | Markers | Lines | Per 1k |\n| --- | --- | --- | --- |\n");
788 for e in &s.densest_files {
789 let _ = writeln!(
790 c,
791 "| [[{}\\|{}]] | {} | {} | {:.2} |",
792 scoped_note_name(scope, &format!("file:{}", e.path)),
793 e.path,
794 e.markers,
795 e.lines,
796 e.per_kloc
797 );
798 }
799 }
800}
801
802/// `Config keys named like secrets` — an inventory and its unconditional caveat.
803fn write_config_secrets(c: &mut String, s: &VaultSummary, scope: &VaultScope<'_>, hd: &str) {
804 if let Some(cs) = &s.config_secrets {
805 let _ = write!(c, "\n{hd} Config keys named like secrets\n\n");
806 let _ = writeln!(
807 c,
808 "**{}** secret-named config key(s): {} redacted before storage, {} \
809 declared in code without a value, {} unredacted.",
810 cs.secret_named, cs.redacted, cs.declared, cs.unredacted
811 );
812 if cs.unredacted > 0 {
813 let _ = writeln!(
814 c,
815 "\n> [!warning] {} key(s) carry an **unredacted** value. Extraction \
816 always redacts, so these came from an import layer — inspect the \
817 importing tool, not this repository.",
818 cs.unredacted
819 );
820 }
821 if !cs.files.is_empty() {
822 c.push_str("\nIn:\n");
823 for path in &cs.files {
824 let _ = writeln!(
825 c,
826 "- [[{}\\|{path}]]",
827 scoped_note_name(scope, &format!("file:{path}"))
828 );
829 }
830 }
831 // The caveat is unconditional and comes last, so it is the final thing read
832 // in this section. A vault note is browsed out of context; this is exactly
833 // where "config keys named like secrets" would otherwise be misread as a
834 // secret scan that came back clean.
835 c.push_str(
836 "\n*An inventory of config keys whose **names** look secret, not a secret \
837 scan. Values are redacted before they are stored, so this reports that \
838 such keys exist and were redacted — never a value. It cannot see a \
839 hardcoded credential in source code, cannot judge whether a value is \
840 valid, and cannot tell a real secret from a placeholder. A credential \
841 under an innocuous key name (`dsn`, `endpoint`) does not appear here at \
842 all, so this section being small says nothing about whether this \
843 repository leaks secrets.*\n",
844 );
845 }
846}
847
848/// `Most depended-on (call fan-in)` — directed call coupling, capped.
849fn write_coupling(c: &mut String, s: &VaultSummary, scope: &VaultScope<'_>, hd: &str) {
850 if !s.most_called.is_empty() {
851 let _ = write!(
852 c,
853 "\n{hd} Most depended-on (call fan-in)\n\n\
854 *Distinct callers and callees over `calls` edges — direction kept, so \
855 \"everything calls this\" and \"this calls everything\" are not the same \
856 row. Call targets are resolved by simple name, so a short, generically-\
857 named function can absorb every call to that name: read a large fan-in on \
858 one as a question, not a finding.*\n\n"
859 );
860 c.push_str("| Symbol | Called by | Calls |\n| --- | --- | --- |\n");
861 for e in &s.most_called {
862 let _ = writeln!(
863 c,
864 "| [[{}\\|{}]] | {} | {} |",
865 scoped_note_name(scope, &e.key),
866 e.name,
867 e.fan_in,
868 e.fan_out
869 );
870 }
871 }
872}
873
874/// One cross-repo edge the workspace vault can actually follow: a spoke's node
875/// linking to a hub's, through the external-ref placeholder ADR-0009 persists.
876///
877/// Collected by the caller, which has every member's store open; the renderer
878/// only lays them out. Nothing here is a new edge — these are the `inferred`
879/// links `roteiro links` already reports, rendered for the first time.
880#[derive(Debug, Clone)]
881pub struct CrossLink {
882 /// The member the edge starts in.
883 pub from_project: String,
884 /// The source node's key, within `from_project`.
885 pub from_key: String,
886 /// The source node's display name.
887 pub from_name: String,
888 /// The edge kind (`links`, …).
889 pub kind: String,
890 /// Confidence, for an `inferred` edge.
891 pub confidence: Option<f64>,
892 /// Whether this link was **declared** (`[[links]]` in the source repo's
893 /// config, ADR-0009) rather than inferred by key matching.
894 ///
895 /// The distinction is the whole of ADR-0009's `authored → gold,
896 /// inferred → slate`: a declaration is a statement of intent by someone who
897 /// knows the topology, a match is a candidate. Until #573 the vault could not
898 /// draw it, because nothing persisted an authored cross-repo edge — so this
899 /// section carried a blanket caveat saying every row was a candidate.
900 pub authored: bool,
901 /// The project-qualified target, `<project>::<key>` (ADR-0009).
902 pub to_qualified: String,
903 /// Whether `to_qualified`'s project is a member of this workspace — and so
904 /// whether the link resolves to a note in this vault, or dangles because the
905 /// target repository is outside it.
906 pub resolves: bool,
907}
908
909/// Aggregate figures for a **workspace** vault's `_Home` overview: the members,
910/// each with exactly the aggregates a single-project `_Home` carries, plus the
911/// cross-repo links between them.
912#[derive(Debug, Clone, Default)]
913pub struct WorkspaceSummary {
914 /// The workspace name (`--workspace-name`).
915 pub name: String,
916 /// One entry per member repository, in stable name order. Each is the very
917 /// same [`VaultSummary`] a per-project vault would render.
918 pub members: Vec<VaultSummary>,
919 /// Cross-repo links between members, already ordered and capped by the caller.
920 pub cross_links: Vec<CrossLink>,
921 /// Cross-repo links found in total, which `cross_links` may be a capped view
922 /// of — so the section can say what it is not showing.
923 pub cross_links_total: usize,
924 /// How many of `cross_links_total` were **declared** (`[[links]]`) rather
925 /// than inferred.
926 ///
927 /// Counted before the cap, not from `cross_links`: that vector is truncated
928 /// to [`WORKSPACE_CROSS_LINK_ROWS`](crate::WORKSPACE_CROSS_LINK_ROWS) rows,
929 /// so counting it would describe the rows on screen while reading as a
930 /// statement about the workspace — a caption that quietly changes meaning
931 /// once a workspace grows past the cap.
932 pub cross_links_authored: usize,
933}
934
935/// Render a **workspace** vault's overview: the members and their scale, the
936/// cross-repo links between them, and then each member's own aggregates —
937/// structure, provenance, ADRs, intent debt, config-secret inventory and call
938/// coupling — under its own heading.
939///
940/// The per-member sections are rendered by the same [`write_summary_sections`]
941/// the single-project `_Home` uses, so the existing view is a **subset** of this
942/// one: someone who came for their repository's coupling and debt tables finds
943/// them, rather than a workspace total that averages them away.
944#[must_use]
945pub fn render_workspace_home(ws: &WorkspaceSummary) -> VaultNote {
946 let members: std::collections::BTreeSet<String> =
947 ws.members.iter().map(|m| m.project.clone()).collect();
948
949 let mut c = String::new();
950 c.push_str("---\ntags:\n - roteiro/home\n - roteiro/workspace\n---\n\n");
951 let _ = writeln!(c, "# {} — workspace knowledge graph", ws.name);
952 c.push_str(
953 "\n*A browsable snapshot of a whole **workspace** as one **knowledge \
954 graph**, generated by [Roteiro](https://roteiro.dev). Every symbol, \
955 document and decision in every member repository is a note, linked to the \
956 things it relates to — including across repositories.*\n",
957 );
958 c.push_str(HOW_TO_READ);
959 // The example is *rendered* by `note_name` rather than spelled out. A
960 // hand-written spelling of this sentence survived #574 unchanged, so every
961 // vault v2.0.0 built stated the pre-#574 naming rule — on the first page a
962 // reader opens — while `note_name` was writing something else. This is the
963 // one copy that lives in the crate defining the rule, so it can simply ask:
964 // a derived example cannot drift, and a spelled one already has.
965 //
966 // The *key* it names has to be real too, or the fix trades one false
967 // sentence in `_Home` for another: `<member>::file:README.md` was fabricated
968 // from the member list, and workspace membership does not require a README.
969 // A cross-repo link's **source** end is the strongest key available here —
970 // `from_project` is a member by definition and `from_key` is a node in that
971 // member's own store, which the Cross-repo links table below already links
972 // to by name. The *target* end will not do: `resolves == false` means the
973 // target repository is outside this vault, so `to_qualified` names no note
974 // here — the same false claim one remove away.
975 //
976 // With no cross-repo links there is no key this function can prove is a
977 // node, so the sentence says nothing rather than inventing one. The rule it
978 // states is complete without an example; only the illustration is lost.
979 let example = ws.cross_links.first().map_or_else(String::new, |l| {
980 let key = format!("{}::{}", l.from_project, l.from_key);
981 format!(" Here, `{key}` is the note `{}.md`.", note_name(&key))
982 });
983 let _ = writeln!(
984 c,
985 "\n**Every note is keyed `<project>::<key>`**, because a node key is \
986 repository-relative: the same path or symbol can occur in more than one \
987 member, and without the project the second note would overwrite the \
988 first. A note's *filename* is derived from that key — a readable \
989 lowercase hint, then a hash of the whole key — so no filename contains \
990 `::`.{example} Filter the graph view by a member's `roteiro/project/*` \
991 tag to see one repository at a time."
992 );
993
994 let total_nodes: usize = ws.members.iter().map(|m| m.total_nodes).sum();
995 let total_edges: usize = ws.members.iter().map(|m| m.total_edges).sum();
996 let _ = writeln!(
997 c,
998 "\n**{total_nodes} nodes**, **{total_edges} edges** across **{}** member \
999 repositor{}.",
1000 ws.members.len(),
1001 if ws.members.len() == 1 { "y" } else { "ies" }
1002 );
1003
1004 c.push_str("\n## Members\n\n| Project | Nodes | Edges | Repository | Commit |\n| --- | --- | --- | --- | --- |\n");
1005 for m in &ws.members {
1006 let repo = m
1007 .repo_url
1008 .as_ref()
1009 .map_or_else(|| "—".to_owned(), |u| format!("[{u}]({u})"));
1010 let commit = m.commit.as_ref().map_or_else(
1011 || "—".to_owned(),
1012 |c| format!("`{}`", &c[..c.len().min(12)]),
1013 );
1014 let _ = writeln!(
1015 c,
1016 "| [[#{}\\|{}]] | {} | {} | {repo} | {commit} |",
1017 m.project, m.project, m.total_nodes, m.total_edges
1018 );
1019 }
1020 c.push_str(
1021 "\n*The `Repository` and `Commit` columns say where each member came from \
1022 and what was read. They are **not** a replication manifest — reconstructing \
1023 a workspace from a vault is issue #442 part 2, and nothing here is designed \
1024 to be handed to someone else.*\n",
1025 );
1026
1027 write_cross_links(&mut c, ws);
1028
1029 for m in &ws.members {
1030 let _ = writeln!(c, "\n## {}", m.project);
1031 let _ = writeln!(
1032 c,
1033 "\n**{} nodes**, **{} edges** in this member.",
1034 m.total_nodes, m.total_edges
1035 );
1036 write_repo_line(&mut c, m);
1037 let scope = VaultScope {
1038 project: Some(&m.project),
1039 members: &members,
1040 };
1041 write_summary_sections(&mut c, m, &scope, 3);
1042 }
1043
1044 c.push_str(NAVIGATING);
1045
1046 VaultNote {
1047 filename: HOME_NOTE.to_owned(),
1048 content: c,
1049 }
1050}
1051
1052/// The `## Cross-repo links` section: the edges that only a workspace vault can
1053/// show, and the honest statement of what is missing from them.
1054fn write_cross_links(c: &mut String, ws: &WorkspaceSummary) {
1055 c.push_str("\n## Cross-repo links\n\n");
1056 if ws.cross_links.is_empty() {
1057 c.push_str(
1058 "*None. These are the `inferred` cross-repo links `roteiro links \
1059 --infer --write` persists (ADR-0009); a workspace whose members have \
1060 never been inferred over has none recorded yet.*\n",
1061 );
1062 return;
1063 }
1064 // The caveat is per-row now, because the two provenances are no longer the
1065 // same claim: an **authored** row was declared by someone who knows the
1066 // topology, an **inferred** row is a scored guess. Saying "these are all
1067 // candidates" over a table containing declarations would understate the
1068 // declarations exactly as saying nothing would overstate the matches.
1069 let authored = ws.cross_links_authored;
1070 // `saturating_sub`: `WorkspaceSummary` is public, so a caller can hand us a
1071 // count larger than the total. In release that subtraction wraps and the
1072 // caption reports billions of inferred links — a rendering function should
1073 // not be the place an inconsistent input becomes nonsense. The debug
1074 // assertion says which caller was wrong, in the build that can afford to.
1075 debug_assert!(
1076 authored <= ws.cross_links_total,
1077 "cross_links_authored ({authored}) exceeds cross_links_total ({})",
1078 ws.cross_links_total
1079 );
1080 let inferred = ws.cross_links_total.saturating_sub(authored);
1081 let _ = writeln!(
1082 c,
1083 "*A spoke's config key and the hub key it corresponds to, across \
1084 repositories — the one thing a per-project vault structurally cannot \
1085 show. **{authored} declared** (`[[links]]`, ADR-0009 — a statement of \
1086 intent) and **{inferred} inferred** (`roteiro links --infer --write` — \
1087 read those as candidate correspondences).*\n"
1088 );
1089 c.push_str("| From | | To | Kind |\n| --- | --- | --- | --- |\n");
1090 for l in &ws.cross_links {
1091 let from_scope = VaultScope {
1092 project: Some(&l.from_project),
1093 members: &NO_MEMBERS,
1094 };
1095 let to = if l.resolves {
1096 format!("[[{}\\|{}]]", note_name(&l.to_qualified), l.to_qualified)
1097 } else {
1098 // Outside this workspace: there is no note to link to, and a wikilink
1099 // to a note that does not exist reads in Obsidian as one that is
1100 // merely unwritten.
1101 format!("`{}` *(outside this workspace)*", l.to_qualified)
1102 };
1103 // `declared` rather than a confidence score: an authored link carries no
1104 // score by construction, so an empty cell there would read as "confidence
1105 // unknown" instead of "not that kind of claim".
1106 let how = if l.authored {
1107 " *(declared)*".to_owned()
1108 } else {
1109 confidence(l.confidence)
1110 };
1111 let _ = writeln!(
1112 c,
1113 "| [[{}\\|{}]] | {} | {to} | {}{how} |",
1114 scoped_note_name(&from_scope, &l.from_key),
1115 l.from_name,
1116 l.from_project,
1117 l.kind,
1118 );
1119 }
1120 if ws.cross_links_total > ws.cross_links.len() {
1121 let _ = writeln!(
1122 c,
1123 "\n*Showing {} of {} — the full report is `roteiro links --matrix`.*",
1124 ws.cross_links.len(),
1125 ws.cross_links_total
1126 );
1127 }
1128 c.push_str(
1129 "\n*Shown in one direction only. The edge lives in the spoke's store, \
1130 pointing at a local placeholder for the hub's node, so the hub's own note \
1131 carries no matching **Incoming** entry — Obsidian's **Backlinks** pane \
1132 still shows it, because the link is in the vault.*\n",
1133 );
1134}
1135
1136#[cfg(test)]
1137mod tests {
1138 use super::{
1139 AdrEntry, ConfigSecretSummary, CouplingEntry, CrossLink, DensityEntry, HOME_NOTE,
1140 VaultScope, VaultSummary, WorkspaceSummary, note_name, render_home, render_note,
1141 render_note_scoped, render_workspace_home, scoped_note_name,
1142 };
1143 use rto_graph::{EdgeRef, Explanation, NodeSummary};
1144
1145 /// The shape of a name, pinned once so a change to it is a deliberate edit
1146 /// here rather than a diff spread over twenty other assertions.
1147 ///
1148 /// Everything else in this module composes `note_name` instead of repeating
1149 /// its output, because those tests are about *which key a link points at* and
1150 /// were never about the spelling.
1151 #[test]
1152 fn note_name_is_a_lowercase_hint_and_a_hash_of_the_whole_key() {
1153 assert_eq!(
1154 note_name("sym:rust:src/a.rs#Store"),
1155 "sym-rust-src-a.rs-store-b4cbf6633003361f"
1156 );
1157 assert_eq!(note_name("adr:0001"), "adr-0001-559a2e837953b2ff");
1158 assert_eq!(
1159 note_name("file:src/main.rs"),
1160 "file-src-main.rs-4a72627453f6780e"
1161 );
1162 // Deterministic: the suffix is a pure function of the key, so a vault
1163 // renders the same names on every machine and every run.
1164 assert_eq!(note_name("adr:0001"), note_name("adr:0001"));
1165 }
1166
1167 /// **The property `note_name` exists to have** (issue #574): distinct keys
1168 /// give distinct notes *on a case-folding filesystem*, which is where the
1169 /// vault was losing them.
1170 ///
1171 /// Asserted over lowercased names, not names. On macOS and Windows two names
1172 /// differing only in case are one file, so a name set that is distinct as
1173 /// strings can still be a vault with notes missing — and Linux CI cannot see
1174 /// it. Folding here makes the assertion say what the filesystem says, on
1175 /// every platform.
1176 ///
1177 /// The keys are the two mechanisms that were actually losing notes, taken
1178 /// from this repository's own render rather than invented: the vendored
1179 /// `cytoscape.min.js` bundle whose minified single-letter symbols differ only
1180 /// by a sigil or by case, and a pair of grouped Rust `use` keys differing
1181 /// only by a trailing comma. `render_cli` runs the same assertion end to end
1182 /// over a rendered vault; this is the unit-level statement of it.
1183 #[test]
1184 fn distinct_keys_give_distinct_notes_even_after_case_folding() {
1185 const JS: &str = "sym:javascript:crates/roteiro/src/assets/cytoscape.min.js";
1186 let keys: Vec<String> = [
1187 // Slug lossiness: the sigil and the letter both slugged to the same
1188 // thing (9 notes lost this way, on every platform).
1189 format!("{JS}#$a"),
1190 format!("{JS}#a"),
1191 format!("{JS}#$o"),
1192 format!("{JS}#o"),
1193 // Case folding: distinct names, one file (95 notes lost this way, and
1194 // only on macOS and Windows).
1195 format!("{JS}#A"),
1196 format!("{JS}#O"),
1197 format!("{JS}#S"),
1198 format!("{JS}#s"),
1199 // Real source symbols, same shape.
1200 "sym:rust:crates/rto-exec/src/sandbox_store.rs#Store".into(),
1201 "sym:rust:crates/rto-exec/src/sandbox_store.rs#store".into(),
1202 // A trailing comma is the whole difference between these two.
1203 "import:rust:crate::engine::{ChatRequest,Engine,ModelInfo,}".into(),
1204 "import:rust:crate::engine::{ChatRequest,Engine,ModelInfo}".into(),
1205 // Nothing but separators: no hint at all, so the name is bare hash.
1206 "::".into(),
1207 "##".into(),
1208 // Over the length bound, differing only past the truncation point —
1209 // the case truncation alone used to merge.
1210 format!("import:rust:{}A", "a::b::c,".repeat(60)),
1211 format!("import:rust:{}a", "a::b::c,".repeat(60)),
1212 ]
1213 .into();
1214
1215 let folded: std::collections::BTreeSet<String> =
1216 keys.iter().map(|k| note_name(k).to_lowercase()).collect();
1217 assert_eq!(
1218 folded.len(),
1219 keys.len(),
1220 "two keys share a note after case folding; the vault would hold one \
1221 file for both and report two"
1222 );
1223 }
1224
1225 /// Case folding is the identity on a note name, so the assertion above is not
1226 /// weaker than the filesystem it stands in for.
1227 ///
1228 /// This is the reason the hint is lowercased rather than case-preserved: it
1229 /// makes "distinct names" and "distinct files on macOS" the same statement,
1230 /// so there is no version of this module that passes on Linux and loses notes
1231 /// on a Mac. Without it, the two assertions could drift apart and only the
1232 /// weaker one would ever run in CI.
1233 #[test]
1234 fn a_note_name_is_already_lowercase() {
1235 for key in [
1236 "sym:rust:src/a.rs#Store",
1237 "file:README.md",
1238 "app::file:CHANGELOG.md",
1239 "sym:javascript:a.js#ABC",
1240 ] {
1241 let name = note_name(key);
1242 assert_eq!(name, name.to_lowercase(), "`{key}` kept case in its name");
1243 }
1244 }
1245
1246 /// `_Home` is a name in the same namespace as every note, and it is not
1247 /// derived from a key — so nothing must be able to collide with it. The
1248 /// mandatory suffix gives that for free: every generated name either ends in
1249 /// `-<16 hex>` or *is* 16 hex digits, and `_home` is neither.
1250 #[test]
1251 fn no_key_can_claim_the_home_note() {
1252 for key in ["_Home", "file:_Home", "_home", "::_Home::"] {
1253 assert_ne!(
1254 format!("{}.md", note_name(key)).to_lowercase(),
1255 HOME_NOTE.to_lowercase(),
1256 "`{key}` would overwrite the overview note"
1257 );
1258 }
1259 }
1260
1261 #[test]
1262 fn render_note_emits_frontmatter_and_wikilinks() {
1263 let ex = Explanation {
1264 schema: rto_graph::SCHEMA,
1265 node: NodeSummary {
1266 key: "sym:rust:a.rs#main".into(),
1267 kind: "fn".into(),
1268 name: "main".into(),
1269 path: Some("a.rs".into()),
1270 lang: Some("rust".into()),
1271 },
1272 meta: serde_json::Value::Null,
1273 outgoing: vec![EdgeRef {
1274 kind: "calls".into(),
1275 provenance: "derived",
1276 confidence: None,
1277 node: "sym:rust:a.rs#helper".into(),
1278 }],
1279 incoming: vec![EdgeRef {
1280 kind: "references".into(),
1281 provenance: "authored",
1282 confidence: None,
1283 node: "adr:0001".into(),
1284 }],
1285 };
1286 let note = render_note(&ex, None, None);
1287 assert_eq!(
1288 note.filename,
1289 format!("{}.md", note_name("sym:rust:a.rs#main"))
1290 );
1291 assert!(note.content.contains("kind: fn"));
1292 // No source base → no Source link.
1293 assert!(!note.content.contains("**Source:**"));
1294 assert!(note.content.contains("# main"));
1295 assert!(note.content.contains(&format!(
1296 "- calls (derived) → [[{}]]",
1297 note_name("sym:rust:a.rs#helper")
1298 )));
1299 assert!(note.content.contains(&format!(
1300 "- [[{}]] references (authored) →",
1301 note_name("adr:0001")
1302 )));
1303 // Tags for the graph view.
1304 assert!(note.content.contains("- roteiro/kind/fn"));
1305 assert!(note.content.contains("- roteiro/lang/rust"));
1306 }
1307
1308 #[test]
1309 fn note_name_bounds_long_keys_deterministically() {
1310 let long = format!("import:rust:{}", "a::b::c,".repeat(60));
1311 let a = note_name(&long);
1312 let b = note_name(&long);
1313 assert_eq!(a, b, "deterministic");
1314 assert!(
1315 a.len() <= 205,
1316 "bounded under the filename limit: {}",
1317 a.len()
1318 );
1319 assert_ne!(
1320 note_name(&format!("{long}x")),
1321 a,
1322 "different keys stay distinct after truncation"
1323 );
1324 // Truncation must not leave a doubled separator before the suffix — the
1325 // hint is trimmed after cutting, not before.
1326 assert!(!a.contains("--"), "{a}");
1327 }
1328
1329 /// A short key is bounded too, and every name carries the suffix — the hash
1330 /// is no longer reached for only when the hint overruns.
1331 ///
1332 /// That gating was the defect (#574): two keys short enough to skip the hash
1333 /// had nothing left to tell them apart once the slug had flattened them.
1334 #[test]
1335 fn every_name_carries_the_hash_however_short_the_key() {
1336 for key in ["a", "adr:0001", "file:README.md"] {
1337 let name = note_name(key);
1338 let (hint, hash) = name.rsplit_once('-').expect("a suffixed name");
1339 assert!(!hint.is_empty(), "{name}");
1340 assert_eq!(hash.len(), 16, "{name}");
1341 assert!(
1342 hash.chars().all(|c| c.is_ascii_hexdigit()),
1343 "the suffix is the key's hash, not part of the hint: {name}"
1344 );
1345 }
1346 // A key with no hint at all is the bare hash, which cannot be mistaken
1347 // for a hinted name (those are at least 18 characters).
1348 let bare = note_name("::");
1349 assert_eq!(bare.len(), 16, "{bare}");
1350 assert!(!bare.contains('-'), "{bare}");
1351 }
1352
1353 #[test]
1354 fn render_note_surfaces_content_and_status() {
1355 let ex = Explanation {
1356 schema: rto_graph::SCHEMA,
1357 node: NodeSummary {
1358 key: "adr:0001".into(),
1359 kind: "adr".into(),
1360 name: "Build Roteiro".into(),
1361 path: Some("docs/adr/0001.md".into()),
1362 lang: None,
1363 },
1364 meta: serde_json::json!({ "status": "Accepted", "content": "The decision text." }),
1365 outgoing: vec![],
1366 incoming: vec![],
1367 };
1368 let note = render_note(&ex, Some("https://github.com/org/repo/blob/abc123"), None);
1369 assert!(note.content.contains("status: Accepted"));
1370 assert!(note.content.contains("- roteiro/status/accepted"));
1371 assert!(note.content.contains("> **Status:** Accepted"));
1372 assert!(note.content.contains("## Content\n\nThe decision text."));
1373 // A clickable link to the actual ADR file on the repository host.
1374 assert!(
1375 note.content.contains(
1376 "**Source:** [`docs/adr/0001.md`](https://github.com/org/repo/blob/abc123/docs/adr/0001.md)"
1377 ),
1378 "{}",
1379 note.content
1380 );
1381 }
1382
1383 /// The structured document a prose note is supposed to reproduce: headings, a
1384 /// table and a fenced code block, none of which survive whitespace collapse.
1385 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";
1386
1387 fn prose_note(content: Option<&str>) -> Explanation {
1388 Explanation {
1389 schema: rto_graph::SCHEMA,
1390 node: NodeSummary {
1391 key: "file:docs/OFFLINE_SETUP.md".into(),
1392 kind: "file".into(),
1393 name: "OFFLINE_SETUP.md".into(),
1394 path: Some("docs/OFFLINE_SETUP.md".into()),
1395 lang: None,
1396 },
1397 meta: content.map_or(
1398 serde_json::Value::Null,
1399 |c| serde_json::json!({ "content": c }),
1400 ),
1401 outgoing: vec![],
1402 incoming: vec![],
1403 }
1404 }
1405
1406 /// The whole readability defect, in one assertion pair: a note built from
1407 /// `meta.content` alone is the document whitespace-collapsed onto one line,
1408 /// and a note built from the source is the document.
1409 ///
1410 /// The newline count is the claim. A character count alone would pass on a
1411 /// note that had merely grown longer while staying flat, which is exactly the
1412 /// failure being fixed — `meta.content` is capped *and* collapsed, and only
1413 /// the collapse is what makes it unreadable.
1414 #[test]
1415 fn a_supplied_body_supersedes_the_collapsed_stored_content() {
1416 // What extraction stores: the same text, whitespace-collapsed.
1417 let collapsed = DOC.split_whitespace().collect::<Vec<_>>().join(" ");
1418 let ex = prose_note(Some(&collapsed));
1419
1420 let note = render_note(&ex, None, Some(DOC));
1421 assert!(
1422 note.content.contains(DOC.trim()),
1423 "the source document is reproduced verbatim: {}",
1424 note.content
1425 );
1426 assert!(
1427 !note.content.contains(&collapsed),
1428 "the collapsed rendering is replaced, not appended: {}",
1429 note.content
1430 );
1431 assert!(
1432 note.content.contains("\n| Host | What |\n"),
1433 "a table needs its own lines to be a table: {}",
1434 note.content
1435 );
1436 assert!(
1437 note.content.contains("\n```sh\n"),
1438 "a fenced block needs its own lines to be a fence: {}",
1439 note.content
1440 );
1441
1442 // The flat control: the same node with no body is the one-line note.
1443 let flat = render_note(&ex, None, None);
1444 assert!(
1445 flat.content.contains(&collapsed),
1446 "without a body the stored content is still shown: {}",
1447 flat.content
1448 );
1449 assert!(
1450 content_lines(¬e.content) > content_lines(&flat.content),
1451 "structure restored: {} line(s) with a body vs {} without",
1452 content_lines(¬e.content),
1453 content_lines(&flat.content)
1454 );
1455 assert_eq!(
1456 content_lines(&flat.content),
1457 1,
1458 "the defect: the stored content is a single line"
1459 );
1460 }
1461
1462 /// A doc comment is a summary of a definition, not a document, and its note is
1463 /// correct as it stands. The caller supplies no body for these, so this pins
1464 /// the unchanged path — the fix must not depend on every node gaining one.
1465 #[test]
1466 fn a_note_with_no_body_is_unchanged() {
1467 let ex = Explanation {
1468 schema: rto_graph::SCHEMA,
1469 node: NodeSummary {
1470 key: "sym:rust:a.rs#main".into(),
1471 kind: "fn".into(),
1472 name: "main".into(),
1473 path: Some("a.rs".into()),
1474 lang: Some("rust".into()),
1475 },
1476 meta: serde_json::json!({ "content": "Entry point." }),
1477 outgoing: vec![],
1478 incoming: vec![],
1479 };
1480 assert!(
1481 render_note(&ex, None, None)
1482 .content
1483 .contains("## Content\n\nEntry point.")
1484 );
1485 }
1486
1487 /// Lines in the note's `## Content` section.
1488 fn content_lines(note: &str) -> usize {
1489 let body = note
1490 .split_once("## Content\n\n")
1491 .map_or("", |(_, rest)| rest);
1492 let body = body.split_once("\n## ").map_or(body, |(head, _)| head);
1493 body.trim_end().lines().count()
1494 }
1495
1496 #[test]
1497 fn render_note_shows_inferred_confidence() {
1498 let ex = Explanation {
1499 schema: rto_graph::SCHEMA,
1500 node: NodeSummary {
1501 key: "file:a.md".into(),
1502 kind: "file".into(),
1503 name: "a.md".into(),
1504 path: Some("a.md".into()),
1505 lang: None,
1506 },
1507 meta: serde_json::Value::Null,
1508 outgoing: vec![EdgeRef {
1509 kind: "related".into(),
1510 provenance: "inferred",
1511 confidence: Some(0.82),
1512 node: "file:b.md".into(),
1513 }],
1514 incoming: vec![],
1515 };
1516 let note = render_note(&ex, None, None);
1517 assert!(
1518 note.content.contains(&format!(
1519 "related (inferred) (0.82) → [[{}]]",
1520 note_name("file:b.md")
1521 )),
1522 "{}",
1523 note.content
1524 );
1525 }
1526
1527 #[test]
1528 fn render_home_summarises_the_graph() {
1529 let summary = VaultSummary {
1530 project: "demo".into(),
1531 total_nodes: 3,
1532 total_edges: 2,
1533 node_counts: vec![("fn".into(), 2), ("adr".into(), 1)],
1534 edge_provenance: vec![("derived".into(), 1), ("authored".into(), 1)],
1535 adrs: vec![AdrEntry {
1536 key: "adr:0001".into(),
1537 name: "First".into(),
1538 status: Some("Accepted".into()),
1539 }],
1540 debt: vec![("todo".into(), 4)], // roteiro:ignore
1541 densest_files: vec![DensityEntry {
1542 path: "src/small.rs".into(),
1543 markers: 3,
1544 lines: 120,
1545 per_kloc: 25.0,
1546 }],
1547 config_secrets: Some(ConfigSecretSummary {
1548 secret_named: 4,
1549 redacted: 3,
1550 declared: 1,
1551 unredacted: 0,
1552 files: vec![".env".into()],
1553 }),
1554 most_called: vec![CouplingEntry {
1555 key: "sym:rust:a.rs#helper".into(),
1556 name: "helper".into(),
1557 fan_in: 7,
1558 fan_out: 1,
1559 }],
1560 repo_url: Some("https://github.com/org/repo".into()),
1561 commit: Some("abcdef0123456789".into()),
1562 };
1563 let note = render_home(&summary);
1564 assert_eq!(note.filename, HOME_NOTE);
1565 assert!(note.content.contains("# demo — knowledge graph"));
1566 assert!(note.content.contains("**3 nodes**, **2 edges**"));
1567 assert!(note.content.contains("| fn | 2 |"));
1568 assert!(note.content.contains("| derived | 1 |"));
1569 assert!(note.content.contains(&format!(
1570 "**Accepted** — [[{}|First]]",
1571 note_name("adr:0001")
1572 )));
1573 assert!(note.content.contains("| todo | 4 |")); // roteiro:ignore
1574 // Directed coupling: the two fans are separate columns, and the wikilink's
1575 // own `|` is escaped so it cannot break the table it sits in.
1576 assert!(
1577 note.content.contains(&format!(
1578 "| [[{}\\|helper]] | 7 | 1 |",
1579 note_name("sym:rust:a.rs#helper")
1580 )),
1581 "{}",
1582 note.content
1583 );
1584 assert!(
1585 note.content.contains("resolved by simple name"),
1586 "the precision caveat travels with the figures"
1587 );
1588 // Density: the count and the denominator are both shown, so the ratio can
1589 // be checked rather than taken on trust, and the wikilink's own `|` is
1590 // escaped so it cannot break the table it sits in.
1591 assert!(
1592 note.content.contains(&format!(
1593 "| [[{}\\|src/small.rs]] | 3 | 120 | 25.00 |",
1594 note_name("file:src/small.rs")
1595 )),
1596 "{}",
1597 note.content
1598 );
1599 assert!(
1600 note.content.contains("not source lines of code"),
1601 "the denominator caveat travels with the figures"
1602 );
1603 // Config secrets: counts and files, and no key names — a vault note is
1604 // browsed out of context, which is the wrong place for a list that would
1605 // read as a secret scan's output.
1606 assert!(
1607 note.content.contains(
1608 "**4** secret-named config key(s): 3 redacted before storage, 1 \
1609 declared in code without a value, 0 unredacted."
1610 ),
1611 "{}",
1612 note.content
1613 );
1614 assert!(
1615 note.content
1616 .contains(&format!("- [[{}\\|.env]]", note_name("file:.env"))),
1617 "{}",
1618 note.content
1619 );
1620 assert!(
1621 note.content.contains("not a secret scan")
1622 && note.content.contains("cannot see a hardcoded credential"),
1623 "the limitation travels with the figures: {}",
1624 note.content
1625 );
1626 assert!(
1627 !note.content.contains("[!warning]"),
1628 "no warning when nothing is unredacted: {}",
1629 note.content
1630 );
1631 // A repository link + short-commit permalink note.
1632 assert!(
1633 note.content
1634 .contains("**Repository:** [https://github.com/org/repo](https://github.com/org/repo) · rendered at commit `abcdef012345`"),
1635 "{}",
1636 note.content
1637 );
1638 }
1639
1640 #[test]
1641 fn render_home_omits_density_for_a_graph_with_no_markers() {
1642 // A clean repository has no markers, so there is no density to rank. An
1643 // empty table under a heading reads as "measured, and there is nothing";
1644 // the section is absent instead. Same rule as the coupling table below.
1645 let note = render_home(&VaultSummary {
1646 project: "clean".into(),
1647 total_nodes: 1,
1648 ..VaultSummary::default()
1649 });
1650 assert!(
1651 !note.content.contains("Densest files"),
1652 "no heading without rows: {}",
1653 note.content
1654 );
1655 // The intent-debt section itself still renders — density is an addition
1656 // to it, not a replacement.
1657 assert!(note.content.contains("## Intent debt"));
1658 assert!(note.content.contains("*None recorded.*"));
1659 }
1660
1661 #[test]
1662 fn render_home_omits_config_secrets_rather_than_rendering_zeroes() {
1663 // A row of zeroes under this heading would read as "scanned, and clean" —
1664 // a conclusion the lens cannot support, since a credential under an
1665 // innocuous key name never appears in it. The section is absent instead.
1666 let note = render_home(&VaultSummary {
1667 project: "clean".into(),
1668 total_nodes: 1,
1669 ..VaultSummary::default()
1670 });
1671 assert!(
1672 !note.content.contains("named like secrets"),
1673 "no heading without figures: {}",
1674 note.content
1675 );
1676 }
1677
1678 #[test]
1679 fn render_home_warns_loudly_about_an_unredacted_value() {
1680 // Extraction cannot produce this state, so if it appears something else
1681 // put an unredacted value in the store — and the note must say where to
1682 // look rather than implicating the repository.
1683 let note = render_home(&VaultSummary {
1684 project: "imported".into(),
1685 total_nodes: 1,
1686 config_secrets: Some(ConfigSecretSummary {
1687 secret_named: 1,
1688 redacted: 0,
1689 declared: 0,
1690 unredacted: 1,
1691 files: vec!["imported.env".into()],
1692 }),
1693 ..VaultSummary::default()
1694 });
1695 assert!(
1696 note.content.contains("[!warning]") && note.content.contains("**unredacted**"),
1697 "{}",
1698 note.content
1699 );
1700 assert!(
1701 note.content.contains("came from an import layer"),
1702 "and it points at the importing tool, not the repository: {}",
1703 note.content
1704 );
1705 }
1706
1707 #[test]
1708 fn render_home_omits_coupling_for_a_graph_with_no_calls() {
1709 // A prose-only vault has no `calls` edges. An empty table under a heading
1710 // reads as "measured, and there is nothing" — the section is absent instead.
1711 let note = render_home(&VaultSummary {
1712 project: "docs".into(),
1713 total_nodes: 1,
1714 ..VaultSummary::default()
1715 });
1716 assert!(
1717 !note.content.contains("Most depended-on"),
1718 "no heading without rows: {}",
1719 note.content
1720 );
1721 // The rest of the overview is unaffected.
1722 assert!(note.content.contains("# docs — knowledge graph"));
1723 }
1724
1725 // ---- Workspace vaults (issue #442 part 1) --------------------------------
1726
1727 /// A `Explanation` for `key`, with one outgoing edge to `to`.
1728 fn node_linking_to(key: &str, name: &str, to: &str) -> Explanation {
1729 Explanation {
1730 schema: rto_graph::SCHEMA,
1731 node: NodeSummary {
1732 key: key.into(),
1733 kind: "config_key".into(),
1734 name: name.into(),
1735 path: Some("config.toml".into()),
1736 lang: None,
1737 },
1738 meta: serde_json::Value::Null,
1739 outgoing: vec![EdgeRef {
1740 kind: "links".into(),
1741 provenance: "inferred",
1742 confidence: Some(0.91),
1743 node: to.into(),
1744 }],
1745 incoming: vec![],
1746 }
1747 }
1748
1749 fn members(names: &[&str]) -> std::collections::BTreeSet<String> {
1750 names.iter().map(|s| (*s).to_owned()).collect()
1751 }
1752
1753 /// **Rewritten deliberately under #574.** #570 landed this as "a project
1754 /// scope leaves every note name exactly as it was", and read that two ways at
1755 /// once: `PROJECT` reduces to `note_name`, *and* `note_name` itself does not
1756 /// move. #574 breaks the second half on purpose — the old names were not
1757 /// injective under filename case folding and this repository's vault lost 104
1758 /// notes to it — so the two halves are separated here rather than having
1759 /// expected values quietly updated underneath the old title.
1760 ///
1761 /// What survives is the half #570 was actually about, and it is unweakened:
1762 /// **turning workspace mode on must not rename a project's notes.** Names may
1763 /// move when `note_name` changes, for a reason argued at `note_name`; they may
1764 /// never move because a repository happens to sit inside a configured
1765 /// workspace, because that would happen by inference rather than by a release.
1766 ///
1767 /// The other half of #570's promise — that a project render is byte-identical
1768 /// apart from names — is now [`render_note_is_the_project_scoped_render_byte_for_byte`]
1769 /// and `render_cli`'s end-to-end pair.
1770 #[test]
1771 fn a_project_scope_never_qualifies_a_name() {
1772 // A user's own notes live outside the vault and link into it *by name*
1773 // (#442), so a rename breaks them silently, with no error and nothing to
1774 // grep for. Whatever workspace mode does, `VaultScope::PROJECT` must
1775 // reduce to `note_name` of the bare key.
1776 for key in [
1777 "file:README.md",
1778 "adr:0001",
1779 "sym:rust:src/a.rs#Store",
1780 "extref:other::file:README.md",
1781 "cfgkey:config.toml#serve.addr",
1782 ] {
1783 assert_eq!(
1784 scoped_note_name(&VaultScope::PROJECT, key),
1785 note_name(key),
1786 "single-project name moved for `{key}`"
1787 );
1788 // And the qualified form really is a different name, so the assertion
1789 // above is not vacuously true of every scope.
1790 let ms = members(&["app"]);
1791 assert_ne!(
1792 scoped_note_name(
1793 &VaultScope {
1794 project: Some("app"),
1795 members: &ms,
1796 },
1797 key
1798 ),
1799 note_name(key),
1800 "qualification must move the name for `{key}`, or nothing above holds"
1801 );
1802 }
1803 }
1804
1805 #[test]
1806 fn render_note_is_the_project_scoped_render_byte_for_byte() {
1807 let ex = node_linking_to("cfgkey:config.toml#addr", "addr", "sym:rust:a.rs#A");
1808 assert_eq!(
1809 render_note(&ex, Some("https://h/b"), Some("body")),
1810 render_note_scoped(&ex, Some("https://h/b"), Some("body"), &VaultScope::PROJECT),
1811 "the unscoped entry point must stay the scoped one at PROJECT, so the \
1812 two cannot drift apart"
1813 );
1814 }
1815
1816 #[test]
1817 fn each_member_gets_its_own_note_for_the_same_key() {
1818 // The collision the whole feature exists for: node keys are
1819 // repository-relative, so every member's `README.md` is `file:README.md`.
1820 let ms = members(&["api", "sdk"]);
1821 let names: Vec<String> = ["api", "sdk"]
1822 .iter()
1823 .map(|p| {
1824 scoped_note_name(
1825 &VaultScope {
1826 project: Some(p),
1827 members: &ms,
1828 },
1829 "file:README.md",
1830 )
1831 })
1832 .collect();
1833 assert_eq!(
1834 names,
1835 [
1836 note_name("api::file:README.md"),
1837 note_name("sdk::file:README.md")
1838 ]
1839 );
1840 assert_ne!(names[0], names[1], "two members must not share one note");
1841 }
1842
1843 /// The two names this feature has, pinned together in one place.
1844 ///
1845 /// They are easy to conflate and were, in this PR, described inconsistently
1846 /// in two doc comments — the **key** is `<project>::<key>` (ADR-0009's
1847 /// cross-repo form, which is why cross-repo links resolve), and the **note
1848 /// name** is [`note_name`] of that key, in which `::` has become `-`. A
1849 /// reader told the wrong one goes looking for a file with `::` in it.
1850 ///
1851 /// Asserting both here means the next description that drifts has something
1852 /// to disagree with, rather than waiting for a reviewer to read two comments
1853 /// side by side.
1854 #[test]
1855 fn the_qualified_key_and_the_note_name_are_different_strings() {
1856 let ms = members(&["app"]);
1857 let scope = VaultScope {
1858 project: Some("app"),
1859 members: &ms,
1860 };
1861 // The key: project-qualified, `::` intact — this is what the graph and
1862 // ADR-0009's external refs use.
1863 let qualified = "app::file:README.md";
1864 // The note name: `note_name` of exactly that key, `::` slugged to `-`,
1865 // the whole hint lowercased, and the key's own hash appended.
1866 assert_eq!(
1867 scoped_note_name(&scope, "file:README.md"),
1868 "app-file-readme.md-a114bde6dcaba1c1"
1869 );
1870 assert_eq!(note_name(qualified), "app-file-readme.md-a114bde6dcaba1c1");
1871 assert!(
1872 !scoped_note_name(&scope, "file:README.md").contains("::"),
1873 "no note name ever contains `::`"
1874 );
1875 // And on disk the stem gains the extension, which is the string a reader
1876 // actually looks for.
1877 let note = render_note_scoped(
1878 &node_with("file:README.md", Some("README.md"), None),
1879 None,
1880 None,
1881 &scope,
1882 );
1883 assert_eq!(note.filename, "app-file-readme.md-a114bde6dcaba1c1.md");
1884 }
1885
1886 /// `_Home` must *show* a name, not spell the form out.
1887 ///
1888 /// The test above pins the distinction in the code. It did not stop the
1889 /// distinction being described wrongly in the same file, because it guards
1890 /// the function and not the sentences: `render_workspace_home` went on
1891 /// writing the pre-#574 form into the `_Home` of every workspace vault
1892 /// v2.0.0 built, and nothing here disagreed with it.
1893 ///
1894 /// So this asserts the property that made that possible is gone — the
1895 /// paragraph now contains a string `note_name` actually produced for a key
1896 /// the workspace really holds, which a hand-written spelling cannot
1897 /// satisfy. It is not a tautology despite both sides calling `note_name`:
1898 /// what it rejects is the *shape* of the old copy, a form written out by
1899 /// hand next to the function that could have rendered it.
1900 ///
1901 /// That the *key* is real is the other half, and the reason the example is
1902 /// drawn from `cross_links` rather than invented from the member list —
1903 /// a name rendered for a node the vault does not hold is a true sentence
1904 /// about a note nobody can open. The empty case is
1905 /// `the_workspace_home_claims_no_example_note_when_it_has_no_real_key`.
1906 /// #573: the cross-repo section distinguishes a **declaration** from a
1907 /// **match**, and says how many of each.
1908 ///
1909 /// Before authored links could be persisted, every row was a candidate and
1910 /// the section said so in one blanket caveat. That caveat is now false for
1911 /// declared rows, and an edge that exists but renders as a guess leaves
1912 /// ADR-0009's `authored → gold` path just as unreachable as no edge at all —
1913 /// so the rendering is part of the contract, not decoration.
1914 #[test]
1915 fn the_cross_repo_section_separates_declared_links_from_inferred_ones() {
1916 let link = |authored: bool, key: &str| CrossLink {
1917 from_project: "sdk".into(),
1918 from_key: format!("cfgkey:config.toml#{key}"),
1919 from_name: key.into(),
1920 kind: "references".into(),
1921 // A declaration carries no score by construction; a match does.
1922 confidence: if authored { None } else { Some(0.91) },
1923 to_qualified: format!("api::cfgkey:config.toml#{key}"),
1924 resolves: true,
1925 authored,
1926 };
1927 let ws = WorkspaceSummary {
1928 name: "platform".into(),
1929 members: vec![member_summary("api", 7), member_summary("sdk", 4)],
1930 cross_links: vec![link(true, "addr"), link(false, "port")],
1931 // Totals deliberately **larger** than the two rows shown: the caption
1932 // is a statement about the workspace, and `cross_links` is a capped
1933 // view of it. Counting the rows would give 1 and 1 here and read as
1934 // correct — which is exactly the bug, invisible until a workspace
1935 // outgrows the cap.
1936 cross_links_total: 9,
1937 cross_links_authored: 4,
1938 };
1939 let c = render_workspace_home(&ws).content;
1940
1941 assert!(
1942 c.contains("**4 declared**"),
1943 "the caption counts the workspace, not the rows on screen: {c}"
1944 );
1945 assert!(c.contains("**5 inferred**"), "{c}");
1946 assert!(
1947 !c.contains("not authored facts"),
1948 "the blanket caveat is false once a declared row can appear: {c}"
1949 );
1950 // Per row: a declaration is marked as one, and a match keeps its score.
1951 assert!(c.contains("references *(declared)*"), "{c}");
1952 assert!(c.contains("references (0.91)"), "{c}");
1953 }
1954
1955 #[test]
1956 fn the_workspace_home_names_an_example_note_name_actually_produces() {
1957 let ws = WorkspaceSummary {
1958 name: "platform".into(),
1959 members: vec![member_summary("api", 7), member_summary("sdk", 4)],
1960 cross_links: vec![CrossLink {
1961 from_project: "sdk".into(),
1962 from_key: "cfgkey:config.toml#addr".into(),
1963 from_name: "addr".into(),
1964 kind: "links".into(),
1965 confidence: Some(0.91),
1966 to_qualified: "api::cfgkey:config.toml#addr".into(),
1967 resolves: true,
1968 authored: false,
1969 }],
1970 cross_links_total: 1,
1971 cross_links_authored: 0,
1972 };
1973 let note = render_workspace_home(&ws);
1974
1975 // The source end of the first cross-repo link, rendered through the real
1976 // function. `from_project` is a member and `from_key` is one of its own
1977 // nodes, so this is a note the render writes rather than one the
1978 // sentence assumes.
1979 let expected = format!("{}.md", note_name("sdk::cfgkey:config.toml#addr"));
1980 assert!(
1981 note.content.contains(&expected),
1982 "the naming paragraph must show a real name ({expected}), not a \
1983 hand-written form:\n{}",
1984 note.content
1985 );
1986 // And the key form it is derived *from* is still stated, because that is
1987 // the half a reader needs to look a note up by its frontmatter.
1988 assert!(
1989 note.content.contains("`<project>::<key>`"),
1990 "{}",
1991 note.content
1992 );
1993 // No filename anywhere in the vault carries `::`.
1994 assert!(!expected.contains("::"), "{expected}");
1995 }
1996
1997 /// With no cross-repo links there is no key the renderer can prove is a
1998 /// node, so it must say nothing rather than fabricate one.
1999 ///
2000 /// The example this replaced was `<first member>::file:README.md`, invented
2001 /// from the member list — and membership does not require a README, so
2002 /// `_Home` could assert a note that was never written. That is the very
2003 /// defect this PR exists to fix, one remove away, so the empty case gets an
2004 /// assertion of its own rather than an assumption.
2005 #[test]
2006 fn the_workspace_home_claims_no_example_note_when_it_has_no_real_key() {
2007 let ws = WorkspaceSummary {
2008 name: "platform".into(),
2009 members: vec![member_summary("api", 7), member_summary("sdk", 4)],
2010 cross_links: vec![],
2011 cross_links_total: 0,
2012 cross_links_authored: 0,
2013 };
2014 let note = render_workspace_home(&ws);
2015
2016 assert!(
2017 !note.content.contains("is the note "),
2018 "no cross-repo link means no provable key, so no `Here, X is the \
2019 note Y` claim:\n{}",
2020 note.content
2021 );
2022 // The fabricated form specifically: never emitted, with or without links.
2023 assert!(
2024 !note.content.contains("::file:README.md"),
2025 "{}",
2026 note.content
2027 );
2028 // The rule itself is still stated — only the illustration is absent.
2029 assert!(
2030 note.content.contains("`<project>::<key>`")
2031 && note.content.contains("no filename contains `::`"),
2032 "{}",
2033 note.content
2034 );
2035 }
2036
2037 #[test]
2038 fn a_member_note_declares_which_member_it_came_from() {
2039 let ms = members(&["api"]);
2040 let ex = node_linking_to("cfgkey:config.toml#addr", "addr", "sym:rust:a.rs#A");
2041 let note = render_note_scoped(
2042 &ex,
2043 None,
2044 None,
2045 &VaultScope {
2046 project: Some("api"),
2047 members: &ms,
2048 },
2049 );
2050 assert_eq!(
2051 note.filename,
2052 format!("{}.md", note_name("api::cfgkey:config.toml#addr"))
2053 );
2054 assert!(
2055 note.content.contains("project: \"api\""),
2056 "{}",
2057 note.content
2058 );
2059 assert!(
2060 note.content.contains("- roteiro/project/api"),
2061 "the tag is what filters the graph view to one repository: {}",
2062 note.content
2063 );
2064 // A within-member edge is qualified to the same member, not left bare.
2065 assert!(
2066 note.content
2067 .contains(&format!("→ [[{}]]", note_name("api::sym:rust:a.rs#A"))),
2068 "{}",
2069 note.content
2070 );
2071 }
2072
2073 #[test]
2074 fn a_project_note_declares_no_project() {
2075 let ex = node_linking_to("cfgkey:config.toml#addr", "addr", "sym:rust:a.rs#A");
2076 let note = render_note(&ex, None, None);
2077 assert!(!note.content.contains("project:"), "{}", note.content);
2078 assert!(
2079 !note.content.contains("roteiro/project/"),
2080 "a per-project vault would carry one constant on every note — and \
2081 adding it would change every note's bytes: {}",
2082 note.content
2083 );
2084 }
2085
2086 #[test]
2087 fn a_cross_repo_edge_links_straight_to_the_other_members_note() {
2088 // ADR-0009: the spoke's edge points at a *local placeholder* for the hub's
2089 // node, because store integrity needs both ends in one store. A workspace
2090 // vault holds both, so the link goes to the real note. No new edge — the
2091 // resolver already follows this placeholder at query time.
2092 let ms = members(&["spoke", "hub"]);
2093 let scope = VaultScope {
2094 project: Some("spoke"),
2095 members: &ms,
2096 };
2097 let ex = node_linking_to(
2098 "cfgkey:config.toml#addr",
2099 "addr",
2100 &rto_graph::external_ref_key("hub::cfgkey:config.toml#addr"),
2101 );
2102 let note = render_note_scoped(&ex, None, None, &scope);
2103 assert!(
2104 note.content.contains(&format!(
2105 "→ [[{}]]",
2106 note_name("hub::cfgkey:config.toml#addr")
2107 )),
2108 "the edge must land on the hub's own note: {}",
2109 note.content
2110 );
2111 assert!(
2112 !note.content.contains("extref"),
2113 "and never on the placeholder: {}",
2114 note.content
2115 );
2116 // The same rule decides that the placeholder is not written as a note, so
2117 // the two halves cannot disagree.
2118 assert!(
2119 scope.redirects_external_ref(&rto_graph::external_ref_key(
2120 "hub::cfgkey:config.toml#addr"
2121 ))
2122 );
2123 }
2124
2125 #[test]
2126 fn a_cross_repo_edge_out_of_the_workspace_keeps_its_placeholder() {
2127 // The target repo is not in this vault, so there is no note to point at.
2128 // Redirecting anyway would produce a link that resolves to nothing —
2129 // Obsidian shows that as merely unwritten, which is a worse lie than a
2130 // placeholder that honestly says "elsewhere".
2131 let ms = members(&["spoke"]);
2132 let scope = VaultScope {
2133 project: Some("spoke"),
2134 members: &ms,
2135 };
2136 let key = rto_graph::external_ref_key("elsewhere::cfgkey:config.toml#addr");
2137 assert!(!scope.redirects_external_ref(&key));
2138 let ex = node_linking_to("cfgkey:config.toml#addr", "addr", &key);
2139 let note = render_note_scoped(&ex, None, None, &scope);
2140 assert!(
2141 note.content.contains(&format!(
2142 "→ [[{}]]",
2143 note_name("spoke::extref:elsewhere::cfgkey:config.toml#addr")
2144 )),
2145 "{}",
2146 note.content
2147 );
2148 }
2149
2150 #[test]
2151 fn a_single_project_vault_never_redirects_an_external_ref() {
2152 // No members ⇒ nothing to resolve against, so today's vault keeps rendering
2153 // the placeholder exactly as it does now.
2154 let key = rto_graph::external_ref_key("hub::cfgkey:config.toml#addr");
2155 assert!(!VaultScope::PROJECT.redirects_external_ref(&key));
2156 assert_eq!(
2157 scoped_note_name(&VaultScope::PROJECT, &key),
2158 note_name(&key)
2159 );
2160 }
2161
2162 fn member_summary(project: &str, fan_in: u32) -> VaultSummary {
2163 VaultSummary {
2164 project: project.to_owned(),
2165 total_nodes: 3,
2166 total_edges: 2,
2167 node_counts: vec![("fn".into(), 2)],
2168 edge_provenance: vec![("derived".into(), 2)],
2169 adrs: vec![AdrEntry {
2170 key: "adr:0001".into(),
2171 name: "First".into(),
2172 status: Some("Accepted".into()),
2173 }],
2174 debt: vec![("todo".into(), 4)], // roteiro:ignore
2175 densest_files: vec![DensityEntry {
2176 path: "src/small.rs".into(),
2177 markers: 3,
2178 lines: 120,
2179 per_kloc: 25.0,
2180 }],
2181 config_secrets: None,
2182 most_called: vec![CouplingEntry {
2183 key: "sym:rust:a.rs#helper".into(),
2184 name: "helper".into(),
2185 fan_in,
2186 fan_out: 1,
2187 }],
2188 repo_url: Some(format!("https://github.com/org/{project}")),
2189 commit: Some("abcdef0123456789".into()),
2190 }
2191 }
2192
2193 #[test]
2194 fn the_workspace_home_keeps_every_members_own_aggregates() {
2195 // The promise in issue #442: the existing per-project `_Home` view is a
2196 // *subset* of the workspace one, not a casualty of it. Someone who came for
2197 // their repository's coupling and debt tables must still find them —
2198 // not a workspace total that averages them away.
2199 let ws = WorkspaceSummary {
2200 name: "platform".into(),
2201 members: vec![member_summary("api", 7), member_summary("sdk", 4)],
2202 cross_links: vec![],
2203 cross_links_total: 0,
2204 cross_links_authored: 0,
2205 };
2206 let note = render_workspace_home(&ws);
2207 assert_eq!(note.filename, HOME_NOTE);
2208 assert!(
2209 note.content
2210 .contains("# platform — workspace knowledge graph")
2211 );
2212 // Summed, and the members listed.
2213 assert!(
2214 note.content
2215 .contains("**6 nodes**, **4 edges** across **2** member")
2216 );
2217 assert!(note.content.contains("| [[#api\\|api]] | 3 | 2 |"));
2218
2219 for project in ["api", "sdk"] {
2220 assert!(
2221 note.content.contains(&format!("\n## {project}\n")),
2222 "each member gets its own section"
2223 );
2224 }
2225 // Today's sections, one level deeper, once per member.
2226 for section in [
2227 "### Structure",
2228 "### Provenance",
2229 "### Decisions (ADRs)",
2230 "### Intent debt",
2231 "#### Densest files",
2232 "### Most depended-on",
2233 ] {
2234 assert_eq!(
2235 note.content.matches(section).count(),
2236 2,
2237 "`{section}` must appear once per member: {}",
2238 note.content
2239 );
2240 }
2241 // And every link inside a member's section resolves within that member.
2242 assert!(note.content.contains(&format!(
2243 "**Accepted** — [[{}|First]]",
2244 note_name("api::adr:0001")
2245 )));
2246 assert!(note.content.contains(&format!(
2247 "**Accepted** — [[{}|First]]",
2248 note_name("sdk::adr:0001")
2249 )));
2250 assert!(note.content.contains(&format!(
2251 "[[{}\\|helper]] | 7 |",
2252 note_name("api::sym:rust:a.rs#helper")
2253 )));
2254 assert!(note.content.contains(&format!(
2255 "[[{}\\|src/small.rs]]",
2256 note_name("sdk::file:src/small.rs")
2257 )));
2258 }
2259
2260 #[test]
2261 fn the_workspace_home_renders_cross_repo_links_and_marks_the_ones_it_cannot_follow() {
2262 let ws = WorkspaceSummary {
2263 name: "platform".into(),
2264 members: vec![member_summary("api", 7), member_summary("sdk", 4)],
2265 cross_links: vec![
2266 CrossLink {
2267 from_project: "sdk".into(),
2268 from_key: "cfgkey:config.toml#addr".into(),
2269 from_name: "addr".into(),
2270 kind: "links".into(),
2271 confidence: Some(0.91),
2272 to_qualified: "api::cfgkey:config.toml#addr".into(),
2273 resolves: true,
2274 authored: false,
2275 },
2276 CrossLink {
2277 from_project: "sdk".into(),
2278 from_key: "cfgkey:config.toml#other".into(),
2279 from_name: "other".into(),
2280 kind: "links".into(),
2281 confidence: None,
2282 to_qualified: "absent::cfgkey:config.toml#other".into(),
2283 resolves: false,
2284 authored: false,
2285 },
2286 ],
2287 cross_links_total: 2,
2288 cross_links_authored: 0,
2289 };
2290 let note = render_workspace_home(&ws);
2291 // Resolvable: a link to the other member's note, with its confidence.
2292 assert!(
2293 note.content.contains(&format!(
2294 "| [[{}\\|addr]] | sdk | [[{}\\|api::cfgkey:config.toml#addr]] | links (0.91) |",
2295 note_name("sdk::cfgkey:config.toml#addr"),
2296 note_name("api::cfgkey:config.toml#addr"),
2297 )),
2298 "{}",
2299 note.content
2300 );
2301 // Outside the workspace: stated as such, never as a wikilink — Obsidian
2302 // renders a link to a missing note as one that is merely unwritten.
2303 assert!(
2304 note.content
2305 .contains("`absent::cfgkey:config.toml#other` *(outside this workspace)*"),
2306 "{}",
2307 note.content
2308 );
2309 assert!(
2310 !note.content.contains("[[absent-"),
2311 "a dangling wikilink would read as a note someone forgot to write: {}",
2312 note.content
2313 );
2314 }
2315
2316 #[test]
2317 fn the_workspace_home_says_when_it_has_truncated_the_cross_links() {
2318 // A capped table that does not say it is capped reads as the whole set.
2319 let ws = WorkspaceSummary {
2320 name: "platform".into(),
2321 members: vec![member_summary("api", 7)],
2322 cross_links: vec![CrossLink {
2323 from_project: "api".into(),
2324 from_key: "cfgkey:config.toml#addr".into(),
2325 from_name: "addr".into(),
2326 kind: "links".into(),
2327 confidence: None,
2328 to_qualified: "api::cfgkey:config.toml#addr".into(),
2329 resolves: true,
2330 authored: false,
2331 }],
2332 cross_links_total: 40,
2333 cross_links_authored: 0,
2334 };
2335 let note = render_workspace_home(&ws);
2336 assert!(note.content.contains("Showing 1 of 40"), "{}", note.content);
2337 assert!(note.content.contains("roteiro links --matrix"));
2338 }
2339
2340 #[test]
2341 fn a_workspace_with_no_cross_repo_links_says_why_rather_than_showing_nothing() {
2342 let ws = WorkspaceSummary {
2343 name: "platform".into(),
2344 members: vec![member_summary("api", 7)],
2345 cross_links: vec![],
2346 cross_links_total: 0,
2347 cross_links_authored: 0,
2348 };
2349 let note = render_workspace_home(&ws);
2350 assert!(note.content.contains("## Cross-repo links"));
2351 assert!(
2352 note.content.contains("links --infer --write"),
2353 "an empty section must name what would fill it, or it reads as \
2354 \"these repos are unrelated\": {}",
2355 note.content
2356 );
2357 // Singular, because getting this wrong on a one-member workspace is the
2358 // kind of thing nobody notices until it ships.
2359 assert!(note.content.contains("**1** member repository."));
2360 }
2361
2362 // ---- YAML frontmatter escaping -------------------------------------------
2363
2364 /// Parse a note's frontmatter block with a **real** YAML parser and return
2365 /// `field`'s value, or the parse error.
2366 ///
2367 /// Every assertion below goes through this rather than checking the emitted
2368 /// bytes. An escaper that is wrong in a self-consistent way passes a
2369 /// byte-comparison — that is precisely how `"foo\bar"` survived: it looks
2370 /// exactly like what was asked for, and means something else.
2371 fn frontmatter_field(note: &str, field: &str) -> Result<Option<String>, String> {
2372 let block = note
2373 .strip_prefix("---\n")
2374 .and_then(|rest| rest.split_once("\n---\n"))
2375 .map(|(block, _)| block)
2376 .expect("note must open with a frontmatter block");
2377 let docs = yaml_rust2::YamlLoader::load_from_str(block).map_err(|e| e.to_string())?;
2378 Ok(docs[0][field].as_str().map(ToOwned::to_owned))
2379 }
2380
2381 /// A node whose key, path and language are whatever the test needs.
2382 fn node_with(key: &str, path: Option<&str>, lang: Option<&str>) -> Explanation {
2383 Explanation {
2384 schema: rto_graph::SCHEMA,
2385 node: NodeSummary {
2386 key: key.into(),
2387 kind: "fn".into(),
2388 name: "n".into(),
2389 path: path.map(ToOwned::to_owned),
2390 lang: lang.map(ToOwned::to_owned),
2391 },
2392 meta: serde_json::Value::Null,
2393 outgoing: vec![],
2394 incoming: vec![],
2395 }
2396 }
2397
2398 /// The three measured failure modes of the escaping this replaced, each
2399 /// asserted on the **parsed** value.
2400 ///
2401 /// Before the fix: `foo\bar` parsed back as `foo<BS>ar` (silently six
2402 /// characters, not seven), and the other two made the whole block
2403 /// unparseable — which in Obsidian costs the note *every* property, with no
2404 /// error shown.
2405 #[test]
2406 fn a_backslash_or_quote_in_a_path_still_parses_back_to_itself() {
2407 for path in [
2408 r"foo\bar", // `\b` was YAML's backspace escape: silent corruption
2409 r"foo\dir", // `\d` is not a YAML escape at all: parse error
2410 "say\"hi\".rs", // an unescaped `"` ended the scalar early: parse error
2411 r"a\\b",
2412 "trailing-backslash\\",
2413 ] {
2414 let note = render_note(&node_with("file:x", Some(path), None), None, None);
2415 assert_eq!(
2416 frontmatter_field(¬e.content, "path"),
2417 Ok(Some(path.to_owned())),
2418 "path {path:?} must round-trip"
2419 );
2420 }
2421 }
2422
2423 /// `key:` is not hypothetical for this: node keys already carry `:` and `#`,
2424 /// and a symbol name can contain a quotation mark.
2425 #[test]
2426 fn a_node_key_round_trips_whatever_punctuation_it_carries() {
2427 for key in [
2428 "sym:rust:src/a.rs#Store",
2429 r"sym:rust:src\weird.rs#Thing",
2430 "sym:rust:a.rs#say\"hi\"",
2431 "cfgkey:config.toml#serve.addr",
2432 ] {
2433 let note = render_note(&node_with(key, None, None), None, None);
2434 assert_eq!(
2435 frontmatter_field(¬e.content, "key"),
2436 Ok(Some(key.to_owned())),
2437 "key {key:?} must round-trip"
2438 );
2439 }
2440 // The old rule turned a `"` into an apostrophe, so the note reported a key
2441 // that was not the node's key — parseable, and wrong.
2442 let note = render_note(
2443 &node_with("sym:rust:a.rs#say\"hi\"", None, None),
2444 None,
2445 None,
2446 );
2447 assert!(
2448 !note.content.contains("say'hi'"),
2449 "a quotation mark must be escaped, not rewritten: {}",
2450 note.content
2451 );
2452 }
2453
2454 /// A member directory name is a path component, so it reaches the same rule.
2455 #[test]
2456 fn a_member_project_name_round_trips() {
2457 let ms: std::collections::BTreeSet<String> =
2458 std::iter::once(r"odd\name".to_owned()).collect();
2459 let note = render_note_scoped(
2460 &node_with("file:x", None, None),
2461 None,
2462 None,
2463 &VaultScope {
2464 project: Some(r"odd\name"),
2465 members: &ms,
2466 },
2467 );
2468 assert_eq!(
2469 frontmatter_field(¬e.content, "project"),
2470 Ok(Some(r"odd\name".to_owned()))
2471 );
2472 }
2473
2474 /// The **bare** fields are the other half of the same class, and were missed
2475 /// by the review that found the quoted ones: `status` is written unquoted, and
2476 /// `roteiro load` installs a caller-supplied artifact whose nodes carry
2477 /// whatever they carry.
2478 #[test]
2479 fn a_bare_field_is_quoted_only_when_being_bare_would_change_it() {
2480 let with_status = |status: &str| {
2481 let mut ex = node_with("adr:0001", None, None);
2482 ex.meta = serde_json::json!({ "status": status });
2483 render_note(&ex, None, None)
2484 };
2485
2486 // Would be a parse error bare; would silently truncate bare.
2487 for status in [
2488 "Accepted: superseded by 0012",
2489 "Accepted # pending",
2490 "{draft}",
2491 "",
2492 ] {
2493 let note = with_status(status);
2494 assert_eq!(
2495 frontmatter_field(¬e.content, "status"),
2496 Ok(Some(status.to_owned())),
2497 "status {status:?} must round-trip"
2498 );
2499 }
2500
2501 // …and a safe one stays bare, which is what keeps an existing vault's
2502 // bytes unchanged.
2503 let note = with_status("Accepted");
2504 assert!(
2505 note.content.contains("\nstatus: Accepted\n"),
2506 "a plain-safe status must not gain quotes: {}",
2507 note.content
2508 );
2509 }
2510
2511 /// `no` is Norwegian, and a bare `no` reads as `false` to a YAML **1.1**
2512 /// parser.
2513 ///
2514 /// The only assertion here that pins emitted bytes, and deliberately so:
2515 /// `yaml-rust2` implements YAML 1.2, whose core schema resolves a bare `no`
2516 /// to the *string* `no`, so a round-trip through this test's own oracle
2517 /// cannot see the problem — it passes either way. The exposure is to the
2518 /// parser on the other side, and Obsidian's is not this one. Quoting costs
2519 /// two characters on a value that never occurs here; guessing which YAML
2520 /// version every downstream reader implements does not seem like the better
2521 /// bet.
2522 #[test]
2523 fn a_language_that_spells_a_yaml_boolean_is_quoted() {
2524 let note = render_note(&node_with("file:x", None, Some("no")), None, None);
2525 assert!(
2526 note.content.contains("\nlang: \"no\"\n"),
2527 "a bare `no` is `false` to a 1.1 parser and must be quoted: {}",
2528 note.content
2529 );
2530 assert_eq!(
2531 frontmatter_field(¬e.content, "lang"),
2532 Ok(Some("no".to_owned())),
2533 "and it must still read back as the string: {}",
2534 note.content
2535 );
2536 // And an ordinary language is untouched.
2537 let rust = render_note(&node_with("file:x", None, Some("rust")), None, None);
2538 assert!(rust.content.contains("\nlang: rust\n"), "{}", rust.content);
2539 }
2540
2541 /// Control characters and the separators some parsers fold as line breaks.
2542 #[test]
2543 fn control_characters_cannot_break_out_of_the_block() {
2544 for path in [
2545 "a\nb",
2546 "a\tb",
2547 "a\u{0}b",
2548 "a\u{2028}b",
2549 "a\u{7f}b",
2550 "a\u{85}b",
2551 ] {
2552 let note = render_note(&node_with("file:x", Some(path), None), None, None);
2553 assert_eq!(
2554 frontmatter_field(¬e.content, "path"),
2555 Ok(Some(path.to_owned())),
2556 "path {path:?} must round-trip"
2557 );
2558 // A raw newline would end the scalar and inject a sibling key.
2559 assert_eq!(
2560 note.content.matches("\npath: ").count(),
2561 1,
2562 "the value must stay on one line: {}",
2563 note.content
2564 );
2565 }
2566 }
2567
2568 /// The escaping is *only* an escaping: for a value with nothing to escape it
2569 /// must emit the same bytes it always did, or #442's promise that a
2570 /// single-project vault is byte-identical does not hold.
2571 #[test]
2572 fn an_ordinary_value_is_emitted_exactly_as_before() {
2573 let note = render_note(
2574 &node_with("sym:rust:src/a.rs#Store", Some("src/a.rs"), Some("rust")),
2575 None,
2576 None,
2577 );
2578 assert!(
2579 note.content
2580 .contains("\nkey: \"sym:rust:src/a.rs#Store\"\n")
2581 );
2582 assert!(note.content.contains("\nkind: fn\n"));
2583 assert!(note.content.contains("\npath: \"src/a.rs\"\n"));
2584 assert!(note.content.contains("\nlang: rust\n"));
2585 }
2586
2587 /// The plain-style decision is checked against a real parser rather than
2588 /// against itself: whatever `is_plain_safe` accepts must actually round-trip
2589 /// bare, and whatever it rejects must round-trip quoted.
2590 #[test]
2591 fn the_plain_style_decision_agrees_with_a_real_yaml_parser() {
2592 for value in [
2593 "fn",
2594 "config_key",
2595 "rust",
2596 "Accepted",
2597 "a.b",
2598 "a/b",
2599 "a-b_c",
2600 "no",
2601 "yes",
2602 "true",
2603 "null",
2604 "y",
2605 "N",
2606 "",
2607 " lead",
2608 "trail ",
2609 "a: b",
2610 "a #c",
2611 "{x}",
2612 "[x]",
2613 "*x",
2614 "&x",
2615 "!x",
2616 "#x",
2617 ">x",
2618 "|x",
2619 "%x",
2620 "@x",
2621 "`x",
2622 "\"x",
2623 "'x",
2624 ",x",
2625 "123",
2626 "1.5",
2627 "-x",
2628 ".x",
2629 "a\\b",
2630 ] {
2631 let emitted = super::yaml_scalar(value);
2632 let doc = format!("v: {emitted}");
2633 let parsed = yaml_rust2::YamlLoader::load_from_str(&doc)
2634 .unwrap_or_else(|e| panic!("{value:?} emitted {emitted:?}: {e}"));
2635 assert_eq!(
2636 parsed[0]["v"].as_str(),
2637 Some(value),
2638 "{value:?} emitted as {emitted:?} did not round-trip"
2639 );
2640 }
2641 }
2642}