rto-render 1.20.0

Renderers for Roteiro: docs site, Obsidian vault, and optional MCP server
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
//! The Obsidian-vault renderer: each graph node becomes a markdown note whose
//! edges are `[[wikilinks]]`, so the provenance-tagged graph is browsable in
//! Obsidian's graph view. Notes carry frontmatter `tags` (`roteiro/kind/*`,
//! `roteiro/lang/*`, `roteiro/status/*`) so the graph is colourable/filterable —
//! edge provenance is shown per-link in the body — surface the captured
//! `meta.content` (doc comments, prose, PDF/image text) as the knowledge base,
//! show an ADR's status, and (when the repository's web host is known) a
//! clickable **Source** link to the file. A generated `_Home` note is the overview: what was
//! scanned, counts by kind, provenance breakdown, ADR statuses, intent-debt (with
//! the files it is densest in), an inventory of secret-**named** config keys and
//! their redaction state, and the most depended-on symbols by directed call
//! fan-in.
//! Built from the same [`Explanation`] the query surface returns, so the vault
//! and the CLI agree.

use std::fmt::Write as _;

use rto_graph::Explanation;

/// Filename of the generated overview note (sorts first in the file list).
pub const HOME_NOTE: &str = "_Home.md";

/// A rendered vault note: its filename (with `.md`) and markdown content.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct VaultNote {
    /// Filename including the `.md` extension.
    pub filename: String,
    /// Markdown content.
    pub content: String,
}

/// Map a node key to a filesystem- and wikilink-safe note stem. Characters that
/// are awkward in filenames or Obsidian links (`:` `/` `#` whitespace) collapse
/// to `-`; alphanumerics, `.`, `_` and `-` are kept. The result is **bounded**
/// in length (a grouped Rust `use` can key a 300+ char import node) by truncating
/// and appending a short hash of the full key, so notes stay under filesystem
/// limits while remaining unique and deterministic.
#[must_use]
pub fn note_name(key: &str) -> String {
    // Keep the stem well under the 255-byte filename limit (leaving room for
    // ".md"). The slug is ASCII, so byte length equals char count and slicing is
    // safe. A hash of the full key preserves uniqueness after truncation.
    const MAX: usize = 200;
    let mut out = String::with_capacity(key.len());
    let mut prev_dash = false;
    for c in key.chars() {
        if c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-') {
            out.push(c);
            prev_dash = false;
        } else if !prev_dash {
            out.push('-');
            prev_dash = true;
        }
    }
    let out = out.trim_matches('-');
    if out.len() <= MAX {
        out.to_owned()
    } else {
        format!("{}-{:016x}", &out[..MAX - 17], fnv1a64(key.as_bytes()))
    }
}

/// FNV-1a (64-bit) — a dependency-free, deterministic hash to disambiguate a
/// truncated note stem. No cryptographic properties needed.
fn fnv1a64(bytes: &[u8]) -> u64 {
    let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
    for &b in bytes {
        hash ^= u64::from(b);
        hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
    }
    hash
}

/// Render a node's [`Explanation`] into an Obsidian note: YAML frontmatter (with
/// `tags` for the graph view and an ADR's `status`), a clickable **Source** link
/// (when `source_base` — a web "blob" base like
/// `https://github.com/org/repo/blob/<sha>` — is known and the node has a path),
/// the captured content as the knowledge base, and its edges as provenance-
/// labelled wikilinks.
#[must_use]
pub fn render_note(ex: &Explanation, source_base: Option<&str>) -> VaultNote {
    let meta = &ex.meta;
    let status = meta.get("status").and_then(|v| v.as_str());
    let content = meta.get("content").and_then(|v| v.as_str());

    let mut c = String::new();
    c.push_str("---\n");
    let _ = writeln!(c, "key: \"{}\"", ex.node.key.replace('"', "'"));
    let _ = writeln!(c, "kind: {}", ex.node.kind);
    if let Some(path) = &ex.node.path {
        let _ = writeln!(c, "path: \"{path}\"");
    }
    if let Some(lang) = &ex.node.lang {
        let _ = writeln!(c, "lang: {lang}");
    }
    if let Some(status) = status {
        let _ = writeln!(c, "status: {status}");
    }
    // Nested tags group in Obsidian's tag pane and colour the graph view.
    c.push_str("tags:\n");
    let _ = writeln!(c, "  - roteiro/kind/{}", tag_slug(&ex.node.kind));
    if let Some(lang) = &ex.node.lang {
        let _ = writeln!(c, "  - roteiro/lang/{}", tag_slug(lang));
    }
    if let Some(status) = status {
        let _ = writeln!(c, "  - roteiro/status/{}", tag_slug(status));
    }
    c.push_str("---\n\n");

    let _ = writeln!(c, "# {}", ex.node.name);
    if let Some(status) = status {
        let _ = writeln!(c, "\n> **Status:** {status}");
    }

    // A clickable link to the file this node comes from. An absolute URL, so it
    // works from the downloaded vault too (which has no repo files beside it).
    if let (Some(base), Some(path)) = (source_base, ex.node.path.as_deref()) {
        let _ = writeln!(
            c,
            "\n**Source:** [`{path}`]({}/{path})",
            base.trim_end_matches('/')
        );
    }

    // The knowledge base: the captured doc comment / prose / PDF / image text.
    if let Some(content) = content.map(str::trim).filter(|s| !s.is_empty()) {
        c.push_str("\n## Content\n\n");
        c.push_str(content);
        c.push('\n');
    }

    if !ex.outgoing.is_empty() {
        c.push_str("\n## Outgoing\n\n");
        for e in &ex.outgoing {
            let _ = writeln!(
                c,
                "- {} ({}){} → [[{}]]",
                e.kind,
                e.provenance,
                confidence(e.confidence),
                note_name(&e.node)
            );
        }
    }
    if !ex.incoming.is_empty() {
        c.push_str("\n## Incoming\n\n");
        for e in &ex.incoming {
            let _ = writeln!(
                c,
                "- [[{}]] {} ({}){}",
                note_name(&e.node),
                e.kind,
                e.provenance,
                confidence(e.confidence)
            );
        }
    }

    VaultNote {
        filename: format!("{}.md", note_name(&ex.node.key)),
        content: c,
    }
}

/// `" (0.82)"` for an inferred edge's confidence, else empty.
fn confidence(c: Option<f64>) -> String {
    c.map_or_else(String::new, |c| format!(" ({c:.2})"))
}

/// A tag-safe slug: lowercase, non-alphanumeric runs → `-`. Keeps Obsidian tags
/// (`roteiro/kind/adr-section`) valid and stable.
fn tag_slug(s: &str) -> String {
    let mut out = String::with_capacity(s.len());
    let mut prev_dash = false;
    for ch in s.chars() {
        if ch.is_ascii_alphanumeric() {
            out.push(ch.to_ascii_lowercase());
            prev_dash = false;
        } else if !prev_dash {
            out.push('-');
            prev_dash = true;
        }
    }
    out.trim_matches('-').to_owned()
}

/// One ADR in the overview, with its lifecycle status.
#[derive(Debug, Clone)]
pub struct AdrEntry {
    /// The ADR node key (`adr:<id>`).
    pub key: String,
    /// The ADR title.
    pub name: String,
    /// Lifecycle status (`Accepted`, …), if recorded.
    pub status: Option<String>,
}

/// The `_Home` overview's config-secret inventory figures.
///
/// Counts and file paths only — deliberately not the key names, which belong in
/// `roteiro config-secrets` where the caveat can be stated at length. A vault note
/// is read casually and out of context, which is exactly the wrong place for a
/// list that looks like a secret scan's output.
#[derive(Debug, Clone, Default)]
pub struct ConfigSecretSummary {
    /// Config keys whose **name** matched the secret-name heuristic.
    pub secret_named: usize,
    /// Of those, how many had their value redacted before persistence.
    pub redacted: usize,
    /// Of those, how many are declared in code with no literal value.
    pub declared: usize,
    /// Of those, how many carry an unredacted value. Expected to be zero.
    pub unredacted: usize,
    /// Distinct files carrying at least one secret-named key, ordered and capped
    /// by the caller.
    pub files: Vec<String>,
}

/// One file in the `_Home` overview's intent-debt density table.
#[derive(Debug, Clone)]
pub struct DensityEntry {
    /// Repository-relative path, used for both the wikilink and the label.
    pub path: String,
    /// Retained markers in the file.
    pub markers: u32,
    /// The file's length in lines — the denominator.
    pub lines: u32,
    /// Markers per 1,000 lines.
    pub per_kloc: f64,
}

/// One node in the `_Home` overview's directed-coupling table.
#[derive(Debug, Clone)]
pub struct CouplingEntry {
    /// The node key, for the wikilink.
    pub key: String,
    /// The symbol name.
    pub name: String,
    /// Distinct callers.
    pub fan_in: u32,
    /// Distinct callees.
    pub fan_out: u32,
}

/// Aggregate figures for the vault's `_Home` overview note.
#[derive(Debug, Clone, Default)]
pub struct VaultSummary {
    /// Name of the scanned project (repository directory).
    pub project: String,
    /// Total node and edge counts.
    pub total_nodes: usize,
    /// Total edge count.
    pub total_edges: usize,
    /// `(kind, count)` for each node kind, most-frequent first.
    pub node_counts: Vec<(String, usize)>,
    /// `(provenance, edge count)` — `derived` / `authored` / `inferred`.
    pub edge_provenance: Vec<(String, usize)>,
    /// The ADRs, with status.
    pub adrs: Vec<AdrEntry>,
    /// `(category, count)` of intent-debt markers.
    pub debt: Vec<(String, usize)>,
    /// The files where that debt is most **concentrated**, already ranked and
    /// capped by the caller. Empty when the graph has no markers, or when no
    /// file carrying one has a recorded length.
    pub densest_files: Vec<DensityEntry>,
    /// Secret-named config keys and their redaction state. `None` when the graph
    /// holds no secret-named config key — the section is then absent rather than
    /// rendering a row of zeroes, which would read as a clean bill of health this
    /// lens cannot give.
    pub config_secrets: Option<ConfigSecretSummary>,
    /// The most depended-on symbols by **directed** call fan-in, already ranked
    /// and capped by the caller. Empty when the graph has no `calls` edges.
    pub most_called: Vec<CouplingEntry>,
    /// Web root of the repository (`https://host/owner/repo`), if derivable from
    /// the git remote — for a "Repository" link in the overview.
    pub repo_url: Option<String>,
    /// Hex commit the graph was rendered from, for a permalink note.
    pub commit: Option<String>,
}

/// Render the vault's overview note: what was scanned, the structure by kind,
/// the provenance breakdown, the decisions (ADRs) and their status, the
/// intent-debt summary, and how to navigate. The entry point for the vault.
#[must_use]
pub fn render_home(s: &VaultSummary) -> VaultNote {
    let mut c = String::new();
    c.push_str("---\ntags:\n  - roteiro/home\n---\n\n");
    let _ = writeln!(c, "# {} — knowledge graph", s.project);
    c.push_str(
        "\n*A browsable snapshot of this codebase as one **knowledge graph**, \
         generated by [Roteiro](https://roteiro.dev). Every symbol, document and \
         decision is a note, linked to the things it relates to.*\n",
    );
    c.push_str(
        "\n**How to read it.** Open any note to see what a thing is, the intent or \
         docs behind it (its **Content**), where it lives (its **Source** link), \
         and how it connects (**Outgoing**/**Incoming** links). Each link is \
         labelled with how the fact was established — `derived` (extracted from \
         code), `authored` (human intent: ADRs, blueprints, annotations), or \
         `inferred` (a scored suggestion). Open Obsidian's **graph view** to see \
         the whole thing at once.\n",
    );
    let _ = writeln!(
        c,
        "\n**{} nodes**, **{} edges** across the project.",
        s.total_nodes, s.total_edges
    );
    if let Some(repo) = &s.repo_url {
        let _ = write!(c, "\n**Repository:** [{repo}]({repo})");
        if let Some(commit) = &s.commit {
            let short = &commit[..commit.len().min(12)];
            let _ = write!(c, " · rendered at commit `{short}`");
        }
        c.push('\n');
    }

    c.push_str("\n## Structure\n\n| Kind | Count |\n| --- | --- |\n");
    for (kind, n) in &s.node_counts {
        let _ = writeln!(c, "| {kind} | {n} |");
    }

    if !s.edge_provenance.is_empty() {
        c.push_str("\n## Provenance\n\n| Provenance | Edges |\n| --- | --- |\n");
        for (prov, n) in &s.edge_provenance {
            let _ = writeln!(c, "| {prov} | {n} |");
        }
    }

    c.push_str("\n## Decisions (ADRs)\n\n");
    if s.adrs.is_empty() {
        c.push_str("*No ADRs found.*\n");
    } else {
        for adr in &s.adrs {
            let status = adr.status.as_deref().unwrap_or("");
            let _ = writeln!(
                c,
                "- **{status}** — [[{}|{}]]",
                note_name(&adr.key),
                adr.name
            );
        }
    }

    c.push_str("\n## Intent debt\n\n");
    if s.debt.is_empty() {
        c.push_str("*None recorded.*\n");
    } else {
        c.push_str("| Category | Count |\n| --- | --- |\n");
        for (cat, n) in &s.debt {
            let _ = writeln!(c, "| {cat} | {n} |");
        }
    }

    if !s.densest_files.is_empty() {
        c.push_str(
            "\n### Densest files (markers per 1,000 lines)\n\n\
             *Where the debt above is concentrated, rather than where there is \
             most of it — a raw count ranks the biggest file first by \
             construction. The denominator is file length: every line, blanks and \
             comments included, not source lines of code. Prose matches (`for \
             now`, `tbd`) count too, so a design document can rank high.*\n\n",
        );
        c.push_str("| File | Markers | Lines | Per 1k |\n| --- | --- | --- | --- |\n");
        for e in &s.densest_files {
            let _ = writeln!(
                c,
                "| [[{}\\|{}]] | {} | {} | {:.2} |",
                note_name(&format!("file:{}", e.path)),
                e.path,
                e.markers,
                e.lines,
                e.per_kloc
            );
        }
    }

    if let Some(cs) = &s.config_secrets {
        c.push_str("\n## Config keys named like secrets\n\n");
        let _ = writeln!(
            c,
            "**{}** secret-named config key(s): {} redacted before storage, {} \
             declared in code without a value, {} unredacted.",
            cs.secret_named, cs.redacted, cs.declared, cs.unredacted
        );
        if cs.unredacted > 0 {
            let _ = writeln!(
                c,
                "\n> [!warning] {} key(s) carry an **unredacted** value. Extraction \
                 always redacts, so these came from an import layer — inspect the \
                 importing tool, not this repository.",
                cs.unredacted
            );
        }
        if !cs.files.is_empty() {
            c.push_str("\nIn:\n");
            for path in &cs.files {
                let _ = writeln!(c, "- [[{}\\|{path}]]", note_name(&format!("file:{path}")));
            }
        }
        // The caveat is unconditional and comes last, so it is the final thing read
        // in this section. A vault note is browsed out of context; this is exactly
        // where "config keys named like secrets" would otherwise be misread as a
        // secret scan that came back clean.
        c.push_str(
            "\n*An inventory of config keys whose **names** look secret, not a secret \
             scan. Values are redacted before they are stored, so this reports that \
             such keys exist and were redacted — never a value. It cannot see a \
             hardcoded credential in source code, cannot judge whether a value is \
             valid, and cannot tell a real secret from a placeholder. A credential \
             under an innocuous key name (`dsn`, `endpoint`) does not appear here at \
             all, so this section being small says nothing about whether this \
             repository leaks secrets.*\n",
        );
    }

    if !s.most_called.is_empty() {
        c.push_str(
            "\n## Most depended-on (call fan-in)\n\n\
             *Distinct callers and callees over `calls` edges — direction kept, so \
             \"everything calls this\" and \"this calls everything\" are not the same \
             row. Call targets are resolved by simple name, so a short, generically-\
             named function can absorb every call to that name: read a large fan-in on \
             one as a question, not a finding.*\n\n",
        );
        c.push_str("| Symbol | Called by | Calls |\n| --- | --- | --- |\n");
        for e in &s.most_called {
            let _ = writeln!(
                c,
                "| [[{}\\|{}]] | {} | {} |",
                note_name(&e.key),
                e.name,
                e.fan_in,
                e.fan_out
            );
        }
    }

    c.push_str(
        "\n## Navigating this vault\n\n\
         - Open the **graph view** to see the whole codebase; notes are coloured/\
         filterable by their `roteiro/kind/*`, `roteiro/lang/*` and \
         `roteiro/status/*` tags.\n\
         - Each note carries its captured **content** (doc comments, prose, PDF/\
         image text) and its provenance-labelled incoming/outgoing links.\n\
         - Start from an ADR above, or search the tag pane for a kind.\n",
    );

    VaultNote {
        filename: HOME_NOTE.to_owned(),
        content: c,
    }
}

#[cfg(test)]
mod tests {
    use super::{
        AdrEntry, ConfigSecretSummary, CouplingEntry, DensityEntry, HOME_NOTE, VaultSummary,
        note_name, render_home, render_note,
    };
    use rto_graph::{EdgeRef, Explanation, NodeSummary};

    #[test]
    fn note_name_is_safe_and_stable() {
        assert_eq!(
            note_name("sym:rust:src/a.rs#Store"),
            "sym-rust-src-a.rs-Store"
        );
        assert_eq!(note_name("adr:0001"), "adr-0001");
        assert_eq!(note_name("file:src/main.rs"), "file-src-main.rs");
    }

    #[test]
    fn render_note_emits_frontmatter_and_wikilinks() {
        let ex = Explanation {
            schema: rto_graph::SCHEMA,
            node: NodeSummary {
                key: "sym:rust:a.rs#main".into(),
                kind: "fn".into(),
                name: "main".into(),
                path: Some("a.rs".into()),
                lang: Some("rust".into()),
            },
            meta: serde_json::Value::Null,
            outgoing: vec![EdgeRef {
                kind: "calls".into(),
                provenance: "derived",
                confidence: None,
                node: "sym:rust:a.rs#helper".into(),
            }],
            incoming: vec![EdgeRef {
                kind: "references".into(),
                provenance: "authored",
                confidence: None,
                node: "adr:0001".into(),
            }],
        };
        let note = render_note(&ex, None);
        assert_eq!(note.filename, "sym-rust-a.rs-main.md");
        assert!(note.content.contains("kind: fn"));
        // No source base → no Source link.
        assert!(!note.content.contains("**Source:**"));
        assert!(note.content.contains("# main"));
        assert!(
            note.content
                .contains("- calls (derived) → [[sym-rust-a.rs-helper]]")
        );
        assert!(
            note.content
                .contains("- [[adr-0001]] references (authored) →")
        );
        // Tags for the graph view.
        assert!(note.content.contains("- roteiro/kind/fn"));
        assert!(note.content.contains("- roteiro/lang/rust"));
    }

    #[test]
    fn note_name_bounds_long_keys_deterministically() {
        let long = format!("import:rust:{}", "a::b::c,".repeat(60));
        let a = note_name(&long);
        let b = note_name(&long);
        assert_eq!(a, b, "deterministic");
        assert!(
            a.len() <= 205,
            "bounded under the filename limit: {}",
            a.len()
        );
        assert_ne!(
            note_name(&format!("{long}x")),
            a,
            "different keys stay distinct after truncation"
        );
    }

    #[test]
    fn render_note_surfaces_content_and_status() {
        let ex = Explanation {
            schema: rto_graph::SCHEMA,
            node: NodeSummary {
                key: "adr:0001".into(),
                kind: "adr".into(),
                name: "Build Roteiro".into(),
                path: Some("docs/adr/0001.md".into()),
                lang: None,
            },
            meta: serde_json::json!({ "status": "Accepted", "content": "The decision text." }),
            outgoing: vec![],
            incoming: vec![],
        };
        let note = render_note(&ex, Some("https://github.com/org/repo/blob/abc123"));
        assert!(note.content.contains("status: Accepted"));
        assert!(note.content.contains("- roteiro/status/accepted"));
        assert!(note.content.contains("> **Status:** Accepted"));
        assert!(note.content.contains("## Content\n\nThe decision text."));
        // A clickable link to the actual ADR file on the repository host.
        assert!(
            note.content.contains(
                "**Source:** [`docs/adr/0001.md`](https://github.com/org/repo/blob/abc123/docs/adr/0001.md)"
            ),
            "{}",
            note.content
        );
    }

    #[test]
    fn render_note_shows_inferred_confidence() {
        let ex = Explanation {
            schema: rto_graph::SCHEMA,
            node: NodeSummary {
                key: "file:a.md".into(),
                kind: "file".into(),
                name: "a.md".into(),
                path: Some("a.md".into()),
                lang: None,
            },
            meta: serde_json::Value::Null,
            outgoing: vec![EdgeRef {
                kind: "related".into(),
                provenance: "inferred",
                confidence: Some(0.82),
                node: "file:b.md".into(),
            }],
            incoming: vec![],
        };
        let note = render_note(&ex, None);
        assert!(
            note.content
                .contains("related (inferred) (0.82) → [[file-b.md]]"),
            "{}",
            note.content
        );
    }

    #[test]
    fn render_home_summarises_the_graph() {
        let summary = VaultSummary {
            project: "demo".into(),
            total_nodes: 3,
            total_edges: 2,
            node_counts: vec![("fn".into(), 2), ("adr".into(), 1)],
            edge_provenance: vec![("derived".into(), 1), ("authored".into(), 1)],
            adrs: vec![AdrEntry {
                key: "adr:0001".into(),
                name: "First".into(),
                status: Some("Accepted".into()),
            }],
            debt: vec![("todo".into(), 4)], // roteiro:ignore
            densest_files: vec![DensityEntry {
                path: "src/small.rs".into(),
                markers: 3,
                lines: 120,
                per_kloc: 25.0,
            }],
            config_secrets: Some(ConfigSecretSummary {
                secret_named: 4,
                redacted: 3,
                declared: 1,
                unredacted: 0,
                files: vec![".env".into()],
            }),
            most_called: vec![CouplingEntry {
                key: "sym:rust:a.rs#helper".into(),
                name: "helper".into(),
                fan_in: 7,
                fan_out: 1,
            }],
            repo_url: Some("https://github.com/org/repo".into()),
            commit: Some("abcdef0123456789".into()),
        };
        let note = render_home(&summary);
        assert_eq!(note.filename, HOME_NOTE);
        assert!(note.content.contains("# demo — knowledge graph"));
        assert!(note.content.contains("**3 nodes**, **2 edges**"));
        assert!(note.content.contains("| fn | 2 |"));
        assert!(note.content.contains("| derived | 1 |"));
        assert!(note.content.contains("**Accepted** — [[adr-0001|First]]"));
        assert!(note.content.contains("| todo | 4 |")); // roteiro:ignore
        // Directed coupling: the two fans are separate columns, and the wikilink's
        // own `|` is escaped so it cannot break the table it sits in.
        assert!(
            note.content
                .contains("| [[sym-rust-a.rs-helper\\|helper]] | 7 | 1 |"),
            "{}",
            note.content
        );
        assert!(
            note.content.contains("resolved by simple name"),
            "the precision caveat travels with the figures"
        );
        // Density: the count and the denominator are both shown, so the ratio can
        // be checked rather than taken on trust, and the wikilink's own `|` is
        // escaped so it cannot break the table it sits in.
        assert!(
            note.content
                .contains("| [[file-src-small.rs\\|src/small.rs]] | 3 | 120 | 25.00 |"),
            "{}",
            note.content
        );
        assert!(
            note.content.contains("not source lines of code"),
            "the denominator caveat travels with the figures"
        );
        // Config secrets: counts and files, and no key names — a vault note is
        // browsed out of context, which is the wrong place for a list that would
        // read as a secret scan's output.
        assert!(
            note.content.contains(
                "**4** secret-named config key(s): 3 redacted before storage, 1 \
                 declared in code without a value, 0 unredacted."
            ),
            "{}",
            note.content
        );
        assert!(
            note.content.contains("- [[file-.env\\|.env]]"),
            "{}",
            note.content
        );
        assert!(
            note.content.contains("not a secret scan")
                && note.content.contains("cannot see a hardcoded credential"),
            "the limitation travels with the figures: {}",
            note.content
        );
        assert!(
            !note.content.contains("[!warning]"),
            "no warning when nothing is unredacted: {}",
            note.content
        );
        // A repository link + short-commit permalink note.
        assert!(
            note.content
                .contains("**Repository:** [https://github.com/org/repo](https://github.com/org/repo) · rendered at commit `abcdef012345`"),
            "{}",
            note.content
        );
    }

    #[test]
    fn render_home_omits_density_for_a_graph_with_no_markers() {
        // A clean repository has no markers, so there is no density to rank. An
        // empty table under a heading reads as "measured, and there is nothing";
        // the section is absent instead. Same rule as the coupling table below.
        let note = render_home(&VaultSummary {
            project: "clean".into(),
            total_nodes: 1,
            ..VaultSummary::default()
        });
        assert!(
            !note.content.contains("Densest files"),
            "no heading without rows: {}",
            note.content
        );
        // The intent-debt section itself still renders — density is an addition
        // to it, not a replacement.
        assert!(note.content.contains("## Intent debt"));
        assert!(note.content.contains("*None recorded.*"));
    }

    #[test]
    fn render_home_omits_config_secrets_rather_than_rendering_zeroes() {
        // A row of zeroes under this heading would read as "scanned, and clean" —
        // a conclusion the lens cannot support, since a credential under an
        // innocuous key name never appears in it. The section is absent instead.
        let note = render_home(&VaultSummary {
            project: "clean".into(),
            total_nodes: 1,
            ..VaultSummary::default()
        });
        assert!(
            !note.content.contains("named like secrets"),
            "no heading without figures: {}",
            note.content
        );
    }

    #[test]
    fn render_home_warns_loudly_about_an_unredacted_value() {
        // Extraction cannot produce this state, so if it appears something else
        // put an unredacted value in the store — and the note must say where to
        // look rather than implicating the repository.
        let note = render_home(&VaultSummary {
            project: "imported".into(),
            total_nodes: 1,
            config_secrets: Some(ConfigSecretSummary {
                secret_named: 1,
                redacted: 0,
                declared: 0,
                unredacted: 1,
                files: vec!["imported.env".into()],
            }),
            ..VaultSummary::default()
        });
        assert!(
            note.content.contains("[!warning]") && note.content.contains("**unredacted**"),
            "{}",
            note.content
        );
        assert!(
            note.content.contains("came from an import layer"),
            "and it points at the importing tool, not the repository: {}",
            note.content
        );
    }

    #[test]
    fn render_home_omits_coupling_for_a_graph_with_no_calls() {
        // A prose-only vault has no `calls` edges. An empty table under a heading
        // reads as "measured, and there is nothing" — the section is absent instead.
        let note = render_home(&VaultSummary {
            project: "docs".into(),
            total_nodes: 1,
            ..VaultSummary::default()
        });
        assert!(
            !note.content.contains("Most depended-on"),
            "no heading without rows: {}",
            note.content
        );
        // The rest of the overview is unaffected.
        assert!(note.content.contains("# docs — knowledge graph"));
    }
}