1use std::fmt::Write as _;
14
15use rto_graph::Explanation;
16
17pub const HOME_NOTE: &str = "_Home.md";
19
20#[derive(Debug, Clone, PartialEq, Eq)]
22pub struct VaultNote {
23 pub filename: String,
25 pub content: String,
27}
28
29#[must_use]
36pub fn note_name(key: &str) -> String {
37 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
60fn 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#[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 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 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 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
162fn confidence(c: Option<f64>) -> String {
164 c.map_or_else(String::new, |c| format!(" ({c:.2})"))
165}
166
167fn 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#[derive(Debug, Clone)]
186pub struct AdrEntry {
187 pub key: String,
189 pub name: String,
191 pub status: Option<String>,
193}
194
195#[derive(Debug, Clone, Default)]
197pub struct VaultSummary {
198 pub project: String,
200 pub total_nodes: usize,
202 pub total_edges: usize,
204 pub node_counts: Vec<(String, usize)>,
206 pub edge_provenance: Vec<(String, usize)>,
208 pub adrs: Vec<AdrEntry>,
210 pub debt: Vec<(String, usize)>,
212 pub repo_url: Option<String>,
215 pub commit: Option<String>,
217}
218
219#[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 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 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 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)], 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 |")); 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}