1use std::fmt::Write as _;
24
25use rto_graph::Explanation;
26
27pub const HOME_NOTE: &str = "_Home.md";
29
30#[derive(Debug, Clone, PartialEq, Eq)]
32pub struct VaultNote {
33 pub filename: String,
35 pub content: String,
37}
38
39#[must_use]
46pub fn note_name(key: &str) -> String {
47 const MAX: usize = 200;
51 let mut out = String::with_capacity(key.len());
52 let mut prev_dash = false;
53 for c in key.chars() {
54 if c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-') {
55 out.push(c);
56 prev_dash = false;
57 } else if !prev_dash {
58 out.push('-');
59 prev_dash = true;
60 }
61 }
62 let out = out.trim_matches('-');
63 if out.len() <= MAX {
64 out.to_owned()
65 } else {
66 format!("{}-{:016x}", &out[..MAX - 17], fnv1a64(key.as_bytes()))
67 }
68}
69
70fn fnv1a64(bytes: &[u8]) -> u64 {
73 let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
74 for &b in bytes {
75 hash ^= u64::from(b);
76 hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
77 }
78 hash
79}
80
81#[must_use]
94pub fn render_note(ex: &Explanation, source_base: Option<&str>, body: Option<&str>) -> VaultNote {
95 let meta = &ex.meta;
96 let status = meta.get("status").and_then(|v| v.as_str());
97 let content = note_body(meta.get("content").and_then(|v| v.as_str()), body);
98
99 let mut c = String::new();
100 c.push_str("---\n");
101 let _ = writeln!(c, "key: \"{}\"", ex.node.key.replace('"', "'"));
102 let _ = writeln!(c, "kind: {}", ex.node.kind);
103 if let Some(path) = &ex.node.path {
104 let _ = writeln!(c, "path: \"{path}\"");
105 }
106 if let Some(lang) = &ex.node.lang {
107 let _ = writeln!(c, "lang: {lang}");
108 }
109 if let Some(status) = status {
110 let _ = writeln!(c, "status: {status}");
111 }
112 c.push_str("tags:\n");
114 let _ = writeln!(c, " - roteiro/kind/{}", tag_slug(&ex.node.kind));
115 if let Some(lang) = &ex.node.lang {
116 let _ = writeln!(c, " - roteiro/lang/{}", tag_slug(lang));
117 }
118 if let Some(status) = status {
119 let _ = writeln!(c, " - roteiro/status/{}", tag_slug(status));
120 }
121 c.push_str("---\n\n");
122
123 let _ = writeln!(c, "# {}", ex.node.name);
124 if let Some(status) = status {
125 let _ = writeln!(c, "\n> **Status:** {status}");
126 }
127
128 if let (Some(base), Some(path)) = (source_base, ex.node.path.as_deref()) {
131 let _ = writeln!(
132 c,
133 "\n**Source:** [`{path}`]({}/{path})",
134 base.trim_end_matches('/')
135 );
136 }
137
138 if let Some(content) = content.map(str::trim).filter(|s| !s.is_empty()) {
141 c.push_str("\n## Content\n\n");
142 c.push_str(content);
143 c.push('\n');
144 }
145
146 if !ex.outgoing.is_empty() {
147 c.push_str("\n## Outgoing\n\n");
148 for e in &ex.outgoing {
149 let _ = writeln!(
150 c,
151 "- {} ({}){} → [[{}]]",
152 e.kind,
153 e.provenance,
154 confidence(e.confidence),
155 note_name(&e.node)
156 );
157 }
158 }
159 if !ex.incoming.is_empty() {
160 c.push_str("\n## Incoming\n\n");
161 for e in &ex.incoming {
162 let _ = writeln!(
163 c,
164 "- [[{}]] {} ({}){} →",
165 note_name(&e.node),
166 e.kind,
167 e.provenance,
168 confidence(e.confidence)
169 );
170 }
171 }
172
173 VaultNote {
174 filename: format!("{}.md", note_name(&ex.node.key)),
175 content: c,
176 }
177}
178
179fn note_body<'a>(content: Option<&'a str>, body: Option<&'a str>) -> Option<&'a str> {
191 body.or(content)
192}
193
194fn confidence(c: Option<f64>) -> String {
196 c.map_or_else(String::new, |c| format!(" ({c:.2})"))
197}
198
199fn tag_slug(s: &str) -> String {
202 let mut out = String::with_capacity(s.len());
203 let mut prev_dash = false;
204 for ch in s.chars() {
205 if ch.is_ascii_alphanumeric() {
206 out.push(ch.to_ascii_lowercase());
207 prev_dash = false;
208 } else if !prev_dash {
209 out.push('-');
210 prev_dash = true;
211 }
212 }
213 out.trim_matches('-').to_owned()
214}
215
216#[derive(Debug, Clone)]
218pub struct AdrEntry {
219 pub key: String,
221 pub name: String,
223 pub status: Option<String>,
225}
226
227#[derive(Debug, Clone, Default)]
234pub struct ConfigSecretSummary {
235 pub secret_named: usize,
237 pub redacted: usize,
239 pub declared: usize,
241 pub unredacted: usize,
243 pub files: Vec<String>,
246}
247
248#[derive(Debug, Clone)]
250pub struct DensityEntry {
251 pub path: String,
253 pub markers: u32,
255 pub lines: u32,
257 pub per_kloc: f64,
259}
260
261#[derive(Debug, Clone)]
263pub struct CouplingEntry {
264 pub key: String,
266 pub name: String,
268 pub fan_in: u32,
270 pub fan_out: u32,
272}
273
274#[derive(Debug, Clone, Default)]
276pub struct VaultSummary {
277 pub project: String,
279 pub total_nodes: usize,
281 pub total_edges: usize,
283 pub node_counts: Vec<(String, usize)>,
285 pub edge_provenance: Vec<(String, usize)>,
287 pub adrs: Vec<AdrEntry>,
289 pub debt: Vec<(String, usize)>,
291 pub densest_files: Vec<DensityEntry>,
295 pub config_secrets: Option<ConfigSecretSummary>,
300 pub most_called: Vec<CouplingEntry>,
303 pub repo_url: Option<String>,
306 pub commit: Option<String>,
308}
309
310#[must_use]
314pub fn render_home(s: &VaultSummary) -> VaultNote {
315 let mut c = String::new();
316 c.push_str("---\ntags:\n - roteiro/home\n---\n\n");
317 let _ = writeln!(c, "# {} — knowledge graph", s.project);
318 c.push_str(
319 "\n*A browsable snapshot of this codebase as one **knowledge graph**, \
320 generated by [Roteiro](https://roteiro.dev). Every symbol, document and \
321 decision is a note, linked to the things it relates to.*\n",
322 );
323 c.push_str(
324 "\n**How to read it.** Open any note to see what a thing is, the intent or \
325 docs behind it (its **Content**), where it lives (its **Source** link), \
326 and how it connects (**Outgoing**/**Incoming** links). Each link is \
327 labelled with how the fact was established — `derived` (extracted from \
328 code), `authored` (human intent: ADRs, blueprints, annotations), or \
329 `inferred` (a scored suggestion). Open Obsidian's **graph view** to see \
330 the whole thing at once.\n",
331 );
332 let _ = writeln!(
333 c,
334 "\n**{} nodes**, **{} edges** across the project.",
335 s.total_nodes, s.total_edges
336 );
337 if let Some(repo) = &s.repo_url {
338 let _ = write!(c, "\n**Repository:** [{repo}]({repo})");
339 if let Some(commit) = &s.commit {
340 let short = &commit[..commit.len().min(12)];
341 let _ = write!(c, " · rendered at commit `{short}`");
342 }
343 c.push('\n');
344 }
345
346 c.push_str("\n## Structure\n\n| Kind | Count |\n| --- | --- |\n");
347 for (kind, n) in &s.node_counts {
348 let _ = writeln!(c, "| {kind} | {n} |");
349 }
350
351 if !s.edge_provenance.is_empty() {
352 c.push_str("\n## Provenance\n\n| Provenance | Edges |\n| --- | --- |\n");
353 for (prov, n) in &s.edge_provenance {
354 let _ = writeln!(c, "| {prov} | {n} |");
355 }
356 }
357
358 c.push_str("\n## Decisions (ADRs)\n\n");
359 if s.adrs.is_empty() {
360 c.push_str("*No ADRs found.*\n");
361 } else {
362 for adr in &s.adrs {
363 let status = adr.status.as_deref().unwrap_or("—");
364 let _ = writeln!(
365 c,
366 "- **{status}** — [[{}|{}]]",
367 note_name(&adr.key),
368 adr.name
369 );
370 }
371 }
372
373 c.push_str("\n## Intent debt\n\n");
374 if s.debt.is_empty() {
375 c.push_str("*None recorded.*\n");
376 } else {
377 c.push_str("| Category | Count |\n| --- | --- |\n");
378 for (cat, n) in &s.debt {
379 let _ = writeln!(c, "| {cat} | {n} |");
380 }
381 }
382
383 if !s.densest_files.is_empty() {
384 c.push_str(
385 "\n### Densest files (markers per 1,000 lines)\n\n\
386 *Where the debt above is concentrated, rather than where there is \
387 most of it — a raw count ranks the biggest file first by \
388 construction. The denominator is file length: every line, blanks and \
389 comments included, not source lines of code. Prose matches (`for \
390 now`, `tbd`) count too, so a design document can rank high.*\n\n",
391 );
392 c.push_str("| File | Markers | Lines | Per 1k |\n| --- | --- | --- | --- |\n");
393 for e in &s.densest_files {
394 let _ = writeln!(
395 c,
396 "| [[{}\\|{}]] | {} | {} | {:.2} |",
397 note_name(&format!("file:{}", e.path)),
398 e.path,
399 e.markers,
400 e.lines,
401 e.per_kloc
402 );
403 }
404 }
405
406 if let Some(cs) = &s.config_secrets {
407 c.push_str("\n## Config keys named like secrets\n\n");
408 let _ = writeln!(
409 c,
410 "**{}** secret-named config key(s): {} redacted before storage, {} \
411 declared in code without a value, {} unredacted.",
412 cs.secret_named, cs.redacted, cs.declared, cs.unredacted
413 );
414 if cs.unredacted > 0 {
415 let _ = writeln!(
416 c,
417 "\n> [!warning] {} key(s) carry an **unredacted** value. Extraction \
418 always redacts, so these came from an import layer — inspect the \
419 importing tool, not this repository.",
420 cs.unredacted
421 );
422 }
423 if !cs.files.is_empty() {
424 c.push_str("\nIn:\n");
425 for path in &cs.files {
426 let _ = writeln!(c, "- [[{}\\|{path}]]", note_name(&format!("file:{path}")));
427 }
428 }
429 c.push_str(
434 "\n*An inventory of config keys whose **names** look secret, not a secret \
435 scan. Values are redacted before they are stored, so this reports that \
436 such keys exist and were redacted — never a value. It cannot see a \
437 hardcoded credential in source code, cannot judge whether a value is \
438 valid, and cannot tell a real secret from a placeholder. A credential \
439 under an innocuous key name (`dsn`, `endpoint`) does not appear here at \
440 all, so this section being small says nothing about whether this \
441 repository leaks secrets.*\n",
442 );
443 }
444
445 if !s.most_called.is_empty() {
446 c.push_str(
447 "\n## Most depended-on (call fan-in)\n\n\
448 *Distinct callers and callees over `calls` edges — direction kept, so \
449 \"everything calls this\" and \"this calls everything\" are not the same \
450 row. Call targets are resolved by simple name, so a short, generically-\
451 named function can absorb every call to that name: read a large fan-in on \
452 one as a question, not a finding.*\n\n",
453 );
454 c.push_str("| Symbol | Called by | Calls |\n| --- | --- | --- |\n");
455 for e in &s.most_called {
456 let _ = writeln!(
457 c,
458 "| [[{}\\|{}]] | {} | {} |",
459 note_name(&e.key),
460 e.name,
461 e.fan_in,
462 e.fan_out
463 );
464 }
465 }
466
467 c.push_str(
468 "\n## Navigating this vault\n\n\
469 - Open the **graph view** to see the whole codebase; notes are coloured/\
470 filterable by their `roteiro/kind/*`, `roteiro/lang/*` and \
471 `roteiro/status/*` tags.\n\
472 - Each note carries its captured **content** (doc comments, prose, PDF/\
473 image text) and its provenance-labelled incoming/outgoing links.\n\
474 - Start from an ADR above, or search the tag pane for a kind.\n",
475 );
476
477 VaultNote {
478 filename: HOME_NOTE.to_owned(),
479 content: c,
480 }
481}
482
483#[cfg(test)]
484mod tests {
485 use super::{
486 AdrEntry, ConfigSecretSummary, CouplingEntry, DensityEntry, HOME_NOTE, VaultSummary,
487 note_name, render_home, render_note,
488 };
489 use rto_graph::{EdgeRef, Explanation, NodeSummary};
490
491 #[test]
492 fn note_name_is_safe_and_stable() {
493 assert_eq!(
494 note_name("sym:rust:src/a.rs#Store"),
495 "sym-rust-src-a.rs-Store"
496 );
497 assert_eq!(note_name("adr:0001"), "adr-0001");
498 assert_eq!(note_name("file:src/main.rs"), "file-src-main.rs");
499 }
500
501 #[test]
502 fn render_note_emits_frontmatter_and_wikilinks() {
503 let ex = Explanation {
504 schema: rto_graph::SCHEMA,
505 node: NodeSummary {
506 key: "sym:rust:a.rs#main".into(),
507 kind: "fn".into(),
508 name: "main".into(),
509 path: Some("a.rs".into()),
510 lang: Some("rust".into()),
511 },
512 meta: serde_json::Value::Null,
513 outgoing: vec![EdgeRef {
514 kind: "calls".into(),
515 provenance: "derived",
516 confidence: None,
517 node: "sym:rust:a.rs#helper".into(),
518 }],
519 incoming: vec![EdgeRef {
520 kind: "references".into(),
521 provenance: "authored",
522 confidence: None,
523 node: "adr:0001".into(),
524 }],
525 };
526 let note = render_note(&ex, None, None);
527 assert_eq!(note.filename, "sym-rust-a.rs-main.md");
528 assert!(note.content.contains("kind: fn"));
529 assert!(!note.content.contains("**Source:**"));
531 assert!(note.content.contains("# main"));
532 assert!(
533 note.content
534 .contains("- calls (derived) → [[sym-rust-a.rs-helper]]")
535 );
536 assert!(
537 note.content
538 .contains("- [[adr-0001]] references (authored) →")
539 );
540 assert!(note.content.contains("- roteiro/kind/fn"));
542 assert!(note.content.contains("- roteiro/lang/rust"));
543 }
544
545 #[test]
546 fn note_name_bounds_long_keys_deterministically() {
547 let long = format!("import:rust:{}", "a::b::c,".repeat(60));
548 let a = note_name(&long);
549 let b = note_name(&long);
550 assert_eq!(a, b, "deterministic");
551 assert!(
552 a.len() <= 205,
553 "bounded under the filename limit: {}",
554 a.len()
555 );
556 assert_ne!(
557 note_name(&format!("{long}x")),
558 a,
559 "different keys stay distinct after truncation"
560 );
561 }
562
563 #[test]
564 fn render_note_surfaces_content_and_status() {
565 let ex = Explanation {
566 schema: rto_graph::SCHEMA,
567 node: NodeSummary {
568 key: "adr:0001".into(),
569 kind: "adr".into(),
570 name: "Build Roteiro".into(),
571 path: Some("docs/adr/0001.md".into()),
572 lang: None,
573 },
574 meta: serde_json::json!({ "status": "Accepted", "content": "The decision text." }),
575 outgoing: vec![],
576 incoming: vec![],
577 };
578 let note = render_note(&ex, Some("https://github.com/org/repo/blob/abc123"), None);
579 assert!(note.content.contains("status: Accepted"));
580 assert!(note.content.contains("- roteiro/status/accepted"));
581 assert!(note.content.contains("> **Status:** Accepted"));
582 assert!(note.content.contains("## Content\n\nThe decision text."));
583 assert!(
585 note.content.contains(
586 "**Source:** [`docs/adr/0001.md`](https://github.com/org/repo/blob/abc123/docs/adr/0001.md)"
587 ),
588 "{}",
589 note.content
590 );
591 }
592
593 const DOC: &str = "# Working offline\n\nRoteiro is **offline-capable**.\n\n| Host | What |\n| --- | --- |\n| `example.com` | models |\n\n```sh\nroteiro model pull\n```\n";
596
597 fn prose_note(content: Option<&str>) -> Explanation {
598 Explanation {
599 schema: rto_graph::SCHEMA,
600 node: NodeSummary {
601 key: "file:docs/OFFLINE_SETUP.md".into(),
602 kind: "file".into(),
603 name: "OFFLINE_SETUP.md".into(),
604 path: Some("docs/OFFLINE_SETUP.md".into()),
605 lang: None,
606 },
607 meta: content.map_or(
608 serde_json::Value::Null,
609 |c| serde_json::json!({ "content": c }),
610 ),
611 outgoing: vec![],
612 incoming: vec![],
613 }
614 }
615
616 #[test]
625 fn a_supplied_body_supersedes_the_collapsed_stored_content() {
626 let collapsed = DOC.split_whitespace().collect::<Vec<_>>().join(" ");
628 let ex = prose_note(Some(&collapsed));
629
630 let note = render_note(&ex, None, Some(DOC));
631 assert!(
632 note.content.contains(DOC.trim()),
633 "the source document is reproduced verbatim: {}",
634 note.content
635 );
636 assert!(
637 !note.content.contains(&collapsed),
638 "the collapsed rendering is replaced, not appended: {}",
639 note.content
640 );
641 assert!(
642 note.content.contains("\n| Host | What |\n"),
643 "a table needs its own lines to be a table: {}",
644 note.content
645 );
646 assert!(
647 note.content.contains("\n```sh\n"),
648 "a fenced block needs its own lines to be a fence: {}",
649 note.content
650 );
651
652 let flat = render_note(&ex, None, None);
654 assert!(
655 flat.content.contains(&collapsed),
656 "without a body the stored content is still shown: {}",
657 flat.content
658 );
659 assert!(
660 content_lines(¬e.content) > content_lines(&flat.content),
661 "structure restored: {} line(s) with a body vs {} without",
662 content_lines(¬e.content),
663 content_lines(&flat.content)
664 );
665 assert_eq!(
666 content_lines(&flat.content),
667 1,
668 "the defect: the stored content is a single line"
669 );
670 }
671
672 #[test]
676 fn a_note_with_no_body_is_unchanged() {
677 let ex = Explanation {
678 schema: rto_graph::SCHEMA,
679 node: NodeSummary {
680 key: "sym:rust:a.rs#main".into(),
681 kind: "fn".into(),
682 name: "main".into(),
683 path: Some("a.rs".into()),
684 lang: Some("rust".into()),
685 },
686 meta: serde_json::json!({ "content": "Entry point." }),
687 outgoing: vec![],
688 incoming: vec![],
689 };
690 assert!(
691 render_note(&ex, None, None)
692 .content
693 .contains("## Content\n\nEntry point.")
694 );
695 }
696
697 fn content_lines(note: &str) -> usize {
699 let body = note
700 .split_once("## Content\n\n")
701 .map_or("", |(_, rest)| rest);
702 let body = body.split_once("\n## ").map_or(body, |(head, _)| head);
703 body.trim_end().lines().count()
704 }
705
706 #[test]
707 fn render_note_shows_inferred_confidence() {
708 let ex = Explanation {
709 schema: rto_graph::SCHEMA,
710 node: NodeSummary {
711 key: "file:a.md".into(),
712 kind: "file".into(),
713 name: "a.md".into(),
714 path: Some("a.md".into()),
715 lang: None,
716 },
717 meta: serde_json::Value::Null,
718 outgoing: vec![EdgeRef {
719 kind: "related".into(),
720 provenance: "inferred",
721 confidence: Some(0.82),
722 node: "file:b.md".into(),
723 }],
724 incoming: vec![],
725 };
726 let note = render_note(&ex, None, None);
727 assert!(
728 note.content
729 .contains("related (inferred) (0.82) → [[file-b.md]]"),
730 "{}",
731 note.content
732 );
733 }
734
735 #[test]
736 fn render_home_summarises_the_graph() {
737 let summary = VaultSummary {
738 project: "demo".into(),
739 total_nodes: 3,
740 total_edges: 2,
741 node_counts: vec![("fn".into(), 2), ("adr".into(), 1)],
742 edge_provenance: vec![("derived".into(), 1), ("authored".into(), 1)],
743 adrs: vec![AdrEntry {
744 key: "adr:0001".into(),
745 name: "First".into(),
746 status: Some("Accepted".into()),
747 }],
748 debt: vec![("todo".into(), 4)], densest_files: vec![DensityEntry {
750 path: "src/small.rs".into(),
751 markers: 3,
752 lines: 120,
753 per_kloc: 25.0,
754 }],
755 config_secrets: Some(ConfigSecretSummary {
756 secret_named: 4,
757 redacted: 3,
758 declared: 1,
759 unredacted: 0,
760 files: vec![".env".into()],
761 }),
762 most_called: vec![CouplingEntry {
763 key: "sym:rust:a.rs#helper".into(),
764 name: "helper".into(),
765 fan_in: 7,
766 fan_out: 1,
767 }],
768 repo_url: Some("https://github.com/org/repo".into()),
769 commit: Some("abcdef0123456789".into()),
770 };
771 let note = render_home(&summary);
772 assert_eq!(note.filename, HOME_NOTE);
773 assert!(note.content.contains("# demo — knowledge graph"));
774 assert!(note.content.contains("**3 nodes**, **2 edges**"));
775 assert!(note.content.contains("| fn | 2 |"));
776 assert!(note.content.contains("| derived | 1 |"));
777 assert!(note.content.contains("**Accepted** — [[adr-0001|First]]"));
778 assert!(note.content.contains("| todo | 4 |")); assert!(
782 note.content
783 .contains("| [[sym-rust-a.rs-helper\\|helper]] | 7 | 1 |"),
784 "{}",
785 note.content
786 );
787 assert!(
788 note.content.contains("resolved by simple name"),
789 "the precision caveat travels with the figures"
790 );
791 assert!(
795 note.content
796 .contains("| [[file-src-small.rs\\|src/small.rs]] | 3 | 120 | 25.00 |"),
797 "{}",
798 note.content
799 );
800 assert!(
801 note.content.contains("not source lines of code"),
802 "the denominator caveat travels with the figures"
803 );
804 assert!(
808 note.content.contains(
809 "**4** secret-named config key(s): 3 redacted before storage, 1 \
810 declared in code without a value, 0 unredacted."
811 ),
812 "{}",
813 note.content
814 );
815 assert!(
816 note.content.contains("- [[file-.env\\|.env]]"),
817 "{}",
818 note.content
819 );
820 assert!(
821 note.content.contains("not a secret scan")
822 && note.content.contains("cannot see a hardcoded credential"),
823 "the limitation travels with the figures: {}",
824 note.content
825 );
826 assert!(
827 !note.content.contains("[!warning]"),
828 "no warning when nothing is unredacted: {}",
829 note.content
830 );
831 assert!(
833 note.content
834 .contains("**Repository:** [https://github.com/org/repo](https://github.com/org/repo) · rendered at commit `abcdef012345`"),
835 "{}",
836 note.content
837 );
838 }
839
840 #[test]
841 fn render_home_omits_density_for_a_graph_with_no_markers() {
842 let note = render_home(&VaultSummary {
846 project: "clean".into(),
847 total_nodes: 1,
848 ..VaultSummary::default()
849 });
850 assert!(
851 !note.content.contains("Densest files"),
852 "no heading without rows: {}",
853 note.content
854 );
855 assert!(note.content.contains("## Intent debt"));
858 assert!(note.content.contains("*None recorded.*"));
859 }
860
861 #[test]
862 fn render_home_omits_config_secrets_rather_than_rendering_zeroes() {
863 let note = render_home(&VaultSummary {
867 project: "clean".into(),
868 total_nodes: 1,
869 ..VaultSummary::default()
870 });
871 assert!(
872 !note.content.contains("named like secrets"),
873 "no heading without figures: {}",
874 note.content
875 );
876 }
877
878 #[test]
879 fn render_home_warns_loudly_about_an_unredacted_value() {
880 let note = render_home(&VaultSummary {
884 project: "imported".into(),
885 total_nodes: 1,
886 config_secrets: Some(ConfigSecretSummary {
887 secret_named: 1,
888 redacted: 0,
889 declared: 0,
890 unredacted: 1,
891 files: vec!["imported.env".into()],
892 }),
893 ..VaultSummary::default()
894 });
895 assert!(
896 note.content.contains("[!warning]") && note.content.contains("**unredacted**"),
897 "{}",
898 note.content
899 );
900 assert!(
901 note.content.contains("came from an import layer"),
902 "and it points at the importing tool, not the repository: {}",
903 note.content
904 );
905 }
906
907 #[test]
908 fn render_home_omits_coupling_for_a_graph_with_no_calls() {
909 let note = render_home(&VaultSummary {
912 project: "docs".into(),
913 total_nodes: 1,
914 ..VaultSummary::default()
915 });
916 assert!(
917 !note.content.contains("Most depended-on"),
918 "no heading without rows: {}",
919 note.content
920 );
921 assert!(note.content.contains("# docs — knowledge graph"));
923 }
924}