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. Built from the same [`Explanation`] the query surface returns, so
4//! the vault and the CLI agree.
5
6use std::fmt::Write as _;
7
8use rto_graph::Explanation;
9
10/// A rendered vault note: its filename (with `.md`) and markdown content.
11#[derive(Debug, Clone, PartialEq, Eq)]
12pub struct VaultNote {
13    /// Filename including the `.md` extension.
14    pub filename: String,
15    /// Markdown content.
16    pub content: String,
17}
18
19/// Map a node key to a filesystem- and wikilink-safe note stem. Characters that
20/// are awkward in filenames or Obsidian links (`:` `/` `#` whitespace) collapse
21/// to `-`; alphanumerics, `.`, `_` and `-` are kept.
22#[must_use]
23pub fn note_name(key: &str) -> String {
24    let mut out = String::with_capacity(key.len());
25    let mut prev_dash = false;
26    for c in key.chars() {
27        if c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-') {
28            out.push(c);
29            prev_dash = false;
30        } else if !prev_dash {
31            out.push('-');
32            prev_dash = true;
33        }
34    }
35    out.trim_matches('-').to_owned()
36}
37
38/// Render a node's [`Explanation`] into an Obsidian note: YAML frontmatter,
39/// a heading, and its outgoing/incoming edges as provenance-labelled wikilinks.
40#[must_use]
41pub fn render_note(ex: &Explanation) -> VaultNote {
42    let mut c = String::new();
43    c.push_str("---\n");
44    let _ = writeln!(c, "key: \"{}\"", ex.node.key.replace('"', "'"));
45    let _ = writeln!(c, "kind: {}", ex.node.kind);
46    if let Some(path) = &ex.node.path {
47        let _ = writeln!(c, "path: \"{path}\"");
48    }
49    c.push_str("---\n\n");
50    let _ = writeln!(c, "# {}", ex.node.name);
51
52    if !ex.outgoing.is_empty() {
53        c.push_str("\n## Outgoing\n\n");
54        for e in &ex.outgoing {
55            let _ = writeln!(
56                c,
57                "- {} ({}) → [[{}]]",
58                e.kind,
59                e.provenance,
60                note_name(&e.node)
61            );
62        }
63    }
64    if !ex.incoming.is_empty() {
65        c.push_str("\n## Incoming\n\n");
66        for e in &ex.incoming {
67            let _ = writeln!(
68                c,
69                "- [[{}]] {} ({}) →",
70                note_name(&e.node),
71                e.kind,
72                e.provenance
73            );
74        }
75    }
76
77    VaultNote {
78        filename: format!("{}.md", note_name(&ex.node.key)),
79        content: c,
80    }
81}
82
83#[cfg(test)]
84mod tests {
85    use super::{note_name, render_note};
86    use rto_graph::{EdgeRef, Explanation, NodeSummary};
87
88    #[test]
89    fn note_name_is_safe_and_stable() {
90        assert_eq!(
91            note_name("sym:rust:src/a.rs#Store"),
92            "sym-rust-src-a.rs-Store"
93        );
94        assert_eq!(note_name("adr:0001"), "adr-0001");
95        assert_eq!(note_name("file:src/main.rs"), "file-src-main.rs");
96    }
97
98    #[test]
99    fn render_note_emits_frontmatter_and_wikilinks() {
100        let ex = Explanation {
101            schema: rto_graph::SCHEMA,
102            node: NodeSummary {
103                key: "sym:rust:a.rs#main".into(),
104                kind: "fn".into(),
105                name: "main".into(),
106                path: Some("a.rs".into()),
107                lang: Some("rust".into()),
108            },
109            meta: serde_json::Value::Null,
110            outgoing: vec![EdgeRef {
111                kind: "calls".into(),
112                provenance: "derived",
113                confidence: None,
114                node: "sym:rust:a.rs#helper".into(),
115            }],
116            incoming: vec![EdgeRef {
117                kind: "references".into(),
118                provenance: "authored",
119                confidence: None,
120                node: "adr:0001".into(),
121            }],
122        };
123        let note = render_note(&ex);
124        assert_eq!(note.filename, "sym-rust-a.rs-main.md");
125        assert!(note.content.contains("kind: fn"));
126        assert!(note.content.contains("# main"));
127        assert!(
128            note.content
129                .contains("- calls (derived) → [[sym-rust-a.rs-helper]]")
130        );
131        assert!(
132            note.content
133                .contains("- [[adr-0001]] references (authored) →")
134        );
135    }
136}