1use std::fmt::Write as _;
17
18use rto_graph::Explanation;
19
20pub const HOME_NOTE: &str = "_Home.md";
22
23#[derive(Debug, Clone, PartialEq, Eq)]
25pub struct VaultNote {
26 pub filename: String,
28 pub content: String,
30}
31
32#[must_use]
39pub fn note_name(key: &str) -> String {
40 const MAX: usize = 200;
44 let mut out = String::with_capacity(key.len());
45 let mut prev_dash = false;
46 for c in key.chars() {
47 if c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-') {
48 out.push(c);
49 prev_dash = false;
50 } else if !prev_dash {
51 out.push('-');
52 prev_dash = true;
53 }
54 }
55 let out = out.trim_matches('-');
56 if out.len() <= MAX {
57 out.to_owned()
58 } else {
59 format!("{}-{:016x}", &out[..MAX - 17], fnv1a64(key.as_bytes()))
60 }
61}
62
63fn fnv1a64(bytes: &[u8]) -> u64 {
66 let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
67 for &b in bytes {
68 hash ^= u64::from(b);
69 hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
70 }
71 hash
72}
73
74#[must_use]
81pub fn render_note(ex: &Explanation, source_base: Option<&str>) -> VaultNote {
82 let meta = &ex.meta;
83 let status = meta.get("status").and_then(|v| v.as_str());
84 let content = meta.get("content").and_then(|v| v.as_str());
85
86 let mut c = String::new();
87 c.push_str("---\n");
88 let _ = writeln!(c, "key: \"{}\"", ex.node.key.replace('"', "'"));
89 let _ = writeln!(c, "kind: {}", ex.node.kind);
90 if let Some(path) = &ex.node.path {
91 let _ = writeln!(c, "path: \"{path}\"");
92 }
93 if let Some(lang) = &ex.node.lang {
94 let _ = writeln!(c, "lang: {lang}");
95 }
96 if let Some(status) = status {
97 let _ = writeln!(c, "status: {status}");
98 }
99 c.push_str("tags:\n");
101 let _ = writeln!(c, " - roteiro/kind/{}", tag_slug(&ex.node.kind));
102 if let Some(lang) = &ex.node.lang {
103 let _ = writeln!(c, " - roteiro/lang/{}", tag_slug(lang));
104 }
105 if let Some(status) = status {
106 let _ = writeln!(c, " - roteiro/status/{}", tag_slug(status));
107 }
108 c.push_str("---\n\n");
109
110 let _ = writeln!(c, "# {}", ex.node.name);
111 if let Some(status) = status {
112 let _ = writeln!(c, "\n> **Status:** {status}");
113 }
114
115 if let (Some(base), Some(path)) = (source_base, ex.node.path.as_deref()) {
118 let _ = writeln!(
119 c,
120 "\n**Source:** [`{path}`]({}/{path})",
121 base.trim_end_matches('/')
122 );
123 }
124
125 if let Some(content) = content.map(str::trim).filter(|s| !s.is_empty()) {
127 c.push_str("\n## Content\n\n");
128 c.push_str(content);
129 c.push('\n');
130 }
131
132 if !ex.outgoing.is_empty() {
133 c.push_str("\n## Outgoing\n\n");
134 for e in &ex.outgoing {
135 let _ = writeln!(
136 c,
137 "- {} ({}){} → [[{}]]",
138 e.kind,
139 e.provenance,
140 confidence(e.confidence),
141 note_name(&e.node)
142 );
143 }
144 }
145 if !ex.incoming.is_empty() {
146 c.push_str("\n## Incoming\n\n");
147 for e in &ex.incoming {
148 let _ = writeln!(
149 c,
150 "- [[{}]] {} ({}){} →",
151 note_name(&e.node),
152 e.kind,
153 e.provenance,
154 confidence(e.confidence)
155 );
156 }
157 }
158
159 VaultNote {
160 filename: format!("{}.md", note_name(&ex.node.key)),
161 content: c,
162 }
163}
164
165fn confidence(c: Option<f64>) -> String {
167 c.map_or_else(String::new, |c| format!(" ({c:.2})"))
168}
169
170fn tag_slug(s: &str) -> String {
173 let mut out = String::with_capacity(s.len());
174 let mut prev_dash = false;
175 for ch in s.chars() {
176 if ch.is_ascii_alphanumeric() {
177 out.push(ch.to_ascii_lowercase());
178 prev_dash = false;
179 } else if !prev_dash {
180 out.push('-');
181 prev_dash = true;
182 }
183 }
184 out.trim_matches('-').to_owned()
185}
186
187#[derive(Debug, Clone)]
189pub struct AdrEntry {
190 pub key: String,
192 pub name: String,
194 pub status: Option<String>,
196}
197
198#[derive(Debug, Clone, Default)]
205pub struct ConfigSecretSummary {
206 pub secret_named: usize,
208 pub redacted: usize,
210 pub declared: usize,
212 pub unredacted: usize,
214 pub files: Vec<String>,
217}
218
219#[derive(Debug, Clone)]
221pub struct DensityEntry {
222 pub path: String,
224 pub markers: u32,
226 pub lines: u32,
228 pub per_kloc: f64,
230}
231
232#[derive(Debug, Clone)]
234pub struct CouplingEntry {
235 pub key: String,
237 pub name: String,
239 pub fan_in: u32,
241 pub fan_out: u32,
243}
244
245#[derive(Debug, Clone, Default)]
247pub struct VaultSummary {
248 pub project: String,
250 pub total_nodes: usize,
252 pub total_edges: usize,
254 pub node_counts: Vec<(String, usize)>,
256 pub edge_provenance: Vec<(String, usize)>,
258 pub adrs: Vec<AdrEntry>,
260 pub debt: Vec<(String, usize)>,
262 pub densest_files: Vec<DensityEntry>,
266 pub config_secrets: Option<ConfigSecretSummary>,
271 pub most_called: Vec<CouplingEntry>,
274 pub repo_url: Option<String>,
277 pub commit: Option<String>,
279}
280
281#[must_use]
285pub fn render_home(s: &VaultSummary) -> VaultNote {
286 let mut c = String::new();
287 c.push_str("---\ntags:\n - roteiro/home\n---\n\n");
288 let _ = writeln!(c, "# {} — knowledge graph", s.project);
289 c.push_str(
290 "\n*A browsable snapshot of this codebase as one **knowledge graph**, \
291 generated by [Roteiro](https://roteiro.dev). Every symbol, document and \
292 decision is a note, linked to the things it relates to.*\n",
293 );
294 c.push_str(
295 "\n**How to read it.** Open any note to see what a thing is, the intent or \
296 docs behind it (its **Content**), where it lives (its **Source** link), \
297 and how it connects (**Outgoing**/**Incoming** links). Each link is \
298 labelled with how the fact was established — `derived` (extracted from \
299 code), `authored` (human intent: ADRs, blueprints, annotations), or \
300 `inferred` (a scored suggestion). Open Obsidian's **graph view** to see \
301 the whole thing at once.\n",
302 );
303 let _ = writeln!(
304 c,
305 "\n**{} nodes**, **{} edges** across the project.",
306 s.total_nodes, s.total_edges
307 );
308 if let Some(repo) = &s.repo_url {
309 let _ = write!(c, "\n**Repository:** [{repo}]({repo})");
310 if let Some(commit) = &s.commit {
311 let short = &commit[..commit.len().min(12)];
312 let _ = write!(c, " · rendered at commit `{short}`");
313 }
314 c.push('\n');
315 }
316
317 c.push_str("\n## Structure\n\n| Kind | Count |\n| --- | --- |\n");
318 for (kind, n) in &s.node_counts {
319 let _ = writeln!(c, "| {kind} | {n} |");
320 }
321
322 if !s.edge_provenance.is_empty() {
323 c.push_str("\n## Provenance\n\n| Provenance | Edges |\n| --- | --- |\n");
324 for (prov, n) in &s.edge_provenance {
325 let _ = writeln!(c, "| {prov} | {n} |");
326 }
327 }
328
329 c.push_str("\n## Decisions (ADRs)\n\n");
330 if s.adrs.is_empty() {
331 c.push_str("*No ADRs found.*\n");
332 } else {
333 for adr in &s.adrs {
334 let status = adr.status.as_deref().unwrap_or("—");
335 let _ = writeln!(
336 c,
337 "- **{status}** — [[{}|{}]]",
338 note_name(&adr.key),
339 adr.name
340 );
341 }
342 }
343
344 c.push_str("\n## Intent debt\n\n");
345 if s.debt.is_empty() {
346 c.push_str("*None recorded.*\n");
347 } else {
348 c.push_str("| Category | Count |\n| --- | --- |\n");
349 for (cat, n) in &s.debt {
350 let _ = writeln!(c, "| {cat} | {n} |");
351 }
352 }
353
354 if !s.densest_files.is_empty() {
355 c.push_str(
356 "\n### Densest files (markers per 1,000 lines)\n\n\
357 *Where the debt above is concentrated, rather than where there is \
358 most of it — a raw count ranks the biggest file first by \
359 construction. The denominator is file length: every line, blanks and \
360 comments included, not source lines of code. Prose matches (`for \
361 now`, `tbd`) count too, so a design document can rank high.*\n\n",
362 );
363 c.push_str("| File | Markers | Lines | Per 1k |\n| --- | --- | --- | --- |\n");
364 for e in &s.densest_files {
365 let _ = writeln!(
366 c,
367 "| [[{}\\|{}]] | {} | {} | {:.2} |",
368 note_name(&format!("file:{}", e.path)),
369 e.path,
370 e.markers,
371 e.lines,
372 e.per_kloc
373 );
374 }
375 }
376
377 if let Some(cs) = &s.config_secrets {
378 c.push_str("\n## Config keys named like secrets\n\n");
379 let _ = writeln!(
380 c,
381 "**{}** secret-named config key(s): {} redacted before storage, {} \
382 declared in code without a value, {} unredacted.",
383 cs.secret_named, cs.redacted, cs.declared, cs.unredacted
384 );
385 if cs.unredacted > 0 {
386 let _ = writeln!(
387 c,
388 "\n> [!warning] {} key(s) carry an **unredacted** value. Extraction \
389 always redacts, so these came from an import layer — inspect the \
390 importing tool, not this repository.",
391 cs.unredacted
392 );
393 }
394 if !cs.files.is_empty() {
395 c.push_str("\nIn:\n");
396 for path in &cs.files {
397 let _ = writeln!(c, "- [[{}\\|{path}]]", note_name(&format!("file:{path}")));
398 }
399 }
400 c.push_str(
405 "\n*An inventory of config keys whose **names** look secret, not a secret \
406 scan. Values are redacted before they are stored, so this reports that \
407 such keys exist and were redacted — never a value. It cannot see a \
408 hardcoded credential in source code, cannot judge whether a value is \
409 valid, and cannot tell a real secret from a placeholder. A credential \
410 under an innocuous key name (`dsn`, `endpoint`) does not appear here at \
411 all, so this section being small says nothing about whether this \
412 repository leaks secrets.*\n",
413 );
414 }
415
416 if !s.most_called.is_empty() {
417 c.push_str(
418 "\n## Most depended-on (call fan-in)\n\n\
419 *Distinct callers and callees over `calls` edges — direction kept, so \
420 \"everything calls this\" and \"this calls everything\" are not the same \
421 row. Call targets are resolved by simple name, so a short, generically-\
422 named function can absorb every call to that name: read a large fan-in on \
423 one as a question, not a finding.*\n\n",
424 );
425 c.push_str("| Symbol | Called by | Calls |\n| --- | --- | --- |\n");
426 for e in &s.most_called {
427 let _ = writeln!(
428 c,
429 "| [[{}\\|{}]] | {} | {} |",
430 note_name(&e.key),
431 e.name,
432 e.fan_in,
433 e.fan_out
434 );
435 }
436 }
437
438 c.push_str(
439 "\n## Navigating this vault\n\n\
440 - Open the **graph view** to see the whole codebase; notes are coloured/\
441 filterable by their `roteiro/kind/*`, `roteiro/lang/*` and \
442 `roteiro/status/*` tags.\n\
443 - Each note carries its captured **content** (doc comments, prose, PDF/\
444 image text) and its provenance-labelled incoming/outgoing links.\n\
445 - Start from an ADR above, or search the tag pane for a kind.\n",
446 );
447
448 VaultNote {
449 filename: HOME_NOTE.to_owned(),
450 content: c,
451 }
452}
453
454#[cfg(test)]
455mod tests {
456 use super::{
457 AdrEntry, ConfigSecretSummary, CouplingEntry, DensityEntry, HOME_NOTE, VaultSummary,
458 note_name, render_home, render_note,
459 };
460 use rto_graph::{EdgeRef, Explanation, NodeSummary};
461
462 #[test]
463 fn note_name_is_safe_and_stable() {
464 assert_eq!(
465 note_name("sym:rust:src/a.rs#Store"),
466 "sym-rust-src-a.rs-Store"
467 );
468 assert_eq!(note_name("adr:0001"), "adr-0001");
469 assert_eq!(note_name("file:src/main.rs"), "file-src-main.rs");
470 }
471
472 #[test]
473 fn render_note_emits_frontmatter_and_wikilinks() {
474 let ex = Explanation {
475 schema: rto_graph::SCHEMA,
476 node: NodeSummary {
477 key: "sym:rust:a.rs#main".into(),
478 kind: "fn".into(),
479 name: "main".into(),
480 path: Some("a.rs".into()),
481 lang: Some("rust".into()),
482 },
483 meta: serde_json::Value::Null,
484 outgoing: vec![EdgeRef {
485 kind: "calls".into(),
486 provenance: "derived",
487 confidence: None,
488 node: "sym:rust:a.rs#helper".into(),
489 }],
490 incoming: vec![EdgeRef {
491 kind: "references".into(),
492 provenance: "authored",
493 confidence: None,
494 node: "adr:0001".into(),
495 }],
496 };
497 let note = render_note(&ex, None);
498 assert_eq!(note.filename, "sym-rust-a.rs-main.md");
499 assert!(note.content.contains("kind: fn"));
500 assert!(!note.content.contains("**Source:**"));
502 assert!(note.content.contains("# main"));
503 assert!(
504 note.content
505 .contains("- calls (derived) → [[sym-rust-a.rs-helper]]")
506 );
507 assert!(
508 note.content
509 .contains("- [[adr-0001]] references (authored) →")
510 );
511 assert!(note.content.contains("- roteiro/kind/fn"));
513 assert!(note.content.contains("- roteiro/lang/rust"));
514 }
515
516 #[test]
517 fn note_name_bounds_long_keys_deterministically() {
518 let long = format!("import:rust:{}", "a::b::c,".repeat(60));
519 let a = note_name(&long);
520 let b = note_name(&long);
521 assert_eq!(a, b, "deterministic");
522 assert!(
523 a.len() <= 205,
524 "bounded under the filename limit: {}",
525 a.len()
526 );
527 assert_ne!(
528 note_name(&format!("{long}x")),
529 a,
530 "different keys stay distinct after truncation"
531 );
532 }
533
534 #[test]
535 fn render_note_surfaces_content_and_status() {
536 let ex = Explanation {
537 schema: rto_graph::SCHEMA,
538 node: NodeSummary {
539 key: "adr:0001".into(),
540 kind: "adr".into(),
541 name: "Build Roteiro".into(),
542 path: Some("docs/adr/0001.md".into()),
543 lang: None,
544 },
545 meta: serde_json::json!({ "status": "Accepted", "content": "The decision text." }),
546 outgoing: vec![],
547 incoming: vec![],
548 };
549 let note = render_note(&ex, Some("https://github.com/org/repo/blob/abc123"));
550 assert!(note.content.contains("status: Accepted"));
551 assert!(note.content.contains("- roteiro/status/accepted"));
552 assert!(note.content.contains("> **Status:** Accepted"));
553 assert!(note.content.contains("## Content\n\nThe decision text."));
554 assert!(
556 note.content.contains(
557 "**Source:** [`docs/adr/0001.md`](https://github.com/org/repo/blob/abc123/docs/adr/0001.md)"
558 ),
559 "{}",
560 note.content
561 );
562 }
563
564 #[test]
565 fn render_note_shows_inferred_confidence() {
566 let ex = Explanation {
567 schema: rto_graph::SCHEMA,
568 node: NodeSummary {
569 key: "file:a.md".into(),
570 kind: "file".into(),
571 name: "a.md".into(),
572 path: Some("a.md".into()),
573 lang: None,
574 },
575 meta: serde_json::Value::Null,
576 outgoing: vec![EdgeRef {
577 kind: "related".into(),
578 provenance: "inferred",
579 confidence: Some(0.82),
580 node: "file:b.md".into(),
581 }],
582 incoming: vec![],
583 };
584 let note = render_note(&ex, None);
585 assert!(
586 note.content
587 .contains("related (inferred) (0.82) → [[file-b.md]]"),
588 "{}",
589 note.content
590 );
591 }
592
593 #[test]
594 fn render_home_summarises_the_graph() {
595 let summary = VaultSummary {
596 project: "demo".into(),
597 total_nodes: 3,
598 total_edges: 2,
599 node_counts: vec![("fn".into(), 2), ("adr".into(), 1)],
600 edge_provenance: vec![("derived".into(), 1), ("authored".into(), 1)],
601 adrs: vec![AdrEntry {
602 key: "adr:0001".into(),
603 name: "First".into(),
604 status: Some("Accepted".into()),
605 }],
606 debt: vec![("todo".into(), 4)], densest_files: vec![DensityEntry {
608 path: "src/small.rs".into(),
609 markers: 3,
610 lines: 120,
611 per_kloc: 25.0,
612 }],
613 config_secrets: Some(ConfigSecretSummary {
614 secret_named: 4,
615 redacted: 3,
616 declared: 1,
617 unredacted: 0,
618 files: vec![".env".into()],
619 }),
620 most_called: vec![CouplingEntry {
621 key: "sym:rust:a.rs#helper".into(),
622 name: "helper".into(),
623 fan_in: 7,
624 fan_out: 1,
625 }],
626 repo_url: Some("https://github.com/org/repo".into()),
627 commit: Some("abcdef0123456789".into()),
628 };
629 let note = render_home(&summary);
630 assert_eq!(note.filename, HOME_NOTE);
631 assert!(note.content.contains("# demo — knowledge graph"));
632 assert!(note.content.contains("**3 nodes**, **2 edges**"));
633 assert!(note.content.contains("| fn | 2 |"));
634 assert!(note.content.contains("| derived | 1 |"));
635 assert!(note.content.contains("**Accepted** — [[adr-0001|First]]"));
636 assert!(note.content.contains("| todo | 4 |")); assert!(
640 note.content
641 .contains("| [[sym-rust-a.rs-helper\\|helper]] | 7 | 1 |"),
642 "{}",
643 note.content
644 );
645 assert!(
646 note.content.contains("resolved by simple name"),
647 "the precision caveat travels with the figures"
648 );
649 assert!(
653 note.content
654 .contains("| [[file-src-small.rs\\|src/small.rs]] | 3 | 120 | 25.00 |"),
655 "{}",
656 note.content
657 );
658 assert!(
659 note.content.contains("not source lines of code"),
660 "the denominator caveat travels with the figures"
661 );
662 assert!(
666 note.content.contains(
667 "**4** secret-named config key(s): 3 redacted before storage, 1 \
668 declared in code without a value, 0 unredacted."
669 ),
670 "{}",
671 note.content
672 );
673 assert!(
674 note.content.contains("- [[file-.env\\|.env]]"),
675 "{}",
676 note.content
677 );
678 assert!(
679 note.content.contains("not a secret scan")
680 && note.content.contains("cannot see a hardcoded credential"),
681 "the limitation travels with the figures: {}",
682 note.content
683 );
684 assert!(
685 !note.content.contains("[!warning]"),
686 "no warning when nothing is unredacted: {}",
687 note.content
688 );
689 assert!(
691 note.content
692 .contains("**Repository:** [https://github.com/org/repo](https://github.com/org/repo) · rendered at commit `abcdef012345`"),
693 "{}",
694 note.content
695 );
696 }
697
698 #[test]
699 fn render_home_omits_density_for_a_graph_with_no_markers() {
700 let note = render_home(&VaultSummary {
704 project: "clean".into(),
705 total_nodes: 1,
706 ..VaultSummary::default()
707 });
708 assert!(
709 !note.content.contains("Densest files"),
710 "no heading without rows: {}",
711 note.content
712 );
713 assert!(note.content.contains("## Intent debt"));
716 assert!(note.content.contains("*None recorded.*"));
717 }
718
719 #[test]
720 fn render_home_omits_config_secrets_rather_than_rendering_zeroes() {
721 let note = render_home(&VaultSummary {
725 project: "clean".into(),
726 total_nodes: 1,
727 ..VaultSummary::default()
728 });
729 assert!(
730 !note.content.contains("named like secrets"),
731 "no heading without figures: {}",
732 note.content
733 );
734 }
735
736 #[test]
737 fn render_home_warns_loudly_about_an_unredacted_value() {
738 let note = render_home(&VaultSummary {
742 project: "imported".into(),
743 total_nodes: 1,
744 config_secrets: Some(ConfigSecretSummary {
745 secret_named: 1,
746 redacted: 0,
747 declared: 0,
748 unredacted: 1,
749 files: vec!["imported.env".into()],
750 }),
751 ..VaultSummary::default()
752 });
753 assert!(
754 note.content.contains("[!warning]") && note.content.contains("**unredacted**"),
755 "{}",
756 note.content
757 );
758 assert!(
759 note.content.contains("came from an import layer"),
760 "and it points at the importing tool, not the repository: {}",
761 note.content
762 );
763 }
764
765 #[test]
766 fn render_home_omits_coupling_for_a_graph_with_no_calls() {
767 let note = render_home(&VaultSummary {
770 project: "docs".into(),
771 total_nodes: 1,
772 ..VaultSummary::default()
773 });
774 assert!(
775 !note.content.contains("Most depended-on"),
776 "no heading without rows: {}",
777 note.content
778 );
779 assert!(note.content.contains("# docs — knowledge graph"));
781 }
782}