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, and
10//! the most depended-on symbols by directed call fan-in.
11//! Built from the same [`Explanation`] the query surface returns, so the vault
12//! and the CLI agree.
13
14use std::fmt::Write as _;
15
16use rto_graph::Explanation;
17
18/// Filename of the generated overview note (sorts first in the file list).
19pub const HOME_NOTE: &str = "_Home.md";
20
21/// A rendered vault note: its filename (with `.md`) and markdown content.
22#[derive(Debug, Clone, PartialEq, Eq)]
23pub struct VaultNote {
24    /// Filename including the `.md` extension.
25    pub filename: String,
26    /// Markdown content.
27    pub content: String,
28}
29
30/// Map a node key to a filesystem- and wikilink-safe note stem. Characters that
31/// are awkward in filenames or Obsidian links (`:` `/` `#` whitespace) collapse
32/// to `-`; alphanumerics, `.`, `_` and `-` are kept. The result is **bounded**
33/// in length (a grouped Rust `use` can key a 300+ char import node) by truncating
34/// and appending a short hash of the full key, so notes stay under filesystem
35/// limits while remaining unique and deterministic.
36#[must_use]
37pub fn note_name(key: &str) -> String {
38    // Keep the stem well under the 255-byte filename limit (leaving room for
39    // ".md"). The slug is ASCII, so byte length equals char count and slicing is
40    // safe. A hash of the full key preserves uniqueness after truncation.
41    const MAX: usize = 200;
42    let mut out = String::with_capacity(key.len());
43    let mut prev_dash = false;
44    for c in key.chars() {
45        if c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-') {
46            out.push(c);
47            prev_dash = false;
48        } else if !prev_dash {
49            out.push('-');
50            prev_dash = true;
51        }
52    }
53    let out = out.trim_matches('-');
54    if out.len() <= MAX {
55        out.to_owned()
56    } else {
57        format!("{}-{:016x}", &out[..MAX - 17], fnv1a64(key.as_bytes()))
58    }
59}
60
61/// FNV-1a (64-bit) — a dependency-free, deterministic hash to disambiguate a
62/// truncated note stem. No cryptographic properties needed.
63fn fnv1a64(bytes: &[u8]) -> u64 {
64    let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
65    for &b in bytes {
66        hash ^= u64::from(b);
67        hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
68    }
69    hash
70}
71
72/// Render a node's [`Explanation`] into an Obsidian note: YAML frontmatter (with
73/// `tags` for the graph view and an ADR's `status`), a clickable **Source** link
74/// (when `source_base` — a web "blob" base like
75/// `https://github.com/org/repo/blob/<sha>` — is known and the node has a path),
76/// the captured content as the knowledge base, and its edges as provenance-
77/// labelled wikilinks.
78#[must_use]
79pub fn render_note(ex: &Explanation, source_base: Option<&str>) -> VaultNote {
80    let meta = &ex.meta;
81    let status = meta.get("status").and_then(|v| v.as_str());
82    let content = meta.get("content").and_then(|v| v.as_str());
83
84    let mut c = String::new();
85    c.push_str("---\n");
86    let _ = writeln!(c, "key: \"{}\"", ex.node.key.replace('"', "'"));
87    let _ = writeln!(c, "kind: {}", ex.node.kind);
88    if let Some(path) = &ex.node.path {
89        let _ = writeln!(c, "path: \"{path}\"");
90    }
91    if let Some(lang) = &ex.node.lang {
92        let _ = writeln!(c, "lang: {lang}");
93    }
94    if let Some(status) = status {
95        let _ = writeln!(c, "status: {status}");
96    }
97    // Nested tags group in Obsidian's tag pane and colour the graph view.
98    c.push_str("tags:\n");
99    let _ = writeln!(c, "  - roteiro/kind/{}", tag_slug(&ex.node.kind));
100    if let Some(lang) = &ex.node.lang {
101        let _ = writeln!(c, "  - roteiro/lang/{}", tag_slug(lang));
102    }
103    if let Some(status) = status {
104        let _ = writeln!(c, "  - roteiro/status/{}", tag_slug(status));
105    }
106    c.push_str("---\n\n");
107
108    let _ = writeln!(c, "# {}", ex.node.name);
109    if let Some(status) = status {
110        let _ = writeln!(c, "\n> **Status:** {status}");
111    }
112
113    // A clickable link to the file this node comes from. An absolute URL, so it
114    // works from the downloaded vault too (which has no repo files beside it).
115    if let (Some(base), Some(path)) = (source_base, ex.node.path.as_deref()) {
116        let _ = writeln!(
117            c,
118            "\n**Source:** [`{path}`]({}/{path})",
119            base.trim_end_matches('/')
120        );
121    }
122
123    // The knowledge base: the captured doc comment / prose / PDF / image text.
124    if let Some(content) = content.map(str::trim).filter(|s| !s.is_empty()) {
125        c.push_str("\n## Content\n\n");
126        c.push_str(content);
127        c.push('\n');
128    }
129
130    if !ex.outgoing.is_empty() {
131        c.push_str("\n## Outgoing\n\n");
132        for e in &ex.outgoing {
133            let _ = writeln!(
134                c,
135                "- {} ({}){} → [[{}]]",
136                e.kind,
137                e.provenance,
138                confidence(e.confidence),
139                note_name(&e.node)
140            );
141        }
142    }
143    if !ex.incoming.is_empty() {
144        c.push_str("\n## Incoming\n\n");
145        for e in &ex.incoming {
146            let _ = writeln!(
147                c,
148                "- [[{}]] {} ({}){} →",
149                note_name(&e.node),
150                e.kind,
151                e.provenance,
152                confidence(e.confidence)
153            );
154        }
155    }
156
157    VaultNote {
158        filename: format!("{}.md", note_name(&ex.node.key)),
159        content: c,
160    }
161}
162
163/// `" (0.82)"` for an inferred edge's confidence, else empty.
164fn confidence(c: Option<f64>) -> String {
165    c.map_or_else(String::new, |c| format!(" ({c:.2})"))
166}
167
168/// A tag-safe slug: lowercase, non-alphanumeric runs → `-`. Keeps Obsidian tags
169/// (`roteiro/kind/adr-section`) valid and stable.
170fn tag_slug(s: &str) -> String {
171    let mut out = String::with_capacity(s.len());
172    let mut prev_dash = false;
173    for ch in s.chars() {
174        if ch.is_ascii_alphanumeric() {
175            out.push(ch.to_ascii_lowercase());
176            prev_dash = false;
177        } else if !prev_dash {
178            out.push('-');
179            prev_dash = true;
180        }
181    }
182    out.trim_matches('-').to_owned()
183}
184
185/// One ADR in the overview, with its lifecycle status.
186#[derive(Debug, Clone)]
187pub struct AdrEntry {
188    /// The ADR node key (`adr:<id>`).
189    pub key: String,
190    /// The ADR title.
191    pub name: String,
192    /// Lifecycle status (`Accepted`, …), if recorded.
193    pub status: Option<String>,
194}
195
196/// One node in the `_Home` overview's directed-coupling table.
197#[derive(Debug, Clone)]
198pub struct CouplingEntry {
199    /// The node key, for the wikilink.
200    pub key: String,
201    /// The symbol name.
202    pub name: String,
203    /// Distinct callers.
204    pub fan_in: u32,
205    /// Distinct callees.
206    pub fan_out: u32,
207}
208
209/// Aggregate figures for the vault's `_Home` overview note.
210#[derive(Debug, Clone, Default)]
211pub struct VaultSummary {
212    /// Name of the scanned project (repository directory).
213    pub project: String,
214    /// Total node and edge counts.
215    pub total_nodes: usize,
216    /// Total edge count.
217    pub total_edges: usize,
218    /// `(kind, count)` for each node kind, most-frequent first.
219    pub node_counts: Vec<(String, usize)>,
220    /// `(provenance, edge count)` — `derived` / `authored` / `inferred`.
221    pub edge_provenance: Vec<(String, usize)>,
222    /// The ADRs, with status.
223    pub adrs: Vec<AdrEntry>,
224    /// `(category, count)` of intent-debt markers.
225    pub debt: Vec<(String, usize)>,
226    /// The most depended-on symbols by **directed** call fan-in, already ranked
227    /// and capped by the caller. Empty when the graph has no `calls` edges.
228    pub most_called: Vec<CouplingEntry>,
229    /// Web root of the repository (`https://host/owner/repo`), if derivable from
230    /// the git remote — for a "Repository" link in the overview.
231    pub repo_url: Option<String>,
232    /// Hex commit the graph was rendered from, for a permalink note.
233    pub commit: Option<String>,
234}
235
236/// Render the vault's overview note: what was scanned, the structure by kind,
237/// the provenance breakdown, the decisions (ADRs) and their status, the
238/// intent-debt summary, and how to navigate. The entry point for the vault.
239#[must_use]
240pub fn render_home(s: &VaultSummary) -> VaultNote {
241    let mut c = String::new();
242    c.push_str("---\ntags:\n  - roteiro/home\n---\n\n");
243    let _ = writeln!(c, "# {} — knowledge graph", s.project);
244    c.push_str(
245        "\n*A browsable snapshot of this codebase as one **knowledge graph**, \
246         generated by [Roteiro](https://roteiro.dev). Every symbol, document and \
247         decision is a note, linked to the things it relates to.*\n",
248    );
249    c.push_str(
250        "\n**How to read it.** Open any note to see what a thing is, the intent or \
251         docs behind it (its **Content**), where it lives (its **Source** link), \
252         and how it connects (**Outgoing**/**Incoming** links). Each link is \
253         labelled with how the fact was established — `derived` (extracted from \
254         code), `authored` (human intent: ADRs, blueprints, annotations), or \
255         `inferred` (a scored suggestion). Open Obsidian's **graph view** to see \
256         the whole thing at once.\n",
257    );
258    let _ = writeln!(
259        c,
260        "\n**{} nodes**, **{} edges** across the project.",
261        s.total_nodes, s.total_edges
262    );
263    if let Some(repo) = &s.repo_url {
264        let _ = write!(c, "\n**Repository:** [{repo}]({repo})");
265        if let Some(commit) = &s.commit {
266            let short = &commit[..commit.len().min(12)];
267            let _ = write!(c, " · rendered at commit `{short}`");
268        }
269        c.push('\n');
270    }
271
272    c.push_str("\n## Structure\n\n| Kind | Count |\n| --- | --- |\n");
273    for (kind, n) in &s.node_counts {
274        let _ = writeln!(c, "| {kind} | {n} |");
275    }
276
277    if !s.edge_provenance.is_empty() {
278        c.push_str("\n## Provenance\n\n| Provenance | Edges |\n| --- | --- |\n");
279        for (prov, n) in &s.edge_provenance {
280            let _ = writeln!(c, "| {prov} | {n} |");
281        }
282    }
283
284    c.push_str("\n## Decisions (ADRs)\n\n");
285    if s.adrs.is_empty() {
286        c.push_str("*No ADRs found.*\n");
287    } else {
288        for adr in &s.adrs {
289            let status = adr.status.as_deref().unwrap_or("—");
290            let _ = writeln!(
291                c,
292                "- **{status}** — [[{}|{}]]",
293                note_name(&adr.key),
294                adr.name
295            );
296        }
297    }
298
299    c.push_str("\n## Intent debt\n\n");
300    if s.debt.is_empty() {
301        c.push_str("*None recorded.*\n");
302    } else {
303        c.push_str("| Category | Count |\n| --- | --- |\n");
304        for (cat, n) in &s.debt {
305            let _ = writeln!(c, "| {cat} | {n} |");
306        }
307    }
308
309    if !s.most_called.is_empty() {
310        c.push_str(
311            "\n## Most depended-on (call fan-in)\n\n\
312             *Distinct callers and callees over `calls` edges — direction kept, so \
313             \"everything calls this\" and \"this calls everything\" are not the same \
314             row. Call targets are resolved by simple name, so a short, generically-\
315             named function can absorb every call to that name: read a large fan-in on \
316             one as a question, not a finding.*\n\n",
317        );
318        c.push_str("| Symbol | Called by | Calls |\n| --- | --- | --- |\n");
319        for e in &s.most_called {
320            let _ = writeln!(
321                c,
322                "| [[{}\\|{}]] | {} | {} |",
323                note_name(&e.key),
324                e.name,
325                e.fan_in,
326                e.fan_out
327            );
328        }
329    }
330
331    c.push_str(
332        "\n## Navigating this vault\n\n\
333         - Open the **graph view** to see the whole codebase; notes are coloured/\
334         filterable by their `roteiro/kind/*`, `roteiro/lang/*` and \
335         `roteiro/status/*` tags.\n\
336         - Each note carries its captured **content** (doc comments, prose, PDF/\
337         image text) and its provenance-labelled incoming/outgoing links.\n\
338         - Start from an ADR above, or search the tag pane for a kind.\n",
339    );
340
341    VaultNote {
342        filename: HOME_NOTE.to_owned(),
343        content: c,
344    }
345}
346
347#[cfg(test)]
348mod tests {
349    use super::{
350        AdrEntry, CouplingEntry, HOME_NOTE, VaultSummary, note_name, render_home, render_note,
351    };
352    use rto_graph::{EdgeRef, Explanation, NodeSummary};
353
354    #[test]
355    fn note_name_is_safe_and_stable() {
356        assert_eq!(
357            note_name("sym:rust:src/a.rs#Store"),
358            "sym-rust-src-a.rs-Store"
359        );
360        assert_eq!(note_name("adr:0001"), "adr-0001");
361        assert_eq!(note_name("file:src/main.rs"), "file-src-main.rs");
362    }
363
364    #[test]
365    fn render_note_emits_frontmatter_and_wikilinks() {
366        let ex = Explanation {
367            schema: rto_graph::SCHEMA,
368            node: NodeSummary {
369                key: "sym:rust:a.rs#main".into(),
370                kind: "fn".into(),
371                name: "main".into(),
372                path: Some("a.rs".into()),
373                lang: Some("rust".into()),
374            },
375            meta: serde_json::Value::Null,
376            outgoing: vec![EdgeRef {
377                kind: "calls".into(),
378                provenance: "derived",
379                confidence: None,
380                node: "sym:rust:a.rs#helper".into(),
381            }],
382            incoming: vec![EdgeRef {
383                kind: "references".into(),
384                provenance: "authored",
385                confidence: None,
386                node: "adr:0001".into(),
387            }],
388        };
389        let note = render_note(&ex, None);
390        assert_eq!(note.filename, "sym-rust-a.rs-main.md");
391        assert!(note.content.contains("kind: fn"));
392        // No source base → no Source link.
393        assert!(!note.content.contains("**Source:**"));
394        assert!(note.content.contains("# main"));
395        assert!(
396            note.content
397                .contains("- calls (derived) → [[sym-rust-a.rs-helper]]")
398        );
399        assert!(
400            note.content
401                .contains("- [[adr-0001]] references (authored) →")
402        );
403        // Tags for the graph view.
404        assert!(note.content.contains("- roteiro/kind/fn"));
405        assert!(note.content.contains("- roteiro/lang/rust"));
406    }
407
408    #[test]
409    fn note_name_bounds_long_keys_deterministically() {
410        let long = format!("import:rust:{}", "a::b::c,".repeat(60));
411        let a = note_name(&long);
412        let b = note_name(&long);
413        assert_eq!(a, b, "deterministic");
414        assert!(
415            a.len() <= 205,
416            "bounded under the filename limit: {}",
417            a.len()
418        );
419        assert_ne!(
420            note_name(&format!("{long}x")),
421            a,
422            "different keys stay distinct after truncation"
423        );
424    }
425
426    #[test]
427    fn render_note_surfaces_content_and_status() {
428        let ex = Explanation {
429            schema: rto_graph::SCHEMA,
430            node: NodeSummary {
431                key: "adr:0001".into(),
432                kind: "adr".into(),
433                name: "Build Roteiro".into(),
434                path: Some("docs/adr/0001.md".into()),
435                lang: None,
436            },
437            meta: serde_json::json!({ "status": "Accepted", "content": "The decision text." }),
438            outgoing: vec![],
439            incoming: vec![],
440        };
441        let note = render_note(&ex, Some("https://github.com/org/repo/blob/abc123"));
442        assert!(note.content.contains("status: Accepted"));
443        assert!(note.content.contains("- roteiro/status/accepted"));
444        assert!(note.content.contains("> **Status:** Accepted"));
445        assert!(note.content.contains("## Content\n\nThe decision text."));
446        // A clickable link to the actual ADR file on the repository host.
447        assert!(
448            note.content.contains(
449                "**Source:** [`docs/adr/0001.md`](https://github.com/org/repo/blob/abc123/docs/adr/0001.md)"
450            ),
451            "{}",
452            note.content
453        );
454    }
455
456    #[test]
457    fn render_note_shows_inferred_confidence() {
458        let ex = Explanation {
459            schema: rto_graph::SCHEMA,
460            node: NodeSummary {
461                key: "file:a.md".into(),
462                kind: "file".into(),
463                name: "a.md".into(),
464                path: Some("a.md".into()),
465                lang: None,
466            },
467            meta: serde_json::Value::Null,
468            outgoing: vec![EdgeRef {
469                kind: "related".into(),
470                provenance: "inferred",
471                confidence: Some(0.82),
472                node: "file:b.md".into(),
473            }],
474            incoming: vec![],
475        };
476        let note = render_note(&ex, None);
477        assert!(
478            note.content
479                .contains("related (inferred) (0.82) → [[file-b.md]]"),
480            "{}",
481            note.content
482        );
483    }
484
485    #[test]
486    fn render_home_summarises_the_graph() {
487        let summary = VaultSummary {
488            project: "demo".into(),
489            total_nodes: 3,
490            total_edges: 2,
491            node_counts: vec![("fn".into(), 2), ("adr".into(), 1)],
492            edge_provenance: vec![("derived".into(), 1), ("authored".into(), 1)],
493            adrs: vec![AdrEntry {
494                key: "adr:0001".into(),
495                name: "First".into(),
496                status: Some("Accepted".into()),
497            }],
498            debt: vec![("todo".into(), 4)], // roteiro:ignore
499            most_called: vec![CouplingEntry {
500                key: "sym:rust:a.rs#helper".into(),
501                name: "helper".into(),
502                fan_in: 7,
503                fan_out: 1,
504            }],
505            repo_url: Some("https://github.com/org/repo".into()),
506            commit: Some("abcdef0123456789".into()),
507        };
508        let note = render_home(&summary);
509        assert_eq!(note.filename, HOME_NOTE);
510        assert!(note.content.contains("# demo — knowledge graph"));
511        assert!(note.content.contains("**3 nodes**, **2 edges**"));
512        assert!(note.content.contains("| fn | 2 |"));
513        assert!(note.content.contains("| derived | 1 |"));
514        assert!(note.content.contains("**Accepted** — [[adr-0001|First]]"));
515        assert!(note.content.contains("| todo | 4 |")); // roteiro:ignore
516        // Directed coupling: the two fans are separate columns, and the wikilink's
517        // own `|` is escaped so it cannot break the table it sits in.
518        assert!(
519            note.content
520                .contains("| [[sym-rust-a.rs-helper\\|helper]] | 7 | 1 |"),
521            "{}",
522            note.content
523        );
524        assert!(
525            note.content.contains("resolved by simple name"),
526            "the precision caveat travels with the figures"
527        );
528        // A repository link + short-commit permalink note.
529        assert!(
530            note.content
531                .contains("**Repository:** [https://github.com/org/repo](https://github.com/org/repo) · rendered at commit `abcdef012345`"),
532            "{}",
533            note.content
534        );
535    }
536
537    #[test]
538    fn render_home_omits_coupling_for_a_graph_with_no_calls() {
539        // A prose-only vault has no `calls` edges. An empty table under a heading
540        // reads as "measured, and there is nothing" — the section is absent instead.
541        let note = render_home(&VaultSummary {
542            project: "docs".into(),
543            total_nodes: 1,
544            ..VaultSummary::default()
545        });
546        assert!(
547            !note.content.contains("Most depended-on"),
548            "no heading without rows: {}",
549            note.content
550        );
551        // The rest of the overview is unaffected.
552        assert!(note.content.contains("# docs — knowledge graph"));
553    }
554}