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/// Render a node's [`Explanation`] into an Obsidian note: YAML frontmatter (with
82/// `tags` for the graph view and an ADR's `status`), a clickable **Source** link
83/// (when `source_base` — a web "blob" base like
84/// `https://github.com/org/repo/blob/<sha>` — is known and the node has a path),
85/// the content as the knowledge base, and its edges as provenance-labelled
86/// wikilinks.
87///
88/// `body` is the node's **full source text**, which only the caller can fetch:
89/// this function is a pure function of the `Explanation`, and an `Explanation`
90/// carries no repository, store or blob. When it is `Some`, it replaces
91/// `meta.content` in the note's `## Content` section — see [`note_body`] for why
92/// replacing is the only correct combination of the two.
93#[must_use]
94pub fn render_note(ex: &Explanation, source_base: Option<&str>, body: Option<&str>) -> VaultNote {
95    let meta = &ex.meta;
96    let status = meta.get("status").and_then(|v| v.as_str());
97    let content = note_body(meta.get("content").and_then(|v| v.as_str()), body);
98
99    let mut c = String::new();
100    c.push_str("---\n");
101    let _ = writeln!(c, "key: \"{}\"", ex.node.key.replace('"', "'"));
102    let _ = writeln!(c, "kind: {}", ex.node.kind);
103    if let Some(path) = &ex.node.path {
104        let _ = writeln!(c, "path: \"{path}\"");
105    }
106    if let Some(lang) = &ex.node.lang {
107        let _ = writeln!(c, "lang: {lang}");
108    }
109    if let Some(status) = status {
110        let _ = writeln!(c, "status: {status}");
111    }
112    // Nested tags group in Obsidian's tag pane and colour the graph view.
113    c.push_str("tags:\n");
114    let _ = writeln!(c, "  - roteiro/kind/{}", tag_slug(&ex.node.kind));
115    if let Some(lang) = &ex.node.lang {
116        let _ = writeln!(c, "  - roteiro/lang/{}", tag_slug(lang));
117    }
118    if let Some(status) = status {
119        let _ = writeln!(c, "  - roteiro/status/{}", tag_slug(status));
120    }
121    c.push_str("---\n\n");
122
123    let _ = writeln!(c, "# {}", ex.node.name);
124    if let Some(status) = status {
125        let _ = writeln!(c, "\n> **Status:** {status}");
126    }
127
128    // A clickable link to the file this node comes from. An absolute URL, so it
129    // works from the downloaded vault too (which has no repo files beside it).
130    if let (Some(base), Some(path)) = (source_base, ex.node.path.as_deref()) {
131        let _ = writeln!(
132            c,
133            "\n**Source:** [`{path}`]({}/{path})",
134            base.trim_end_matches('/')
135        );
136    }
137
138    // The knowledge base: the full source text, or the captured doc comment /
139    // prose / PDF / image text.
140    if let Some(content) = content.map(str::trim).filter(|s| !s.is_empty()) {
141        c.push_str("\n## Content\n\n");
142        c.push_str(content);
143        c.push('\n');
144    }
145
146    if !ex.outgoing.is_empty() {
147        c.push_str("\n## Outgoing\n\n");
148        for e in &ex.outgoing {
149            let _ = writeln!(
150                c,
151                "- {} ({}){} → [[{}]]",
152                e.kind,
153                e.provenance,
154                confidence(e.confidence),
155                note_name(&e.node)
156            );
157        }
158    }
159    if !ex.incoming.is_empty() {
160        c.push_str("\n## Incoming\n\n");
161        for e in &ex.incoming {
162            let _ = writeln!(
163                c,
164                "- [[{}]] {} ({}){} →",
165                note_name(&e.node),
166                e.kind,
167                e.provenance,
168                confidence(e.confidence)
169            );
170        }
171    }
172
173    VaultNote {
174        filename: format!("{}.md", note_name(&ex.node.key)),
175        content: c,
176    }
177}
178
179/// Choose the text a note shows: the caller's full `body` when it has one, else
180/// the node's stored `content`.
181///
182/// The two are **not** complementary, they are the same text at two fidelities,
183/// so a note shows one of them and never both. `meta.content` is an embedding
184/// budget — extraction caps it (1500 chars) and collapses every whitespace run to
185/// a single space, which is right for a store that ships with the graph and wrong
186/// for a note: a 23 KB document arrives as one 1500-character line with every
187/// heading, table and code fence flattened into it. Where the caller can supply
188/// the source, that is what a reader wants; appending the capped rendering
189/// underneath it would only restate its first 6% badly.
190fn note_body<'a>(content: Option<&'a str>, body: Option<&'a str>) -> Option<&'a str> {
191    body.or(content)
192}
193
194/// `" (0.82)"` for an inferred edge's confidence, else empty.
195fn confidence(c: Option<f64>) -> String {
196    c.map_or_else(String::new, |c| format!(" ({c:.2})"))
197}
198
199/// A tag-safe slug: lowercase, non-alphanumeric runs → `-`. Keeps Obsidian tags
200/// (`roteiro/kind/adr-section`) valid and stable.
201fn tag_slug(s: &str) -> String {
202    let mut out = String::with_capacity(s.len());
203    let mut prev_dash = false;
204    for ch in s.chars() {
205        if ch.is_ascii_alphanumeric() {
206            out.push(ch.to_ascii_lowercase());
207            prev_dash = false;
208        } else if !prev_dash {
209            out.push('-');
210            prev_dash = true;
211        }
212    }
213    out.trim_matches('-').to_owned()
214}
215
216/// One ADR in the overview, with its lifecycle status.
217#[derive(Debug, Clone)]
218pub struct AdrEntry {
219    /// The ADR node key (`adr:<id>`).
220    pub key: String,
221    /// The ADR title.
222    pub name: String,
223    /// Lifecycle status (`Accepted`, …), if recorded.
224    pub status: Option<String>,
225}
226
227/// The `_Home` overview's config-secret inventory figures.
228///
229/// Counts and file paths only — deliberately not the key names, which belong in
230/// `roteiro config-secrets` where the caveat can be stated at length. A vault note
231/// is read casually and out of context, which is exactly the wrong place for a
232/// list that looks like a secret scan's output.
233#[derive(Debug, Clone, Default)]
234pub struct ConfigSecretSummary {
235    /// Config keys whose **name** matched the secret-name heuristic.
236    pub secret_named: usize,
237    /// Of those, how many had their value redacted before persistence.
238    pub redacted: usize,
239    /// Of those, how many are declared in code with no literal value.
240    pub declared: usize,
241    /// Of those, how many carry an unredacted value. Expected to be zero.
242    pub unredacted: usize,
243    /// Distinct files carrying at least one secret-named key, ordered and capped
244    /// by the caller.
245    pub files: Vec<String>,
246}
247
248/// One file in the `_Home` overview's intent-debt density table.
249#[derive(Debug, Clone)]
250pub struct DensityEntry {
251    /// Repository-relative path, used for both the wikilink and the label.
252    pub path: String,
253    /// Retained markers in the file.
254    pub markers: u32,
255    /// The file's length in lines — the denominator.
256    pub lines: u32,
257    /// Markers per 1,000 lines.
258    pub per_kloc: f64,
259}
260
261/// One node in the `_Home` overview's directed-coupling table.
262#[derive(Debug, Clone)]
263pub struct CouplingEntry {
264    /// The node key, for the wikilink.
265    pub key: String,
266    /// The symbol name.
267    pub name: String,
268    /// Distinct callers.
269    pub fan_in: u32,
270    /// Distinct callees.
271    pub fan_out: u32,
272}
273
274/// Aggregate figures for the vault's `_Home` overview note.
275#[derive(Debug, Clone, Default)]
276pub struct VaultSummary {
277    /// Name of the scanned project (repository directory).
278    pub project: String,
279    /// Total node and edge counts.
280    pub total_nodes: usize,
281    /// Total edge count.
282    pub total_edges: usize,
283    /// `(kind, count)` for each node kind, most-frequent first.
284    pub node_counts: Vec<(String, usize)>,
285    /// `(provenance, edge count)` — `derived` / `authored` / `inferred`.
286    pub edge_provenance: Vec<(String, usize)>,
287    /// The ADRs, with status.
288    pub adrs: Vec<AdrEntry>,
289    /// `(category, count)` of intent-debt markers.
290    pub debt: Vec<(String, usize)>,
291    /// The files where that debt is most **concentrated**, already ranked and
292    /// capped by the caller. Empty when the graph has no markers, or when no
293    /// file carrying one has a recorded length.
294    pub densest_files: Vec<DensityEntry>,
295    /// Secret-named config keys and their redaction state. `None` when the graph
296    /// holds no secret-named config key — the section is then absent rather than
297    /// rendering a row of zeroes, which would read as a clean bill of health this
298    /// lens cannot give.
299    pub config_secrets: Option<ConfigSecretSummary>,
300    /// The most depended-on symbols by **directed** call fan-in, already ranked
301    /// and capped by the caller. Empty when the graph has no `calls` edges.
302    pub most_called: Vec<CouplingEntry>,
303    /// Web root of the repository (`https://host/owner/repo`), if derivable from
304    /// the git remote — for a "Repository" link in the overview.
305    pub repo_url: Option<String>,
306    /// Hex commit the graph was rendered from, for a permalink note.
307    pub commit: Option<String>,
308}
309
310/// Render the vault's overview note: what was scanned, the structure by kind,
311/// the provenance breakdown, the decisions (ADRs) and their status, the
312/// intent-debt summary, and how to navigate. The entry point for the vault.
313#[must_use]
314pub fn render_home(s: &VaultSummary) -> VaultNote {
315    let mut c = String::new();
316    c.push_str("---\ntags:\n  - roteiro/home\n---\n\n");
317    let _ = writeln!(c, "# {} — knowledge graph", s.project);
318    c.push_str(
319        "\n*A browsable snapshot of this codebase as one **knowledge graph**, \
320         generated by [Roteiro](https://roteiro.dev). Every symbol, document and \
321         decision is a note, linked to the things it relates to.*\n",
322    );
323    c.push_str(
324        "\n**How to read it.** Open any note to see what a thing is, the intent or \
325         docs behind it (its **Content**), where it lives (its **Source** link), \
326         and how it connects (**Outgoing**/**Incoming** links). Each link is \
327         labelled with how the fact was established — `derived` (extracted from \
328         code), `authored` (human intent: ADRs, blueprints, annotations), or \
329         `inferred` (a scored suggestion). Open Obsidian's **graph view** to see \
330         the whole thing at once.\n",
331    );
332    let _ = writeln!(
333        c,
334        "\n**{} nodes**, **{} edges** across the project.",
335        s.total_nodes, s.total_edges
336    );
337    if let Some(repo) = &s.repo_url {
338        let _ = write!(c, "\n**Repository:** [{repo}]({repo})");
339        if let Some(commit) = &s.commit {
340            let short = &commit[..commit.len().min(12)];
341            let _ = write!(c, " · rendered at commit `{short}`");
342        }
343        c.push('\n');
344    }
345
346    c.push_str("\n## Structure\n\n| Kind | Count |\n| --- | --- |\n");
347    for (kind, n) in &s.node_counts {
348        let _ = writeln!(c, "| {kind} | {n} |");
349    }
350
351    if !s.edge_provenance.is_empty() {
352        c.push_str("\n## Provenance\n\n| Provenance | Edges |\n| --- | --- |\n");
353        for (prov, n) in &s.edge_provenance {
354            let _ = writeln!(c, "| {prov} | {n} |");
355        }
356    }
357
358    c.push_str("\n## Decisions (ADRs)\n\n");
359    if s.adrs.is_empty() {
360        c.push_str("*No ADRs found.*\n");
361    } else {
362        for adr in &s.adrs {
363            let status = adr.status.as_deref().unwrap_or("—");
364            let _ = writeln!(
365                c,
366                "- **{status}** — [[{}|{}]]",
367                note_name(&adr.key),
368                adr.name
369            );
370        }
371    }
372
373    c.push_str("\n## Intent debt\n\n");
374    if s.debt.is_empty() {
375        c.push_str("*None recorded.*\n");
376    } else {
377        c.push_str("| Category | Count |\n| --- | --- |\n");
378        for (cat, n) in &s.debt {
379            let _ = writeln!(c, "| {cat} | {n} |");
380        }
381    }
382
383    if !s.densest_files.is_empty() {
384        c.push_str(
385            "\n### Densest files (markers per 1,000 lines)\n\n\
386             *Where the debt above is concentrated, rather than where there is \
387             most of it — a raw count ranks the biggest file first by \
388             construction. The denominator is file length: every line, blanks and \
389             comments included, not source lines of code. Prose matches (`for \
390             now`, `tbd`) count too, so a design document can rank high.*\n\n",
391        );
392        c.push_str("| File | Markers | Lines | Per 1k |\n| --- | --- | --- | --- |\n");
393        for e in &s.densest_files {
394            let _ = writeln!(
395                c,
396                "| [[{}\\|{}]] | {} | {} | {:.2} |",
397                note_name(&format!("file:{}", e.path)),
398                e.path,
399                e.markers,
400                e.lines,
401                e.per_kloc
402            );
403        }
404    }
405
406    if let Some(cs) = &s.config_secrets {
407        c.push_str("\n## Config keys named like secrets\n\n");
408        let _ = writeln!(
409            c,
410            "**{}** secret-named config key(s): {} redacted before storage, {} \
411             declared in code without a value, {} unredacted.",
412            cs.secret_named, cs.redacted, cs.declared, cs.unredacted
413        );
414        if cs.unredacted > 0 {
415            let _ = writeln!(
416                c,
417                "\n> [!warning] {} key(s) carry an **unredacted** value. Extraction \
418                 always redacts, so these came from an import layer — inspect the \
419                 importing tool, not this repository.",
420                cs.unredacted
421            );
422        }
423        if !cs.files.is_empty() {
424            c.push_str("\nIn:\n");
425            for path in &cs.files {
426                let _ = writeln!(c, "- [[{}\\|{path}]]", note_name(&format!("file:{path}")));
427            }
428        }
429        // The caveat is unconditional and comes last, so it is the final thing read
430        // in this section. A vault note is browsed out of context; this is exactly
431        // where "config keys named like secrets" would otherwise be misread as a
432        // secret scan that came back clean.
433        c.push_str(
434            "\n*An inventory of config keys whose **names** look secret, not a secret \
435             scan. Values are redacted before they are stored, so this reports that \
436             such keys exist and were redacted — never a value. It cannot see a \
437             hardcoded credential in source code, cannot judge whether a value is \
438             valid, and cannot tell a real secret from a placeholder. A credential \
439             under an innocuous key name (`dsn`, `endpoint`) does not appear here at \
440             all, so this section being small says nothing about whether this \
441             repository leaks secrets.*\n",
442        );
443    }
444
445    if !s.most_called.is_empty() {
446        c.push_str(
447            "\n## Most depended-on (call fan-in)\n\n\
448             *Distinct callers and callees over `calls` edges — direction kept, so \
449             \"everything calls this\" and \"this calls everything\" are not the same \
450             row. Call targets are resolved by simple name, so a short, generically-\
451             named function can absorb every call to that name: read a large fan-in on \
452             one as a question, not a finding.*\n\n",
453        );
454        c.push_str("| Symbol | Called by | Calls |\n| --- | --- | --- |\n");
455        for e in &s.most_called {
456            let _ = writeln!(
457                c,
458                "| [[{}\\|{}]] | {} | {} |",
459                note_name(&e.key),
460                e.name,
461                e.fan_in,
462                e.fan_out
463            );
464        }
465    }
466
467    c.push_str(
468        "\n## Navigating this vault\n\n\
469         - Open the **graph view** to see the whole codebase; notes are coloured/\
470         filterable by their `roteiro/kind/*`, `roteiro/lang/*` and \
471         `roteiro/status/*` tags.\n\
472         - Each note carries its captured **content** (doc comments, prose, PDF/\
473         image text) and its provenance-labelled incoming/outgoing links.\n\
474         - Start from an ADR above, or search the tag pane for a kind.\n",
475    );
476
477    VaultNote {
478        filename: HOME_NOTE.to_owned(),
479        content: c,
480    }
481}
482
483#[cfg(test)]
484mod tests {
485    use super::{
486        AdrEntry, ConfigSecretSummary, CouplingEntry, DensityEntry, HOME_NOTE, VaultSummary,
487        note_name, render_home, render_note,
488    };
489    use rto_graph::{EdgeRef, Explanation, NodeSummary};
490
491    #[test]
492    fn note_name_is_safe_and_stable() {
493        assert_eq!(
494            note_name("sym:rust:src/a.rs#Store"),
495            "sym-rust-src-a.rs-Store"
496        );
497        assert_eq!(note_name("adr:0001"), "adr-0001");
498        assert_eq!(note_name("file:src/main.rs"), "file-src-main.rs");
499    }
500
501    #[test]
502    fn render_note_emits_frontmatter_and_wikilinks() {
503        let ex = Explanation {
504            schema: rto_graph::SCHEMA,
505            node: NodeSummary {
506                key: "sym:rust:a.rs#main".into(),
507                kind: "fn".into(),
508                name: "main".into(),
509                path: Some("a.rs".into()),
510                lang: Some("rust".into()),
511            },
512            meta: serde_json::Value::Null,
513            outgoing: vec![EdgeRef {
514                kind: "calls".into(),
515                provenance: "derived",
516                confidence: None,
517                node: "sym:rust:a.rs#helper".into(),
518            }],
519            incoming: vec![EdgeRef {
520                kind: "references".into(),
521                provenance: "authored",
522                confidence: None,
523                node: "adr:0001".into(),
524            }],
525        };
526        let note = render_note(&ex, None, None);
527        assert_eq!(note.filename, "sym-rust-a.rs-main.md");
528        assert!(note.content.contains("kind: fn"));
529        // No source base → no Source link.
530        assert!(!note.content.contains("**Source:**"));
531        assert!(note.content.contains("# main"));
532        assert!(
533            note.content
534                .contains("- calls (derived) → [[sym-rust-a.rs-helper]]")
535        );
536        assert!(
537            note.content
538                .contains("- [[adr-0001]] references (authored) →")
539        );
540        // Tags for the graph view.
541        assert!(note.content.contains("- roteiro/kind/fn"));
542        assert!(note.content.contains("- roteiro/lang/rust"));
543    }
544
545    #[test]
546    fn note_name_bounds_long_keys_deterministically() {
547        let long = format!("import:rust:{}", "a::b::c,".repeat(60));
548        let a = note_name(&long);
549        let b = note_name(&long);
550        assert_eq!(a, b, "deterministic");
551        assert!(
552            a.len() <= 205,
553            "bounded under the filename limit: {}",
554            a.len()
555        );
556        assert_ne!(
557            note_name(&format!("{long}x")),
558            a,
559            "different keys stay distinct after truncation"
560        );
561    }
562
563    #[test]
564    fn render_note_surfaces_content_and_status() {
565        let ex = Explanation {
566            schema: rto_graph::SCHEMA,
567            node: NodeSummary {
568                key: "adr:0001".into(),
569                kind: "adr".into(),
570                name: "Build Roteiro".into(),
571                path: Some("docs/adr/0001.md".into()),
572                lang: None,
573            },
574            meta: serde_json::json!({ "status": "Accepted", "content": "The decision text." }),
575            outgoing: vec![],
576            incoming: vec![],
577        };
578        let note = render_note(&ex, Some("https://github.com/org/repo/blob/abc123"), None);
579        assert!(note.content.contains("status: Accepted"));
580        assert!(note.content.contains("- roteiro/status/accepted"));
581        assert!(note.content.contains("> **Status:** Accepted"));
582        assert!(note.content.contains("## Content\n\nThe decision text."));
583        // A clickable link to the actual ADR file on the repository host.
584        assert!(
585            note.content.contains(
586                "**Source:** [`docs/adr/0001.md`](https://github.com/org/repo/blob/abc123/docs/adr/0001.md)"
587            ),
588            "{}",
589            note.content
590        );
591    }
592
593    /// The structured document a prose note is supposed to reproduce: headings, a
594    /// table and a fenced code block, none of which survive whitespace collapse.
595    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";
596
597    fn prose_note(content: Option<&str>) -> Explanation {
598        Explanation {
599            schema: rto_graph::SCHEMA,
600            node: NodeSummary {
601                key: "file:docs/OFFLINE_SETUP.md".into(),
602                kind: "file".into(),
603                name: "OFFLINE_SETUP.md".into(),
604                path: Some("docs/OFFLINE_SETUP.md".into()),
605                lang: None,
606            },
607            meta: content.map_or(
608                serde_json::Value::Null,
609                |c| serde_json::json!({ "content": c }),
610            ),
611            outgoing: vec![],
612            incoming: vec![],
613        }
614    }
615
616    /// The whole readability defect, in one assertion pair: a note built from
617    /// `meta.content` alone is the document whitespace-collapsed onto one line,
618    /// and a note built from the source is the document.
619    ///
620    /// The newline count is the claim. A character count alone would pass on a
621    /// note that had merely grown longer while staying flat, which is exactly the
622    /// failure being fixed — `meta.content` is capped *and* collapsed, and only
623    /// the collapse is what makes it unreadable.
624    #[test]
625    fn a_supplied_body_supersedes_the_collapsed_stored_content() {
626        // What extraction stores: the same text, whitespace-collapsed.
627        let collapsed = DOC.split_whitespace().collect::<Vec<_>>().join(" ");
628        let ex = prose_note(Some(&collapsed));
629
630        let note = render_note(&ex, None, Some(DOC));
631        assert!(
632            note.content.contains(DOC.trim()),
633            "the source document is reproduced verbatim: {}",
634            note.content
635        );
636        assert!(
637            !note.content.contains(&collapsed),
638            "the collapsed rendering is replaced, not appended: {}",
639            note.content
640        );
641        assert!(
642            note.content.contains("\n| Host | What |\n"),
643            "a table needs its own lines to be a table: {}",
644            note.content
645        );
646        assert!(
647            note.content.contains("\n```sh\n"),
648            "a fenced block needs its own lines to be a fence: {}",
649            note.content
650        );
651
652        // The flat control: the same node with no body is the one-line note.
653        let flat = render_note(&ex, None, None);
654        assert!(
655            flat.content.contains(&collapsed),
656            "without a body the stored content is still shown: {}",
657            flat.content
658        );
659        assert!(
660            content_lines(&note.content) > content_lines(&flat.content),
661            "structure restored: {} line(s) with a body vs {} without",
662            content_lines(&note.content),
663            content_lines(&flat.content)
664        );
665        assert_eq!(
666            content_lines(&flat.content),
667            1,
668            "the defect: the stored content is a single line"
669        );
670    }
671
672    /// A doc comment is a summary of a definition, not a document, and its note is
673    /// correct as it stands. The caller supplies no body for these, so this pins
674    /// the unchanged path — the fix must not depend on every node gaining one.
675    #[test]
676    fn a_note_with_no_body_is_unchanged() {
677        let ex = Explanation {
678            schema: rto_graph::SCHEMA,
679            node: NodeSummary {
680                key: "sym:rust:a.rs#main".into(),
681                kind: "fn".into(),
682                name: "main".into(),
683                path: Some("a.rs".into()),
684                lang: Some("rust".into()),
685            },
686            meta: serde_json::json!({ "content": "Entry point." }),
687            outgoing: vec![],
688            incoming: vec![],
689        };
690        assert!(
691            render_note(&ex, None, None)
692                .content
693                .contains("## Content\n\nEntry point.")
694        );
695    }
696
697    /// Lines in the note's `## Content` section.
698    fn content_lines(note: &str) -> usize {
699        let body = note
700            .split_once("## Content\n\n")
701            .map_or("", |(_, rest)| rest);
702        let body = body.split_once("\n## ").map_or(body, |(head, _)| head);
703        body.trim_end().lines().count()
704    }
705
706    #[test]
707    fn render_note_shows_inferred_confidence() {
708        let ex = Explanation {
709            schema: rto_graph::SCHEMA,
710            node: NodeSummary {
711                key: "file:a.md".into(),
712                kind: "file".into(),
713                name: "a.md".into(),
714                path: Some("a.md".into()),
715                lang: None,
716            },
717            meta: serde_json::Value::Null,
718            outgoing: vec![EdgeRef {
719                kind: "related".into(),
720                provenance: "inferred",
721                confidence: Some(0.82),
722                node: "file:b.md".into(),
723            }],
724            incoming: vec![],
725        };
726        let note = render_note(&ex, None, None);
727        assert!(
728            note.content
729                .contains("related (inferred) (0.82) → [[file-b.md]]"),
730            "{}",
731            note.content
732        );
733    }
734
735    #[test]
736    fn render_home_summarises_the_graph() {
737        let summary = VaultSummary {
738            project: "demo".into(),
739            total_nodes: 3,
740            total_edges: 2,
741            node_counts: vec![("fn".into(), 2), ("adr".into(), 1)],
742            edge_provenance: vec![("derived".into(), 1), ("authored".into(), 1)],
743            adrs: vec![AdrEntry {
744                key: "adr:0001".into(),
745                name: "First".into(),
746                status: Some("Accepted".into()),
747            }],
748            debt: vec![("todo".into(), 4)], // roteiro:ignore
749            densest_files: vec![DensityEntry {
750                path: "src/small.rs".into(),
751                markers: 3,
752                lines: 120,
753                per_kloc: 25.0,
754            }],
755            config_secrets: Some(ConfigSecretSummary {
756                secret_named: 4,
757                redacted: 3,
758                declared: 1,
759                unredacted: 0,
760                files: vec![".env".into()],
761            }),
762            most_called: vec![CouplingEntry {
763                key: "sym:rust:a.rs#helper".into(),
764                name: "helper".into(),
765                fan_in: 7,
766                fan_out: 1,
767            }],
768            repo_url: Some("https://github.com/org/repo".into()),
769            commit: Some("abcdef0123456789".into()),
770        };
771        let note = render_home(&summary);
772        assert_eq!(note.filename, HOME_NOTE);
773        assert!(note.content.contains("# demo — knowledge graph"));
774        assert!(note.content.contains("**3 nodes**, **2 edges**"));
775        assert!(note.content.contains("| fn | 2 |"));
776        assert!(note.content.contains("| derived | 1 |"));
777        assert!(note.content.contains("**Accepted** — [[adr-0001|First]]"));
778        assert!(note.content.contains("| todo | 4 |")); // roteiro:ignore
779        // Directed coupling: the two fans are separate columns, and the wikilink's
780        // own `|` is escaped so it cannot break the table it sits in.
781        assert!(
782            note.content
783                .contains("| [[sym-rust-a.rs-helper\\|helper]] | 7 | 1 |"),
784            "{}",
785            note.content
786        );
787        assert!(
788            note.content.contains("resolved by simple name"),
789            "the precision caveat travels with the figures"
790        );
791        // Density: the count and the denominator are both shown, so the ratio can
792        // be checked rather than taken on trust, and the wikilink's own `|` is
793        // escaped so it cannot break the table it sits in.
794        assert!(
795            note.content
796                .contains("| [[file-src-small.rs\\|src/small.rs]] | 3 | 120 | 25.00 |"),
797            "{}",
798            note.content
799        );
800        assert!(
801            note.content.contains("not source lines of code"),
802            "the denominator caveat travels with the figures"
803        );
804        // Config secrets: counts and files, and no key names — a vault note is
805        // browsed out of context, which is the wrong place for a list that would
806        // read as a secret scan's output.
807        assert!(
808            note.content.contains(
809                "**4** secret-named config key(s): 3 redacted before storage, 1 \
810                 declared in code without a value, 0 unredacted."
811            ),
812            "{}",
813            note.content
814        );
815        assert!(
816            note.content.contains("- [[file-.env\\|.env]]"),
817            "{}",
818            note.content
819        );
820        assert!(
821            note.content.contains("not a secret scan")
822                && note.content.contains("cannot see a hardcoded credential"),
823            "the limitation travels with the figures: {}",
824            note.content
825        );
826        assert!(
827            !note.content.contains("[!warning]"),
828            "no warning when nothing is unredacted: {}",
829            note.content
830        );
831        // A repository link + short-commit permalink note.
832        assert!(
833            note.content
834                .contains("**Repository:** [https://github.com/org/repo](https://github.com/org/repo) · rendered at commit `abcdef012345`"),
835            "{}",
836            note.content
837        );
838    }
839
840    #[test]
841    fn render_home_omits_density_for_a_graph_with_no_markers() {
842        // A clean repository has no markers, so there is no density to rank. An
843        // empty table under a heading reads as "measured, and there is nothing";
844        // the section is absent instead. Same rule as the coupling table below.
845        let note = render_home(&VaultSummary {
846            project: "clean".into(),
847            total_nodes: 1,
848            ..VaultSummary::default()
849        });
850        assert!(
851            !note.content.contains("Densest files"),
852            "no heading without rows: {}",
853            note.content
854        );
855        // The intent-debt section itself still renders — density is an addition
856        // to it, not a replacement.
857        assert!(note.content.contains("## Intent debt"));
858        assert!(note.content.contains("*None recorded.*"));
859    }
860
861    #[test]
862    fn render_home_omits_config_secrets_rather_than_rendering_zeroes() {
863        // A row of zeroes under this heading would read as "scanned, and clean" —
864        // a conclusion the lens cannot support, since a credential under an
865        // innocuous key name never appears in it. The section is absent instead.
866        let note = render_home(&VaultSummary {
867            project: "clean".into(),
868            total_nodes: 1,
869            ..VaultSummary::default()
870        });
871        assert!(
872            !note.content.contains("named like secrets"),
873            "no heading without figures: {}",
874            note.content
875        );
876    }
877
878    #[test]
879    fn render_home_warns_loudly_about_an_unredacted_value() {
880        // Extraction cannot produce this state, so if it appears something else
881        // put an unredacted value in the store — and the note must say where to
882        // look rather than implicating the repository.
883        let note = render_home(&VaultSummary {
884            project: "imported".into(),
885            total_nodes: 1,
886            config_secrets: Some(ConfigSecretSummary {
887                secret_named: 1,
888                redacted: 0,
889                declared: 0,
890                unredacted: 1,
891                files: vec!["imported.env".into()],
892            }),
893            ..VaultSummary::default()
894        });
895        assert!(
896            note.content.contains("[!warning]") && note.content.contains("**unredacted**"),
897            "{}",
898            note.content
899        );
900        assert!(
901            note.content.contains("came from an import layer"),
902            "and it points at the importing tool, not the repository: {}",
903            note.content
904        );
905    }
906
907    #[test]
908    fn render_home_omits_coupling_for_a_graph_with_no_calls() {
909        // A prose-only vault has no `calls` edges. An empty table under a heading
910        // reads as "measured, and there is nothing" — the section is absent instead.
911        let note = render_home(&VaultSummary {
912            project: "docs".into(),
913            total_nodes: 1,
914            ..VaultSummary::default()
915        });
916        assert!(
917            !note.content.contains("Most depended-on"),
918            "no heading without rows: {}",
919            note.content
920        );
921        // The rest of the overview is unaffected.
922        assert!(note.content.contains("# docs — knowledge graph"));
923    }
924}