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