1use std::fmt::Write as _;
15
16use rto_graph::Explanation;
17
18pub const HOME_NOTE: &str = "_Home.md";
20
21#[derive(Debug, Clone, PartialEq, Eq)]
23pub struct VaultNote {
24 pub filename: String,
26 pub content: String,
28}
29
30#[must_use]
37pub fn note_name(key: &str) -> String {
38 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
61fn 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#[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 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 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 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
163fn confidence(c: Option<f64>) -> String {
165 c.map_or_else(String::new, |c| format!(" ({c:.2})"))
166}
167
168fn 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#[derive(Debug, Clone)]
187pub struct AdrEntry {
188 pub key: String,
190 pub name: String,
192 pub status: Option<String>,
194}
195
196#[derive(Debug, Clone)]
198pub struct CouplingEntry {
199 pub key: String,
201 pub name: String,
203 pub fan_in: u32,
205 pub fan_out: u32,
207}
208
209#[derive(Debug, Clone, Default)]
211pub struct VaultSummary {
212 pub project: String,
214 pub total_nodes: usize,
216 pub total_edges: usize,
218 pub node_counts: Vec<(String, usize)>,
220 pub edge_provenance: Vec<(String, usize)>,
222 pub adrs: Vec<AdrEntry>,
224 pub debt: Vec<(String, usize)>,
226 pub most_called: Vec<CouplingEntry>,
229 pub repo_url: Option<String>,
232 pub commit: Option<String>,
234}
235
236#[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 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 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 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)], 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 |")); 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 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 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 assert!(note.content.contains("# docs — knowledge graph"));
553 }
554}