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