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