Skip to main content

rto_render/
obsidian.rs

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