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 captured
6//! `meta.content` (doc comments, prose, PDF/image text) as the knowledge base,
7//! show an ADR's status, and (when the repository's web host is known) a
8//! clickable **Source** link to the file. A generated `_Home` note is the overview: what was
9//! scanned, counts by kind, provenance breakdown, ADR statuses, intent-debt (with
10//! the files it is densest in), an inventory of secret-**named** config keys and
11//! their redaction state, and the most depended-on symbols by directed call
12//! fan-in.
13//! Built from the same [`Explanation`] the query surface returns, so the vault
14//! and the CLI agree.
15
16use std::fmt::Write as _;
17
18use rto_graph::Explanation;
19
20/// Filename of the generated overview note (sorts first in the file list).
21pub const HOME_NOTE: &str = "_Home.md";
22
23/// A rendered vault note: its filename (with `.md`) and markdown content.
24#[derive(Debug, Clone, PartialEq, Eq)]
25pub struct VaultNote {
26    /// Filename including the `.md` extension.
27    pub filename: String,
28    /// Markdown content.
29    pub content: String,
30}
31
32/// Map a node key to a filesystem- and wikilink-safe note stem. Characters that
33/// are awkward in filenames or Obsidian links (`:` `/` `#` whitespace) collapse
34/// to `-`; alphanumerics, `.`, `_` and `-` are kept. The result is **bounded**
35/// in length (a grouped Rust `use` can key a 300+ char import node) by truncating
36/// and appending a short hash of the full key, so notes stay under filesystem
37/// limits while remaining unique and deterministic.
38#[must_use]
39pub fn note_name(key: &str) -> String {
40    // Keep the stem well under the 255-byte filename limit (leaving room for
41    // ".md"). The slug is ASCII, so byte length equals char count and slicing is
42    // safe. A hash of the full key preserves uniqueness after truncation.
43    const MAX: usize = 200;
44    let mut out = String::with_capacity(key.len());
45    let mut prev_dash = false;
46    for c in key.chars() {
47        if c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-') {
48            out.push(c);
49            prev_dash = false;
50        } else if !prev_dash {
51            out.push('-');
52            prev_dash = true;
53        }
54    }
55    let out = out.trim_matches('-');
56    if out.len() <= MAX {
57        out.to_owned()
58    } else {
59        format!("{}-{:016x}", &out[..MAX - 17], fnv1a64(key.as_bytes()))
60    }
61}
62
63/// FNV-1a (64-bit) — a dependency-free, deterministic hash to disambiguate a
64/// truncated note stem. No cryptographic properties needed.
65fn fnv1a64(bytes: &[u8]) -> u64 {
66    let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
67    for &b in bytes {
68        hash ^= u64::from(b);
69        hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
70    }
71    hash
72}
73
74/// Render a node's [`Explanation`] into an Obsidian note: YAML frontmatter (with
75/// `tags` for the graph view and an ADR's `status`), a clickable **Source** link
76/// (when `source_base` — a web "blob" base like
77/// `https://github.com/org/repo/blob/<sha>` — is known and the node has a path),
78/// the captured content as the knowledge base, and its edges as provenance-
79/// labelled wikilinks.
80#[must_use]
81pub fn render_note(ex: &Explanation, source_base: Option<&str>) -> VaultNote {
82    let meta = &ex.meta;
83    let status = meta.get("status").and_then(|v| v.as_str());
84    let content = meta.get("content").and_then(|v| v.as_str());
85
86    let mut c = String::new();
87    c.push_str("---\n");
88    let _ = writeln!(c, "key: \"{}\"", ex.node.key.replace('"', "'"));
89    let _ = writeln!(c, "kind: {}", ex.node.kind);
90    if let Some(path) = &ex.node.path {
91        let _ = writeln!(c, "path: \"{path}\"");
92    }
93    if let Some(lang) = &ex.node.lang {
94        let _ = writeln!(c, "lang: {lang}");
95    }
96    if let Some(status) = status {
97        let _ = writeln!(c, "status: {status}");
98    }
99    // Nested tags group in Obsidian's tag pane and colour the graph view.
100    c.push_str("tags:\n");
101    let _ = writeln!(c, "  - roteiro/kind/{}", tag_slug(&ex.node.kind));
102    if let Some(lang) = &ex.node.lang {
103        let _ = writeln!(c, "  - roteiro/lang/{}", tag_slug(lang));
104    }
105    if let Some(status) = status {
106        let _ = writeln!(c, "  - roteiro/status/{}", tag_slug(status));
107    }
108    c.push_str("---\n\n");
109
110    let _ = writeln!(c, "# {}", ex.node.name);
111    if let Some(status) = status {
112        let _ = writeln!(c, "\n> **Status:** {status}");
113    }
114
115    // A clickable link to the file this node comes from. An absolute URL, so it
116    // works from the downloaded vault too (which has no repo files beside it).
117    if let (Some(base), Some(path)) = (source_base, ex.node.path.as_deref()) {
118        let _ = writeln!(
119            c,
120            "\n**Source:** [`{path}`]({}/{path})",
121            base.trim_end_matches('/')
122        );
123    }
124
125    // The knowledge base: the captured doc comment / prose / PDF / image text.
126    if let Some(content) = content.map(str::trim).filter(|s| !s.is_empty()) {
127        c.push_str("\n## Content\n\n");
128        c.push_str(content);
129        c.push('\n');
130    }
131
132    if !ex.outgoing.is_empty() {
133        c.push_str("\n## Outgoing\n\n");
134        for e in &ex.outgoing {
135            let _ = writeln!(
136                c,
137                "- {} ({}){} → [[{}]]",
138                e.kind,
139                e.provenance,
140                confidence(e.confidence),
141                note_name(&e.node)
142            );
143        }
144    }
145    if !ex.incoming.is_empty() {
146        c.push_str("\n## Incoming\n\n");
147        for e in &ex.incoming {
148            let _ = writeln!(
149                c,
150                "- [[{}]] {} ({}){} →",
151                note_name(&e.node),
152                e.kind,
153                e.provenance,
154                confidence(e.confidence)
155            );
156        }
157    }
158
159    VaultNote {
160        filename: format!("{}.md", note_name(&ex.node.key)),
161        content: c,
162    }
163}
164
165/// `" (0.82)"` for an inferred edge's confidence, else empty.
166fn confidence(c: Option<f64>) -> String {
167    c.map_or_else(String::new, |c| format!(" ({c:.2})"))
168}
169
170/// A tag-safe slug: lowercase, non-alphanumeric runs → `-`. Keeps Obsidian tags
171/// (`roteiro/kind/adr-section`) valid and stable.
172fn tag_slug(s: &str) -> String {
173    let mut out = String::with_capacity(s.len());
174    let mut prev_dash = false;
175    for ch in s.chars() {
176        if ch.is_ascii_alphanumeric() {
177            out.push(ch.to_ascii_lowercase());
178            prev_dash = false;
179        } else if !prev_dash {
180            out.push('-');
181            prev_dash = true;
182        }
183    }
184    out.trim_matches('-').to_owned()
185}
186
187/// One ADR in the overview, with its lifecycle status.
188#[derive(Debug, Clone)]
189pub struct AdrEntry {
190    /// The ADR node key (`adr:<id>`).
191    pub key: String,
192    /// The ADR title.
193    pub name: String,
194    /// Lifecycle status (`Accepted`, …), if recorded.
195    pub status: Option<String>,
196}
197
198/// The `_Home` overview's config-secret inventory figures.
199///
200/// Counts and file paths only — deliberately not the key names, which belong in
201/// `roteiro config-secrets` where the caveat can be stated at length. A vault note
202/// is read casually and out of context, which is exactly the wrong place for a
203/// list that looks like a secret scan's output.
204#[derive(Debug, Clone, Default)]
205pub struct ConfigSecretSummary {
206    /// Config keys whose **name** matched the secret-name heuristic.
207    pub secret_named: usize,
208    /// Of those, how many had their value redacted before persistence.
209    pub redacted: usize,
210    /// Of those, how many are declared in code with no literal value.
211    pub declared: usize,
212    /// Of those, how many carry an unredacted value. Expected to be zero.
213    pub unredacted: usize,
214    /// Distinct files carrying at least one secret-named key, ordered and capped
215    /// by the caller.
216    pub files: Vec<String>,
217}
218
219/// One file in the `_Home` overview's intent-debt density table.
220#[derive(Debug, Clone)]
221pub struct DensityEntry {
222    /// Repository-relative path, used for both the wikilink and the label.
223    pub path: String,
224    /// Retained markers in the file.
225    pub markers: u32,
226    /// The file's length in lines — the denominator.
227    pub lines: u32,
228    /// Markers per 1,000 lines.
229    pub per_kloc: f64,
230}
231
232/// One node in the `_Home` overview's directed-coupling table.
233#[derive(Debug, Clone)]
234pub struct CouplingEntry {
235    /// The node key, for the wikilink.
236    pub key: String,
237    /// The symbol name.
238    pub name: String,
239    /// Distinct callers.
240    pub fan_in: u32,
241    /// Distinct callees.
242    pub fan_out: u32,
243}
244
245/// Aggregate figures for the vault's `_Home` overview note.
246#[derive(Debug, Clone, Default)]
247pub struct VaultSummary {
248    /// Name of the scanned project (repository directory).
249    pub project: String,
250    /// Total node and edge counts.
251    pub total_nodes: usize,
252    /// Total edge count.
253    pub total_edges: usize,
254    /// `(kind, count)` for each node kind, most-frequent first.
255    pub node_counts: Vec<(String, usize)>,
256    /// `(provenance, edge count)` — `derived` / `authored` / `inferred`.
257    pub edge_provenance: Vec<(String, usize)>,
258    /// The ADRs, with status.
259    pub adrs: Vec<AdrEntry>,
260    /// `(category, count)` of intent-debt markers.
261    pub debt: Vec<(String, usize)>,
262    /// The files where that debt is most **concentrated**, already ranked and
263    /// capped by the caller. Empty when the graph has no markers, or when no
264    /// file carrying one has a recorded length.
265    pub densest_files: Vec<DensityEntry>,
266    /// Secret-named config keys and their redaction state. `None` when the graph
267    /// holds no secret-named config key — the section is then absent rather than
268    /// rendering a row of zeroes, which would read as a clean bill of health this
269    /// lens cannot give.
270    pub config_secrets: Option<ConfigSecretSummary>,
271    /// The most depended-on symbols by **directed** call fan-in, already ranked
272    /// and capped by the caller. Empty when the graph has no `calls` edges.
273    pub most_called: Vec<CouplingEntry>,
274    /// Web root of the repository (`https://host/owner/repo`), if derivable from
275    /// the git remote — for a "Repository" link in the overview.
276    pub repo_url: Option<String>,
277    /// Hex commit the graph was rendered from, for a permalink note.
278    pub commit: Option<String>,
279}
280
281/// Render the vault's overview note: what was scanned, the structure by kind,
282/// the provenance breakdown, the decisions (ADRs) and their status, the
283/// intent-debt summary, and how to navigate. The entry point for the vault.
284#[must_use]
285pub fn render_home(s: &VaultSummary) -> VaultNote {
286    let mut c = String::new();
287    c.push_str("---\ntags:\n  - roteiro/home\n---\n\n");
288    let _ = writeln!(c, "# {} — knowledge graph", s.project);
289    c.push_str(
290        "\n*A browsable snapshot of this codebase as one **knowledge graph**, \
291         generated by [Roteiro](https://roteiro.dev). Every symbol, document and \
292         decision is a note, linked to the things it relates to.*\n",
293    );
294    c.push_str(
295        "\n**How to read it.** Open any note to see what a thing is, the intent or \
296         docs behind it (its **Content**), where it lives (its **Source** link), \
297         and how it connects (**Outgoing**/**Incoming** links). Each link is \
298         labelled with how the fact was established — `derived` (extracted from \
299         code), `authored` (human intent: ADRs, blueprints, annotations), or \
300         `inferred` (a scored suggestion). Open Obsidian's **graph view** to see \
301         the whole thing at once.\n",
302    );
303    let _ = writeln!(
304        c,
305        "\n**{} nodes**, **{} edges** across the project.",
306        s.total_nodes, s.total_edges
307    );
308    if let Some(repo) = &s.repo_url {
309        let _ = write!(c, "\n**Repository:** [{repo}]({repo})");
310        if let Some(commit) = &s.commit {
311            let short = &commit[..commit.len().min(12)];
312            let _ = write!(c, " · rendered at commit `{short}`");
313        }
314        c.push('\n');
315    }
316
317    c.push_str("\n## Structure\n\n| Kind | Count |\n| --- | --- |\n");
318    for (kind, n) in &s.node_counts {
319        let _ = writeln!(c, "| {kind} | {n} |");
320    }
321
322    if !s.edge_provenance.is_empty() {
323        c.push_str("\n## Provenance\n\n| Provenance | Edges |\n| --- | --- |\n");
324        for (prov, n) in &s.edge_provenance {
325            let _ = writeln!(c, "| {prov} | {n} |");
326        }
327    }
328
329    c.push_str("\n## Decisions (ADRs)\n\n");
330    if s.adrs.is_empty() {
331        c.push_str("*No ADRs found.*\n");
332    } else {
333        for adr in &s.adrs {
334            let status = adr.status.as_deref().unwrap_or("—");
335            let _ = writeln!(
336                c,
337                "- **{status}** — [[{}|{}]]",
338                note_name(&adr.key),
339                adr.name
340            );
341        }
342    }
343
344    c.push_str("\n## Intent debt\n\n");
345    if s.debt.is_empty() {
346        c.push_str("*None recorded.*\n");
347    } else {
348        c.push_str("| Category | Count |\n| --- | --- |\n");
349        for (cat, n) in &s.debt {
350            let _ = writeln!(c, "| {cat} | {n} |");
351        }
352    }
353
354    if !s.densest_files.is_empty() {
355        c.push_str(
356            "\n### Densest files (markers per 1,000 lines)\n\n\
357             *Where the debt above is concentrated, rather than where there is \
358             most of it — a raw count ranks the biggest file first by \
359             construction. The denominator is file length: every line, blanks and \
360             comments included, not source lines of code. Prose matches (`for \
361             now`, `tbd`) count too, so a design document can rank high.*\n\n",
362        );
363        c.push_str("| File | Markers | Lines | Per 1k |\n| --- | --- | --- | --- |\n");
364        for e in &s.densest_files {
365            let _ = writeln!(
366                c,
367                "| [[{}\\|{}]] | {} | {} | {:.2} |",
368                note_name(&format!("file:{}", e.path)),
369                e.path,
370                e.markers,
371                e.lines,
372                e.per_kloc
373            );
374        }
375    }
376
377    if let Some(cs) = &s.config_secrets {
378        c.push_str("\n## Config keys named like secrets\n\n");
379        let _ = writeln!(
380            c,
381            "**{}** secret-named config key(s): {} redacted before storage, {} \
382             declared in code without a value, {} unredacted.",
383            cs.secret_named, cs.redacted, cs.declared, cs.unredacted
384        );
385        if cs.unredacted > 0 {
386            let _ = writeln!(
387                c,
388                "\n> [!warning] {} key(s) carry an **unredacted** value. Extraction \
389                 always redacts, so these came from an import layer — inspect the \
390                 importing tool, not this repository.",
391                cs.unredacted
392            );
393        }
394        if !cs.files.is_empty() {
395            c.push_str("\nIn:\n");
396            for path in &cs.files {
397                let _ = writeln!(c, "- [[{}\\|{path}]]", note_name(&format!("file:{path}")));
398            }
399        }
400        // The caveat is unconditional and comes last, so it is the final thing read
401        // in this section. A vault note is browsed out of context; this is exactly
402        // where "config keys named like secrets" would otherwise be misread as a
403        // secret scan that came back clean.
404        c.push_str(
405            "\n*An inventory of config keys whose **names** look secret, not a secret \
406             scan. Values are redacted before they are stored, so this reports that \
407             such keys exist and were redacted — never a value. It cannot see a \
408             hardcoded credential in source code, cannot judge whether a value is \
409             valid, and cannot tell a real secret from a placeholder. A credential \
410             under an innocuous key name (`dsn`, `endpoint`) does not appear here at \
411             all, so this section being small says nothing about whether this \
412             repository leaks secrets.*\n",
413        );
414    }
415
416    if !s.most_called.is_empty() {
417        c.push_str(
418            "\n## Most depended-on (call fan-in)\n\n\
419             *Distinct callers and callees over `calls` edges — direction kept, so \
420             \"everything calls this\" and \"this calls everything\" are not the same \
421             row. Call targets are resolved by simple name, so a short, generically-\
422             named function can absorb every call to that name: read a large fan-in on \
423             one as a question, not a finding.*\n\n",
424        );
425        c.push_str("| Symbol | Called by | Calls |\n| --- | --- | --- |\n");
426        for e in &s.most_called {
427            let _ = writeln!(
428                c,
429                "| [[{}\\|{}]] | {} | {} |",
430                note_name(&e.key),
431                e.name,
432                e.fan_in,
433                e.fan_out
434            );
435        }
436    }
437
438    c.push_str(
439        "\n## Navigating this vault\n\n\
440         - Open the **graph view** to see the whole codebase; notes are coloured/\
441         filterable by their `roteiro/kind/*`, `roteiro/lang/*` and \
442         `roteiro/status/*` tags.\n\
443         - Each note carries its captured **content** (doc comments, prose, PDF/\
444         image text) and its provenance-labelled incoming/outgoing links.\n\
445         - Start from an ADR above, or search the tag pane for a kind.\n",
446    );
447
448    VaultNote {
449        filename: HOME_NOTE.to_owned(),
450        content: c,
451    }
452}
453
454#[cfg(test)]
455mod tests {
456    use super::{
457        AdrEntry, ConfigSecretSummary, CouplingEntry, DensityEntry, HOME_NOTE, VaultSummary,
458        note_name, render_home, render_note,
459    };
460    use rto_graph::{EdgeRef, Explanation, NodeSummary};
461
462    #[test]
463    fn note_name_is_safe_and_stable() {
464        assert_eq!(
465            note_name("sym:rust:src/a.rs#Store"),
466            "sym-rust-src-a.rs-Store"
467        );
468        assert_eq!(note_name("adr:0001"), "adr-0001");
469        assert_eq!(note_name("file:src/main.rs"), "file-src-main.rs");
470    }
471
472    #[test]
473    fn render_note_emits_frontmatter_and_wikilinks() {
474        let ex = Explanation {
475            schema: rto_graph::SCHEMA,
476            node: NodeSummary {
477                key: "sym:rust:a.rs#main".into(),
478                kind: "fn".into(),
479                name: "main".into(),
480                path: Some("a.rs".into()),
481                lang: Some("rust".into()),
482            },
483            meta: serde_json::Value::Null,
484            outgoing: vec![EdgeRef {
485                kind: "calls".into(),
486                provenance: "derived",
487                confidence: None,
488                node: "sym:rust:a.rs#helper".into(),
489            }],
490            incoming: vec![EdgeRef {
491                kind: "references".into(),
492                provenance: "authored",
493                confidence: None,
494                node: "adr:0001".into(),
495            }],
496        };
497        let note = render_note(&ex, None);
498        assert_eq!(note.filename, "sym-rust-a.rs-main.md");
499        assert!(note.content.contains("kind: fn"));
500        // No source base → no Source link.
501        assert!(!note.content.contains("**Source:**"));
502        assert!(note.content.contains("# main"));
503        assert!(
504            note.content
505                .contains("- calls (derived) → [[sym-rust-a.rs-helper]]")
506        );
507        assert!(
508            note.content
509                .contains("- [[adr-0001]] references (authored) →")
510        );
511        // Tags for the graph view.
512        assert!(note.content.contains("- roteiro/kind/fn"));
513        assert!(note.content.contains("- roteiro/lang/rust"));
514    }
515
516    #[test]
517    fn note_name_bounds_long_keys_deterministically() {
518        let long = format!("import:rust:{}", "a::b::c,".repeat(60));
519        let a = note_name(&long);
520        let b = note_name(&long);
521        assert_eq!(a, b, "deterministic");
522        assert!(
523            a.len() <= 205,
524            "bounded under the filename limit: {}",
525            a.len()
526        );
527        assert_ne!(
528            note_name(&format!("{long}x")),
529            a,
530            "different keys stay distinct after truncation"
531        );
532    }
533
534    #[test]
535    fn render_note_surfaces_content_and_status() {
536        let ex = Explanation {
537            schema: rto_graph::SCHEMA,
538            node: NodeSummary {
539                key: "adr:0001".into(),
540                kind: "adr".into(),
541                name: "Build Roteiro".into(),
542                path: Some("docs/adr/0001.md".into()),
543                lang: None,
544            },
545            meta: serde_json::json!({ "status": "Accepted", "content": "The decision text." }),
546            outgoing: vec![],
547            incoming: vec![],
548        };
549        let note = render_note(&ex, Some("https://github.com/org/repo/blob/abc123"));
550        assert!(note.content.contains("status: Accepted"));
551        assert!(note.content.contains("- roteiro/status/accepted"));
552        assert!(note.content.contains("> **Status:** Accepted"));
553        assert!(note.content.contains("## Content\n\nThe decision text."));
554        // A clickable link to the actual ADR file on the repository host.
555        assert!(
556            note.content.contains(
557                "**Source:** [`docs/adr/0001.md`](https://github.com/org/repo/blob/abc123/docs/adr/0001.md)"
558            ),
559            "{}",
560            note.content
561        );
562    }
563
564    #[test]
565    fn render_note_shows_inferred_confidence() {
566        let ex = Explanation {
567            schema: rto_graph::SCHEMA,
568            node: NodeSummary {
569                key: "file:a.md".into(),
570                kind: "file".into(),
571                name: "a.md".into(),
572                path: Some("a.md".into()),
573                lang: None,
574            },
575            meta: serde_json::Value::Null,
576            outgoing: vec![EdgeRef {
577                kind: "related".into(),
578                provenance: "inferred",
579                confidence: Some(0.82),
580                node: "file:b.md".into(),
581            }],
582            incoming: vec![],
583        };
584        let note = render_note(&ex, None);
585        assert!(
586            note.content
587                .contains("related (inferred) (0.82) → [[file-b.md]]"),
588            "{}",
589            note.content
590        );
591    }
592
593    #[test]
594    fn render_home_summarises_the_graph() {
595        let summary = VaultSummary {
596            project: "demo".into(),
597            total_nodes: 3,
598            total_edges: 2,
599            node_counts: vec![("fn".into(), 2), ("adr".into(), 1)],
600            edge_provenance: vec![("derived".into(), 1), ("authored".into(), 1)],
601            adrs: vec![AdrEntry {
602                key: "adr:0001".into(),
603                name: "First".into(),
604                status: Some("Accepted".into()),
605            }],
606            debt: vec![("todo".into(), 4)], // roteiro:ignore
607            densest_files: vec![DensityEntry {
608                path: "src/small.rs".into(),
609                markers: 3,
610                lines: 120,
611                per_kloc: 25.0,
612            }],
613            config_secrets: Some(ConfigSecretSummary {
614                secret_named: 4,
615                redacted: 3,
616                declared: 1,
617                unredacted: 0,
618                files: vec![".env".into()],
619            }),
620            most_called: vec![CouplingEntry {
621                key: "sym:rust:a.rs#helper".into(),
622                name: "helper".into(),
623                fan_in: 7,
624                fan_out: 1,
625            }],
626            repo_url: Some("https://github.com/org/repo".into()),
627            commit: Some("abcdef0123456789".into()),
628        };
629        let note = render_home(&summary);
630        assert_eq!(note.filename, HOME_NOTE);
631        assert!(note.content.contains("# demo — knowledge graph"));
632        assert!(note.content.contains("**3 nodes**, **2 edges**"));
633        assert!(note.content.contains("| fn | 2 |"));
634        assert!(note.content.contains("| derived | 1 |"));
635        assert!(note.content.contains("**Accepted** — [[adr-0001|First]]"));
636        assert!(note.content.contains("| todo | 4 |")); // roteiro:ignore
637        // Directed coupling: the two fans are separate columns, and the wikilink's
638        // own `|` is escaped so it cannot break the table it sits in.
639        assert!(
640            note.content
641                .contains("| [[sym-rust-a.rs-helper\\|helper]] | 7 | 1 |"),
642            "{}",
643            note.content
644        );
645        assert!(
646            note.content.contains("resolved by simple name"),
647            "the precision caveat travels with the figures"
648        );
649        // Density: the count and the denominator are both shown, so the ratio can
650        // be checked rather than taken on trust, and the wikilink's own `|` is
651        // escaped so it cannot break the table it sits in.
652        assert!(
653            note.content
654                .contains("| [[file-src-small.rs\\|src/small.rs]] | 3 | 120 | 25.00 |"),
655            "{}",
656            note.content
657        );
658        assert!(
659            note.content.contains("not source lines of code"),
660            "the denominator caveat travels with the figures"
661        );
662        // Config secrets: counts and files, and no key names — a vault note is
663        // browsed out of context, which is the wrong place for a list that would
664        // read as a secret scan's output.
665        assert!(
666            note.content.contains(
667                "**4** secret-named config key(s): 3 redacted before storage, 1 \
668                 declared in code without a value, 0 unredacted."
669            ),
670            "{}",
671            note.content
672        );
673        assert!(
674            note.content.contains("- [[file-.env\\|.env]]"),
675            "{}",
676            note.content
677        );
678        assert!(
679            note.content.contains("not a secret scan")
680                && note.content.contains("cannot see a hardcoded credential"),
681            "the limitation travels with the figures: {}",
682            note.content
683        );
684        assert!(
685            !note.content.contains("[!warning]"),
686            "no warning when nothing is unredacted: {}",
687            note.content
688        );
689        // A repository link + short-commit permalink note.
690        assert!(
691            note.content
692                .contains("**Repository:** [https://github.com/org/repo](https://github.com/org/repo) · rendered at commit `abcdef012345`"),
693            "{}",
694            note.content
695        );
696    }
697
698    #[test]
699    fn render_home_omits_density_for_a_graph_with_no_markers() {
700        // A clean repository has no markers, so there is no density to rank. An
701        // empty table under a heading reads as "measured, and there is nothing";
702        // the section is absent instead. Same rule as the coupling table below.
703        let note = render_home(&VaultSummary {
704            project: "clean".into(),
705            total_nodes: 1,
706            ..VaultSummary::default()
707        });
708        assert!(
709            !note.content.contains("Densest files"),
710            "no heading without rows: {}",
711            note.content
712        );
713        // The intent-debt section itself still renders — density is an addition
714        // to it, not a replacement.
715        assert!(note.content.contains("## Intent debt"));
716        assert!(note.content.contains("*None recorded.*"));
717    }
718
719    #[test]
720    fn render_home_omits_config_secrets_rather_than_rendering_zeroes() {
721        // A row of zeroes under this heading would read as "scanned, and clean" —
722        // a conclusion the lens cannot support, since a credential under an
723        // innocuous key name never appears in it. The section is absent instead.
724        let note = render_home(&VaultSummary {
725            project: "clean".into(),
726            total_nodes: 1,
727            ..VaultSummary::default()
728        });
729        assert!(
730            !note.content.contains("named like secrets"),
731            "no heading without figures: {}",
732            note.content
733        );
734    }
735
736    #[test]
737    fn render_home_warns_loudly_about_an_unredacted_value() {
738        // Extraction cannot produce this state, so if it appears something else
739        // put an unredacted value in the store — and the note must say where to
740        // look rather than implicating the repository.
741        let note = render_home(&VaultSummary {
742            project: "imported".into(),
743            total_nodes: 1,
744            config_secrets: Some(ConfigSecretSummary {
745                secret_named: 1,
746                redacted: 0,
747                declared: 0,
748                unredacted: 1,
749                files: vec!["imported.env".into()],
750            }),
751            ..VaultSummary::default()
752        });
753        assert!(
754            note.content.contains("[!warning]") && note.content.contains("**unredacted**"),
755            "{}",
756            note.content
757        );
758        assert!(
759            note.content.contains("came from an import layer"),
760            "and it points at the importing tool, not the repository: {}",
761            note.content
762        );
763    }
764
765    #[test]
766    fn render_home_omits_coupling_for_a_graph_with_no_calls() {
767        // A prose-only vault has no `calls` edges. An empty table under a heading
768        // reads as "measured, and there is nothing" — the section is absent instead.
769        let note = render_home(&VaultSummary {
770            project: "docs".into(),
771            total_nodes: 1,
772            ..VaultSummary::default()
773        });
774        assert!(
775            !note.content.contains("Most depended-on"),
776            "no heading without rows: {}",
777            note.content
778        );
779        // The rest of the overview is unaffected.
780        assert!(note.content.contains("# docs — knowledge graph"));
781    }
782}