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
81fn yaml_double_quoted(value: &str) -> String {
109 let mut out = String::with_capacity(value.len() + 2);
110 out.push('"');
111 for ch in value.chars() {
112 match ch {
113 '\\' => out.push_str(r"\\"),
114 '"' => out.push_str("\\\""),
115 '\n' => out.push_str(r"\n"),
116 '\r' => out.push_str(r"\r"),
117 '\t' => out.push_str(r"\t"),
118 '\u{0}' => out.push_str(r"\0"),
119 '\u{7}' => out.push_str(r"\a"),
120 '\u{8}' => out.push_str(r"\b"),
121 '\u{b}' => out.push_str(r"\v"),
122 '\u{c}' => out.push_str(r"\f"),
123 '\u{1b}' => out.push_str(r"\e"),
124 c if (c < ' ')
127 || c == '\u{7f}'
128 || ('\u{80}'..='\u{9f}').contains(&c)
129 || matches!(c, '\u{2028}' | '\u{2029}' | '\u{feff}') =>
130 {
131 let _ = write!(out, "\\u{:04x}", c as u32);
132 }
133 c => out.push(c),
134 }
135 }
136 out.push('"');
137 out
138}
139
140fn yaml_scalar(value: &str) -> String {
157 if is_plain_safe(value) {
158 value.to_owned()
159 } else {
160 yaml_double_quoted(value)
161 }
162}
163
164fn is_plain_safe(value: &str) -> bool {
179 const NOT_STRINGS: [&str; 11] = [
180 "true", "false", "yes", "no", "on", "off", "null", "nil", "none", "y", "n",
181 ];
182 !value.is_empty()
183 && value.starts_with(|c: char| c.is_ascii_alphabetic())
184 && value
185 .chars()
186 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.' | '/'))
187 && !NOT_STRINGS.contains(&value.to_ascii_lowercase().as_str())
188}
189
190#[derive(Debug, Clone, Copy)]
206pub struct VaultScope<'a> {
207 pub project: Option<&'a str>,
212 pub members: &'a std::collections::BTreeSet<String>,
217}
218
219static NO_MEMBERS: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
222
223impl VaultScope<'_> {
224 pub const PROJECT: Self = Self {
228 project: None,
229 members: &NO_MEMBERS,
230 };
231}
232
233impl Default for VaultScope<'_> {
234 fn default() -> Self {
235 Self::PROJECT
236 }
237}
238
239impl VaultScope<'_> {
240 #[must_use]
248 pub fn redirects_external_ref(&self, key: &str) -> bool {
249 key.strip_prefix("extref:")
250 .and_then(rto_graph::parse_qualified)
251 .is_some_and(|(project, _)| self.members.contains(project))
252 }
253}
254
255#[must_use]
263pub fn scoped_note_name(scope: &VaultScope<'_>, key: &str) -> String {
264 match scope.project {
265 None => note_name(key),
266 Some(project) => note_name(&format!("{project}::{key}")),
267 }
268}
269
270fn link_target(scope: &VaultScope<'_>, key: &str) -> String {
285 if scope.redirects_external_ref(key) {
286 return note_name(key.strip_prefix("extref:").unwrap_or(key));
291 }
292 scoped_note_name(scope, key)
293}
294
295#[must_use]
308pub fn render_note(ex: &Explanation, source_base: Option<&str>, body: Option<&str>) -> VaultNote {
309 render_note_scoped(ex, source_base, body, &VaultScope::PROJECT)
310}
311
312#[must_use]
320pub fn render_note_scoped(
321 ex: &Explanation,
322 source_base: Option<&str>,
323 body: Option<&str>,
324 scope: &VaultScope<'_>,
325) -> VaultNote {
326 let meta = &ex.meta;
327 let status = meta.get("status").and_then(|v| v.as_str());
328 let content = note_body(meta.get("content").and_then(|v| v.as_str()), body);
329
330 let mut c = String::new();
331 c.push_str("---\n");
332 let _ = writeln!(c, "key: {}", yaml_double_quoted(&ex.node.key));
333 let _ = writeln!(c, "kind: {}", yaml_scalar(ex.node.kind.as_str()));
334 if let Some(project) = scope.project {
338 let _ = writeln!(c, "project: {}", yaml_double_quoted(project));
339 }
340 if let Some(path) = &ex.node.path {
341 let _ = writeln!(c, "path: {}", yaml_double_quoted(path));
342 }
343 if let Some(lang) = &ex.node.lang {
344 let _ = writeln!(c, "lang: {}", yaml_scalar(lang));
345 }
346 if let Some(status) = status {
347 let _ = writeln!(c, "status: {}", yaml_scalar(status));
348 }
349 c.push_str("tags:\n");
351 let _ = writeln!(c, " - roteiro/kind/{}", tag_slug(&ex.node.kind));
352 if let Some(project) = scope.project {
355 let _ = writeln!(c, " - roteiro/project/{}", tag_slug(project));
356 }
357 if let Some(lang) = &ex.node.lang {
358 let _ = writeln!(c, " - roteiro/lang/{}", tag_slug(lang));
359 }
360 if let Some(status) = status {
361 let _ = writeln!(c, " - roteiro/status/{}", tag_slug(status));
362 }
363 c.push_str("---\n\n");
364
365 let _ = writeln!(c, "# {}", ex.node.name);
366 if let Some(status) = status {
367 let _ = writeln!(c, "\n> **Status:** {status}");
368 }
369
370 if let (Some(base), Some(path)) = (source_base, ex.node.path.as_deref()) {
373 let _ = writeln!(
374 c,
375 "\n**Source:** [`{path}`]({}/{path})",
376 base.trim_end_matches('/')
377 );
378 }
379
380 if let Some(content) = content.map(str::trim).filter(|s| !s.is_empty()) {
383 c.push_str("\n## Content\n\n");
384 c.push_str(content);
385 c.push('\n');
386 }
387
388 if !ex.outgoing.is_empty() {
389 c.push_str("\n## Outgoing\n\n");
390 for e in &ex.outgoing {
391 let _ = writeln!(
392 c,
393 "- {} ({}){} → [[{}]]",
394 e.kind,
395 e.provenance,
396 confidence(e.confidence),
397 link_target(scope, &e.node)
398 );
399 }
400 }
401 if !ex.incoming.is_empty() {
402 c.push_str("\n## Incoming\n\n");
403 for e in &ex.incoming {
404 let _ = writeln!(
405 c,
406 "- [[{}]] {} ({}){} →",
407 link_target(scope, &e.node),
408 e.kind,
409 e.provenance,
410 confidence(e.confidence)
411 );
412 }
413 }
414
415 VaultNote {
416 filename: format!("{}.md", scoped_note_name(scope, &ex.node.key)),
417 content: c,
418 }
419}
420
421fn note_body<'a>(content: Option<&'a str>, body: Option<&'a str>) -> Option<&'a str> {
433 body.or(content)
434}
435
436fn confidence(c: Option<f64>) -> String {
438 c.map_or_else(String::new, |c| format!(" ({c:.2})"))
439}
440
441fn tag_slug(s: &str) -> String {
444 let mut out = String::with_capacity(s.len());
445 let mut prev_dash = false;
446 for ch in s.chars() {
447 if ch.is_ascii_alphanumeric() {
448 out.push(ch.to_ascii_lowercase());
449 prev_dash = false;
450 } else if !prev_dash {
451 out.push('-');
452 prev_dash = true;
453 }
454 }
455 out.trim_matches('-').to_owned()
456}
457
458#[derive(Debug, Clone)]
460pub struct AdrEntry {
461 pub key: String,
463 pub name: String,
465 pub status: Option<String>,
467}
468
469#[derive(Debug, Clone, Default)]
476pub struct ConfigSecretSummary {
477 pub secret_named: usize,
479 pub redacted: usize,
481 pub declared: usize,
483 pub unredacted: usize,
485 pub files: Vec<String>,
488}
489
490#[derive(Debug, Clone)]
492pub struct DensityEntry {
493 pub path: String,
495 pub markers: u32,
497 pub lines: u32,
499 pub per_kloc: f64,
501}
502
503#[derive(Debug, Clone)]
505pub struct CouplingEntry {
506 pub key: String,
508 pub name: String,
510 pub fan_in: u32,
512 pub fan_out: u32,
514}
515
516#[derive(Debug, Clone, Default)]
518pub struct VaultSummary {
519 pub project: String,
521 pub total_nodes: usize,
523 pub total_edges: usize,
525 pub node_counts: Vec<(String, usize)>,
527 pub edge_provenance: Vec<(String, usize)>,
529 pub adrs: Vec<AdrEntry>,
531 pub debt: Vec<(String, usize)>,
533 pub densest_files: Vec<DensityEntry>,
537 pub config_secrets: Option<ConfigSecretSummary>,
542 pub most_called: Vec<CouplingEntry>,
545 pub repo_url: Option<String>,
548 pub commit: Option<String>,
550}
551
552#[must_use]
556pub fn render_home(s: &VaultSummary) -> VaultNote {
557 let mut c = String::new();
558 c.push_str("---\ntags:\n - roteiro/home\n---\n\n");
559 let _ = writeln!(c, "# {} — knowledge graph", s.project);
560 c.push_str(
561 "\n*A browsable snapshot of this codebase as one **knowledge graph**, \
562 generated by [Roteiro](https://roteiro.dev). Every symbol, document and \
563 decision is a note, linked to the things it relates to.*\n",
564 );
565 c.push_str(HOW_TO_READ);
566 let _ = writeln!(
567 c,
568 "\n**{} nodes**, **{} edges** across the project.",
569 s.total_nodes, s.total_edges
570 );
571 write_repo_line(&mut c, s);
572 write_summary_sections(&mut c, s, &VaultScope::PROJECT, 2);
573 c.push_str(NAVIGATING);
574
575 VaultNote {
576 filename: HOME_NOTE.to_owned(),
577 content: c,
578 }
579}
580
581const HOW_TO_READ: &str = "\n**How to read it.** Open any note to see what a thing is, the intent or \
585 docs behind it (its **Content**), where it lives (its **Source** link), \
586 and how it connects (**Outgoing**/**Incoming** links). Each link is \
587 labelled with how the fact was established — `derived` (extracted from \
588 code), `authored` (human intent: ADRs, blueprints, annotations), or \
589 `inferred` (a scored suggestion). Open Obsidian's **graph view** to see \
590 the whole thing at once.\n";
591
592const NAVIGATING: &str = "\n## Navigating this vault\n\n\
594 - Open the **graph view** to see the whole codebase; notes are coloured/\
595 filterable by their `roteiro/kind/*`, `roteiro/lang/*` and \
596 `roteiro/status/*` tags.\n\
597 - Each note carries its captured **content** (doc comments, prose, PDF/\
598 image text) and its provenance-labelled incoming/outgoing links.\n\
599 - Start from an ADR above, or search the tag pane for a kind.\n";
600
601fn write_repo_line(c: &mut String, s: &VaultSummary) {
603 if let Some(repo) = &s.repo_url {
604 let _ = write!(c, "\n**Repository:** [{repo}]({repo})");
605 if let Some(commit) = &s.commit {
606 let short = &commit[..commit.len().min(12)];
607 let _ = write!(c, " · rendered at commit `{short}`");
608 }
609 c.push('\n');
610 }
611}
612
613fn write_summary_sections(c: &mut String, s: &VaultSummary, scope: &VaultScope<'_>, level: usize) {
624 let hd = &"#".repeat(level);
625 let sub = &"#".repeat(level + 1);
626 write_structure(c, s, hd);
627 write_decisions(c, s, scope, hd);
628 write_debt(c, s, scope, hd, sub);
629 write_config_secrets(c, s, scope, hd);
630 write_coupling(c, s, scope, hd);
631}
632
633fn write_structure(c: &mut String, s: &VaultSummary, hd: &str) {
635 let _ = write!(c, "\n{hd} Structure\n\n| Kind | Count |\n| --- | --- |\n");
636 for (kind, n) in &s.node_counts {
637 let _ = writeln!(c, "| {kind} | {n} |");
638 }
639
640 if !s.edge_provenance.is_empty() {
641 let _ = write!(
642 c,
643 "\n{hd} Provenance\n\n| Provenance | Edges |\n| --- | --- |\n"
644 );
645 for (prov, n) in &s.edge_provenance {
646 let _ = writeln!(c, "| {prov} | {n} |");
647 }
648 }
649}
650
651fn write_decisions(c: &mut String, s: &VaultSummary, scope: &VaultScope<'_>, hd: &str) {
653 let _ = write!(c, "\n{hd} Decisions (ADRs)\n\n");
654 if s.adrs.is_empty() {
655 c.push_str("*No ADRs found.*\n");
656 } else {
657 for adr in &s.adrs {
658 let status = adr.status.as_deref().unwrap_or("—");
659 let _ = writeln!(
660 c,
661 "- **{status}** — [[{}|{}]]",
662 scoped_note_name(scope, &adr.key),
663 adr.name
664 );
665 }
666 }
667}
668
669fn write_debt(c: &mut String, s: &VaultSummary, scope: &VaultScope<'_>, hd: &str, sub: &str) {
671 let _ = write!(c, "\n{hd} Intent debt\n\n");
672 if s.debt.is_empty() {
673 c.push_str("*None recorded.*\n");
674 } else {
675 c.push_str("| Category | Count |\n| --- | --- |\n");
676 for (cat, n) in &s.debt {
677 let _ = writeln!(c, "| {cat} | {n} |");
678 }
679 }
680
681 if !s.densest_files.is_empty() {
682 let _ = write!(
683 c,
684 "\n{sub} Densest files (markers per 1,000 lines)\n\n\
685 *Where the debt above is concentrated, rather than where there is \
686 most of it — a raw count ranks the biggest file first by \
687 construction. The denominator is file length: every line, blanks and \
688 comments included, not source lines of code. Prose matches (`for \
689 now`, `tbd`) count too, so a design document can rank high.*\n\n"
690 );
691 c.push_str("| File | Markers | Lines | Per 1k |\n| --- | --- | --- | --- |\n");
692 for e in &s.densest_files {
693 let _ = writeln!(
694 c,
695 "| [[{}\\|{}]] | {} | {} | {:.2} |",
696 scoped_note_name(scope, &format!("file:{}", e.path)),
697 e.path,
698 e.markers,
699 e.lines,
700 e.per_kloc
701 );
702 }
703 }
704}
705
706fn write_config_secrets(c: &mut String, s: &VaultSummary, scope: &VaultScope<'_>, hd: &str) {
708 if let Some(cs) = &s.config_secrets {
709 let _ = write!(c, "\n{hd} Config keys named like secrets\n\n");
710 let _ = writeln!(
711 c,
712 "**{}** secret-named config key(s): {} redacted before storage, {} \
713 declared in code without a value, {} unredacted.",
714 cs.secret_named, cs.redacted, cs.declared, cs.unredacted
715 );
716 if cs.unredacted > 0 {
717 let _ = writeln!(
718 c,
719 "\n> [!warning] {} key(s) carry an **unredacted** value. Extraction \
720 always redacts, so these came from an import layer — inspect the \
721 importing tool, not this repository.",
722 cs.unredacted
723 );
724 }
725 if !cs.files.is_empty() {
726 c.push_str("\nIn:\n");
727 for path in &cs.files {
728 let _ = writeln!(
729 c,
730 "- [[{}\\|{path}]]",
731 scoped_note_name(scope, &format!("file:{path}"))
732 );
733 }
734 }
735 c.push_str(
740 "\n*An inventory of config keys whose **names** look secret, not a secret \
741 scan. Values are redacted before they are stored, so this reports that \
742 such keys exist and were redacted — never a value. It cannot see a \
743 hardcoded credential in source code, cannot judge whether a value is \
744 valid, and cannot tell a real secret from a placeholder. A credential \
745 under an innocuous key name (`dsn`, `endpoint`) does not appear here at \
746 all, so this section being small says nothing about whether this \
747 repository leaks secrets.*\n",
748 );
749 }
750}
751
752fn write_coupling(c: &mut String, s: &VaultSummary, scope: &VaultScope<'_>, hd: &str) {
754 if !s.most_called.is_empty() {
755 let _ = write!(
756 c,
757 "\n{hd} Most depended-on (call fan-in)\n\n\
758 *Distinct callers and callees over `calls` edges — direction kept, so \
759 \"everything calls this\" and \"this calls everything\" are not the same \
760 row. Call targets are resolved by simple name, so a short, generically-\
761 named function can absorb every call to that name: read a large fan-in on \
762 one as a question, not a finding.*\n\n"
763 );
764 c.push_str("| Symbol | Called by | Calls |\n| --- | --- | --- |\n");
765 for e in &s.most_called {
766 let _ = writeln!(
767 c,
768 "| [[{}\\|{}]] | {} | {} |",
769 scoped_note_name(scope, &e.key),
770 e.name,
771 e.fan_in,
772 e.fan_out
773 );
774 }
775 }
776}
777
778#[derive(Debug, Clone)]
785pub struct CrossLink {
786 pub from_project: String,
788 pub from_key: String,
790 pub from_name: String,
792 pub kind: String,
794 pub confidence: Option<f64>,
796 pub to_qualified: String,
798 pub resolves: bool,
802}
803
804#[derive(Debug, Clone, Default)]
808pub struct WorkspaceSummary {
809 pub name: String,
811 pub members: Vec<VaultSummary>,
814 pub cross_links: Vec<CrossLink>,
816 pub cross_links_total: usize,
819}
820
821#[must_use]
831pub fn render_workspace_home(ws: &WorkspaceSummary) -> VaultNote {
832 let members: std::collections::BTreeSet<String> =
833 ws.members.iter().map(|m| m.project.clone()).collect();
834
835 let mut c = String::new();
836 c.push_str("---\ntags:\n - roteiro/home\n - roteiro/workspace\n---\n\n");
837 let _ = writeln!(c, "# {} — workspace knowledge graph", ws.name);
838 c.push_str(
839 "\n*A browsable snapshot of a whole **workspace** as one **knowledge \
840 graph**, generated by [Roteiro](https://roteiro.dev). Every symbol, \
841 document and decision in every member repository is a note, linked to the \
842 things it relates to — including across repositories.*\n",
843 );
844 c.push_str(HOW_TO_READ);
845 c.push_str(
846 "\n**Notes are named `<project>-<key>`**, because a node key is \
847 repository-relative: every member has a `README.md`, and without the \
848 project each would overwrite the last. Filter the graph view by a \
849 member's `roteiro/project/*` tag to see one repository at a time.\n",
850 );
851
852 let total_nodes: usize = ws.members.iter().map(|m| m.total_nodes).sum();
853 let total_edges: usize = ws.members.iter().map(|m| m.total_edges).sum();
854 let _ = writeln!(
855 c,
856 "\n**{total_nodes} nodes**, **{total_edges} edges** across **{}** member \
857 repositor{}.",
858 ws.members.len(),
859 if ws.members.len() == 1 { "y" } else { "ies" }
860 );
861
862 c.push_str("\n## Members\n\n| Project | Nodes | Edges | Repository | Commit |\n| --- | --- | --- | --- | --- |\n");
863 for m in &ws.members {
864 let repo = m
865 .repo_url
866 .as_ref()
867 .map_or_else(|| "—".to_owned(), |u| format!("[{u}]({u})"));
868 let commit = m.commit.as_ref().map_or_else(
869 || "—".to_owned(),
870 |c| format!("`{}`", &c[..c.len().min(12)]),
871 );
872 let _ = writeln!(
873 c,
874 "| [[#{}\\|{}]] | {} | {} | {repo} | {commit} |",
875 m.project, m.project, m.total_nodes, m.total_edges
876 );
877 }
878 c.push_str(
879 "\n*The `Repository` and `Commit` columns say where each member came from \
880 and what was read. They are **not** a replication manifest — reconstructing \
881 a workspace from a vault is issue #442 part 2, and nothing here is designed \
882 to be handed to someone else.*\n",
883 );
884
885 write_cross_links(&mut c, ws);
886
887 for m in &ws.members {
888 let _ = writeln!(c, "\n## {}", m.project);
889 let _ = writeln!(
890 c,
891 "\n**{} nodes**, **{} edges** in this member.",
892 m.total_nodes, m.total_edges
893 );
894 write_repo_line(&mut c, m);
895 let scope = VaultScope {
896 project: Some(&m.project),
897 members: &members,
898 };
899 write_summary_sections(&mut c, m, &scope, 3);
900 }
901
902 c.push_str(NAVIGATING);
903
904 VaultNote {
905 filename: HOME_NOTE.to_owned(),
906 content: c,
907 }
908}
909
910fn write_cross_links(c: &mut String, ws: &WorkspaceSummary) {
913 c.push_str("\n## Cross-repo links\n\n");
914 if ws.cross_links.is_empty() {
915 c.push_str(
916 "*None. These are the `inferred` cross-repo links `roteiro links \
917 --infer --write` persists (ADR-0009); a workspace whose members have \
918 never been inferred over has none recorded yet.*\n",
919 );
920 return;
921 }
922 c.push_str(
923 "*A spoke's config key and the hub key it corresponds to, across \
924 repositories — the one thing a per-project vault structurally cannot show. \
925 These are `inferred` matches persisted by `roteiro links --infer --write` \
926 (ADR-0009), not authored facts: read a row as a candidate correspondence.*\n\n",
927 );
928 c.push_str("| From | | To | Kind |\n| --- | --- | --- | --- |\n");
929 for l in &ws.cross_links {
930 let from_scope = VaultScope {
931 project: Some(&l.from_project),
932 members: &NO_MEMBERS,
933 };
934 let to = if l.resolves {
935 format!("[[{}\\|{}]]", note_name(&l.to_qualified), l.to_qualified)
936 } else {
937 format!("`{}` *(outside this workspace)*", l.to_qualified)
941 };
942 let _ = writeln!(
943 c,
944 "| [[{}\\|{}]] | {} | {to} | {}{} |",
945 scoped_note_name(&from_scope, &l.from_key),
946 l.from_name,
947 l.from_project,
948 l.kind,
949 confidence(l.confidence)
950 );
951 }
952 if ws.cross_links_total > ws.cross_links.len() {
953 let _ = writeln!(
954 c,
955 "\n*Showing {} of {} — the full report is `roteiro links --matrix`.*",
956 ws.cross_links.len(),
957 ws.cross_links_total
958 );
959 }
960 c.push_str(
961 "\n*Shown in one direction only. The edge lives in the spoke's store, \
962 pointing at a local placeholder for the hub's node, so the hub's own note \
963 carries no matching **Incoming** entry — Obsidian's **Backlinks** pane \
964 still shows it, because the link is in the vault.*\n",
965 );
966}
967
968#[cfg(test)]
969mod tests {
970 use super::{
971 AdrEntry, ConfigSecretSummary, CouplingEntry, CrossLink, DensityEntry, HOME_NOTE,
972 VaultScope, VaultSummary, WorkspaceSummary, note_name, render_home, render_note,
973 render_note_scoped, render_workspace_home, scoped_note_name,
974 };
975 use rto_graph::{EdgeRef, Explanation, NodeSummary};
976
977 #[test]
978 fn note_name_is_safe_and_stable() {
979 assert_eq!(
980 note_name("sym:rust:src/a.rs#Store"),
981 "sym-rust-src-a.rs-Store"
982 );
983 assert_eq!(note_name("adr:0001"), "adr-0001");
984 assert_eq!(note_name("file:src/main.rs"), "file-src-main.rs");
985 }
986
987 #[test]
988 fn render_note_emits_frontmatter_and_wikilinks() {
989 let ex = Explanation {
990 schema: rto_graph::SCHEMA,
991 node: NodeSummary {
992 key: "sym:rust:a.rs#main".into(),
993 kind: "fn".into(),
994 name: "main".into(),
995 path: Some("a.rs".into()),
996 lang: Some("rust".into()),
997 },
998 meta: serde_json::Value::Null,
999 outgoing: vec![EdgeRef {
1000 kind: "calls".into(),
1001 provenance: "derived",
1002 confidence: None,
1003 node: "sym:rust:a.rs#helper".into(),
1004 }],
1005 incoming: vec![EdgeRef {
1006 kind: "references".into(),
1007 provenance: "authored",
1008 confidence: None,
1009 node: "adr:0001".into(),
1010 }],
1011 };
1012 let note = render_note(&ex, None, None);
1013 assert_eq!(note.filename, "sym-rust-a.rs-main.md");
1014 assert!(note.content.contains("kind: fn"));
1015 assert!(!note.content.contains("**Source:**"));
1017 assert!(note.content.contains("# main"));
1018 assert!(
1019 note.content
1020 .contains("- calls (derived) → [[sym-rust-a.rs-helper]]")
1021 );
1022 assert!(
1023 note.content
1024 .contains("- [[adr-0001]] references (authored) →")
1025 );
1026 assert!(note.content.contains("- roteiro/kind/fn"));
1028 assert!(note.content.contains("- roteiro/lang/rust"));
1029 }
1030
1031 #[test]
1032 fn note_name_bounds_long_keys_deterministically() {
1033 let long = format!("import:rust:{}", "a::b::c,".repeat(60));
1034 let a = note_name(&long);
1035 let b = note_name(&long);
1036 assert_eq!(a, b, "deterministic");
1037 assert!(
1038 a.len() <= 205,
1039 "bounded under the filename limit: {}",
1040 a.len()
1041 );
1042 assert_ne!(
1043 note_name(&format!("{long}x")),
1044 a,
1045 "different keys stay distinct after truncation"
1046 );
1047 }
1048
1049 #[test]
1050 fn render_note_surfaces_content_and_status() {
1051 let ex = Explanation {
1052 schema: rto_graph::SCHEMA,
1053 node: NodeSummary {
1054 key: "adr:0001".into(),
1055 kind: "adr".into(),
1056 name: "Build Roteiro".into(),
1057 path: Some("docs/adr/0001.md".into()),
1058 lang: None,
1059 },
1060 meta: serde_json::json!({ "status": "Accepted", "content": "The decision text." }),
1061 outgoing: vec![],
1062 incoming: vec![],
1063 };
1064 let note = render_note(&ex, Some("https://github.com/org/repo/blob/abc123"), None);
1065 assert!(note.content.contains("status: Accepted"));
1066 assert!(note.content.contains("- roteiro/status/accepted"));
1067 assert!(note.content.contains("> **Status:** Accepted"));
1068 assert!(note.content.contains("## Content\n\nThe decision text."));
1069 assert!(
1071 note.content.contains(
1072 "**Source:** [`docs/adr/0001.md`](https://github.com/org/repo/blob/abc123/docs/adr/0001.md)"
1073 ),
1074 "{}",
1075 note.content
1076 );
1077 }
1078
1079 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";
1082
1083 fn prose_note(content: Option<&str>) -> Explanation {
1084 Explanation {
1085 schema: rto_graph::SCHEMA,
1086 node: NodeSummary {
1087 key: "file:docs/OFFLINE_SETUP.md".into(),
1088 kind: "file".into(),
1089 name: "OFFLINE_SETUP.md".into(),
1090 path: Some("docs/OFFLINE_SETUP.md".into()),
1091 lang: None,
1092 },
1093 meta: content.map_or(
1094 serde_json::Value::Null,
1095 |c| serde_json::json!({ "content": c }),
1096 ),
1097 outgoing: vec![],
1098 incoming: vec![],
1099 }
1100 }
1101
1102 #[test]
1111 fn a_supplied_body_supersedes_the_collapsed_stored_content() {
1112 let collapsed = DOC.split_whitespace().collect::<Vec<_>>().join(" ");
1114 let ex = prose_note(Some(&collapsed));
1115
1116 let note = render_note(&ex, None, Some(DOC));
1117 assert!(
1118 note.content.contains(DOC.trim()),
1119 "the source document is reproduced verbatim: {}",
1120 note.content
1121 );
1122 assert!(
1123 !note.content.contains(&collapsed),
1124 "the collapsed rendering is replaced, not appended: {}",
1125 note.content
1126 );
1127 assert!(
1128 note.content.contains("\n| Host | What |\n"),
1129 "a table needs its own lines to be a table: {}",
1130 note.content
1131 );
1132 assert!(
1133 note.content.contains("\n```sh\n"),
1134 "a fenced block needs its own lines to be a fence: {}",
1135 note.content
1136 );
1137
1138 let flat = render_note(&ex, None, None);
1140 assert!(
1141 flat.content.contains(&collapsed),
1142 "without a body the stored content is still shown: {}",
1143 flat.content
1144 );
1145 assert!(
1146 content_lines(¬e.content) > content_lines(&flat.content),
1147 "structure restored: {} line(s) with a body vs {} without",
1148 content_lines(¬e.content),
1149 content_lines(&flat.content)
1150 );
1151 assert_eq!(
1152 content_lines(&flat.content),
1153 1,
1154 "the defect: the stored content is a single line"
1155 );
1156 }
1157
1158 #[test]
1162 fn a_note_with_no_body_is_unchanged() {
1163 let ex = Explanation {
1164 schema: rto_graph::SCHEMA,
1165 node: NodeSummary {
1166 key: "sym:rust:a.rs#main".into(),
1167 kind: "fn".into(),
1168 name: "main".into(),
1169 path: Some("a.rs".into()),
1170 lang: Some("rust".into()),
1171 },
1172 meta: serde_json::json!({ "content": "Entry point." }),
1173 outgoing: vec![],
1174 incoming: vec![],
1175 };
1176 assert!(
1177 render_note(&ex, None, None)
1178 .content
1179 .contains("## Content\n\nEntry point.")
1180 );
1181 }
1182
1183 fn content_lines(note: &str) -> usize {
1185 let body = note
1186 .split_once("## Content\n\n")
1187 .map_or("", |(_, rest)| rest);
1188 let body = body.split_once("\n## ").map_or(body, |(head, _)| head);
1189 body.trim_end().lines().count()
1190 }
1191
1192 #[test]
1193 fn render_note_shows_inferred_confidence() {
1194 let ex = Explanation {
1195 schema: rto_graph::SCHEMA,
1196 node: NodeSummary {
1197 key: "file:a.md".into(),
1198 kind: "file".into(),
1199 name: "a.md".into(),
1200 path: Some("a.md".into()),
1201 lang: None,
1202 },
1203 meta: serde_json::Value::Null,
1204 outgoing: vec![EdgeRef {
1205 kind: "related".into(),
1206 provenance: "inferred",
1207 confidence: Some(0.82),
1208 node: "file:b.md".into(),
1209 }],
1210 incoming: vec![],
1211 };
1212 let note = render_note(&ex, None, None);
1213 assert!(
1214 note.content
1215 .contains("related (inferred) (0.82) → [[file-b.md]]"),
1216 "{}",
1217 note.content
1218 );
1219 }
1220
1221 #[test]
1222 fn render_home_summarises_the_graph() {
1223 let summary = VaultSummary {
1224 project: "demo".into(),
1225 total_nodes: 3,
1226 total_edges: 2,
1227 node_counts: vec![("fn".into(), 2), ("adr".into(), 1)],
1228 edge_provenance: vec![("derived".into(), 1), ("authored".into(), 1)],
1229 adrs: vec![AdrEntry {
1230 key: "adr:0001".into(),
1231 name: "First".into(),
1232 status: Some("Accepted".into()),
1233 }],
1234 debt: vec![("todo".into(), 4)], densest_files: vec![DensityEntry {
1236 path: "src/small.rs".into(),
1237 markers: 3,
1238 lines: 120,
1239 per_kloc: 25.0,
1240 }],
1241 config_secrets: Some(ConfigSecretSummary {
1242 secret_named: 4,
1243 redacted: 3,
1244 declared: 1,
1245 unredacted: 0,
1246 files: vec![".env".into()],
1247 }),
1248 most_called: vec![CouplingEntry {
1249 key: "sym:rust:a.rs#helper".into(),
1250 name: "helper".into(),
1251 fan_in: 7,
1252 fan_out: 1,
1253 }],
1254 repo_url: Some("https://github.com/org/repo".into()),
1255 commit: Some("abcdef0123456789".into()),
1256 };
1257 let note = render_home(&summary);
1258 assert_eq!(note.filename, HOME_NOTE);
1259 assert!(note.content.contains("# demo — knowledge graph"));
1260 assert!(note.content.contains("**3 nodes**, **2 edges**"));
1261 assert!(note.content.contains("| fn | 2 |"));
1262 assert!(note.content.contains("| derived | 1 |"));
1263 assert!(note.content.contains("**Accepted** — [[adr-0001|First]]"));
1264 assert!(note.content.contains("| todo | 4 |")); assert!(
1268 note.content
1269 .contains("| [[sym-rust-a.rs-helper\\|helper]] | 7 | 1 |"),
1270 "{}",
1271 note.content
1272 );
1273 assert!(
1274 note.content.contains("resolved by simple name"),
1275 "the precision caveat travels with the figures"
1276 );
1277 assert!(
1281 note.content
1282 .contains("| [[file-src-small.rs\\|src/small.rs]] | 3 | 120 | 25.00 |"),
1283 "{}",
1284 note.content
1285 );
1286 assert!(
1287 note.content.contains("not source lines of code"),
1288 "the denominator caveat travels with the figures"
1289 );
1290 assert!(
1294 note.content.contains(
1295 "**4** secret-named config key(s): 3 redacted before storage, 1 \
1296 declared in code without a value, 0 unredacted."
1297 ),
1298 "{}",
1299 note.content
1300 );
1301 assert!(
1302 note.content.contains("- [[file-.env\\|.env]]"),
1303 "{}",
1304 note.content
1305 );
1306 assert!(
1307 note.content.contains("not a secret scan")
1308 && note.content.contains("cannot see a hardcoded credential"),
1309 "the limitation travels with the figures: {}",
1310 note.content
1311 );
1312 assert!(
1313 !note.content.contains("[!warning]"),
1314 "no warning when nothing is unredacted: {}",
1315 note.content
1316 );
1317 assert!(
1319 note.content
1320 .contains("**Repository:** [https://github.com/org/repo](https://github.com/org/repo) · rendered at commit `abcdef012345`"),
1321 "{}",
1322 note.content
1323 );
1324 }
1325
1326 #[test]
1327 fn render_home_omits_density_for_a_graph_with_no_markers() {
1328 let note = render_home(&VaultSummary {
1332 project: "clean".into(),
1333 total_nodes: 1,
1334 ..VaultSummary::default()
1335 });
1336 assert!(
1337 !note.content.contains("Densest files"),
1338 "no heading without rows: {}",
1339 note.content
1340 );
1341 assert!(note.content.contains("## Intent debt"));
1344 assert!(note.content.contains("*None recorded.*"));
1345 }
1346
1347 #[test]
1348 fn render_home_omits_config_secrets_rather_than_rendering_zeroes() {
1349 let note = render_home(&VaultSummary {
1353 project: "clean".into(),
1354 total_nodes: 1,
1355 ..VaultSummary::default()
1356 });
1357 assert!(
1358 !note.content.contains("named like secrets"),
1359 "no heading without figures: {}",
1360 note.content
1361 );
1362 }
1363
1364 #[test]
1365 fn render_home_warns_loudly_about_an_unredacted_value() {
1366 let note = render_home(&VaultSummary {
1370 project: "imported".into(),
1371 total_nodes: 1,
1372 config_secrets: Some(ConfigSecretSummary {
1373 secret_named: 1,
1374 redacted: 0,
1375 declared: 0,
1376 unredacted: 1,
1377 files: vec!["imported.env".into()],
1378 }),
1379 ..VaultSummary::default()
1380 });
1381 assert!(
1382 note.content.contains("[!warning]") && note.content.contains("**unredacted**"),
1383 "{}",
1384 note.content
1385 );
1386 assert!(
1387 note.content.contains("came from an import layer"),
1388 "and it points at the importing tool, not the repository: {}",
1389 note.content
1390 );
1391 }
1392
1393 #[test]
1394 fn render_home_omits_coupling_for_a_graph_with_no_calls() {
1395 let note = render_home(&VaultSummary {
1398 project: "docs".into(),
1399 total_nodes: 1,
1400 ..VaultSummary::default()
1401 });
1402 assert!(
1403 !note.content.contains("Most depended-on"),
1404 "no heading without rows: {}",
1405 note.content
1406 );
1407 assert!(note.content.contains("# docs — knowledge graph"));
1409 }
1410
1411 fn node_linking_to(key: &str, name: &str, to: &str) -> Explanation {
1415 Explanation {
1416 schema: rto_graph::SCHEMA,
1417 node: NodeSummary {
1418 key: key.into(),
1419 kind: "config_key".into(),
1420 name: name.into(),
1421 path: Some("config.toml".into()),
1422 lang: None,
1423 },
1424 meta: serde_json::Value::Null,
1425 outgoing: vec![EdgeRef {
1426 kind: "links".into(),
1427 provenance: "inferred",
1428 confidence: Some(0.91),
1429 node: to.into(),
1430 }],
1431 incoming: vec![],
1432 }
1433 }
1434
1435 fn members(names: &[&str]) -> std::collections::BTreeSet<String> {
1436 names.iter().map(|s| (*s).to_owned()).collect()
1437 }
1438
1439 #[test]
1440 fn a_project_scope_leaves_every_note_name_exactly_as_it_was() {
1441 for key in [
1446 "file:README.md",
1447 "adr:0001",
1448 "sym:rust:src/a.rs#Store",
1449 "extref:other::file:README.md",
1450 "cfgkey:config.toml#serve.addr",
1451 ] {
1452 assert_eq!(
1453 scoped_note_name(&VaultScope::PROJECT, key),
1454 note_name(key),
1455 "single-project name moved for `{key}`"
1456 );
1457 }
1458 }
1459
1460 #[test]
1461 fn render_note_is_the_project_scoped_render_byte_for_byte() {
1462 let ex = node_linking_to("cfgkey:config.toml#addr", "addr", "sym:rust:a.rs#A");
1463 assert_eq!(
1464 render_note(&ex, Some("https://h/b"), Some("body")),
1465 render_note_scoped(&ex, Some("https://h/b"), Some("body"), &VaultScope::PROJECT),
1466 "the unscoped entry point must stay the scoped one at PROJECT, so the \
1467 two cannot drift apart"
1468 );
1469 }
1470
1471 #[test]
1472 fn each_member_gets_its_own_note_for_the_same_key() {
1473 let ms = members(&["api", "sdk"]);
1476 let names: Vec<String> = ["api", "sdk"]
1477 .iter()
1478 .map(|p| {
1479 scoped_note_name(
1480 &VaultScope {
1481 project: Some(p),
1482 members: &ms,
1483 },
1484 "file:README.md",
1485 )
1486 })
1487 .collect();
1488 assert_eq!(names, ["api-file-README.md", "sdk-file-README.md"]);
1489 assert_ne!(names[0], names[1], "two members must not share one note");
1490 }
1491
1492 #[test]
1504 fn the_qualified_key_and_the_note_name_are_different_strings() {
1505 let ms = members(&["app"]);
1506 let scope = VaultScope {
1507 project: Some("app"),
1508 members: &ms,
1509 };
1510 let qualified = "app::file:README.md";
1513 assert_eq!(
1515 scoped_note_name(&scope, "file:README.md"),
1516 "app-file-README.md"
1517 );
1518 assert_eq!(note_name(qualified), "app-file-README.md");
1519 assert!(
1520 !scoped_note_name(&scope, "file:README.md").contains("::"),
1521 "no note name ever contains `::`"
1522 );
1523 let note = render_note_scoped(
1526 &node_with("file:README.md", Some("README.md"), None),
1527 None,
1528 None,
1529 &scope,
1530 );
1531 assert_eq!(note.filename, "app-file-README.md.md");
1532 }
1533
1534 #[test]
1535 fn a_member_note_declares_which_member_it_came_from() {
1536 let ms = members(&["api"]);
1537 let ex = node_linking_to("cfgkey:config.toml#addr", "addr", "sym:rust:a.rs#A");
1538 let note = render_note_scoped(
1539 &ex,
1540 None,
1541 None,
1542 &VaultScope {
1543 project: Some("api"),
1544 members: &ms,
1545 },
1546 );
1547 assert_eq!(note.filename, "api-cfgkey-config.toml-addr.md");
1548 assert!(
1549 note.content.contains("project: \"api\""),
1550 "{}",
1551 note.content
1552 );
1553 assert!(
1554 note.content.contains("- roteiro/project/api"),
1555 "the tag is what filters the graph view to one repository: {}",
1556 note.content
1557 );
1558 assert!(
1560 note.content.contains("→ [[api-sym-rust-a.rs-A]]"),
1561 "{}",
1562 note.content
1563 );
1564 }
1565
1566 #[test]
1567 fn a_project_note_declares_no_project() {
1568 let ex = node_linking_to("cfgkey:config.toml#addr", "addr", "sym:rust:a.rs#A");
1569 let note = render_note(&ex, None, None);
1570 assert!(!note.content.contains("project:"), "{}", note.content);
1571 assert!(
1572 !note.content.contains("roteiro/project/"),
1573 "a per-project vault would carry one constant on every note — and \
1574 adding it would change every note's bytes: {}",
1575 note.content
1576 );
1577 }
1578
1579 #[test]
1580 fn a_cross_repo_edge_links_straight_to_the_other_members_note() {
1581 let ms = members(&["spoke", "hub"]);
1586 let scope = VaultScope {
1587 project: Some("spoke"),
1588 members: &ms,
1589 };
1590 let ex = node_linking_to(
1591 "cfgkey:config.toml#addr",
1592 "addr",
1593 &rto_graph::external_ref_key("hub::cfgkey:config.toml#addr"),
1594 );
1595 let note = render_note_scoped(&ex, None, None, &scope);
1596 assert!(
1597 note.content.contains("→ [[hub-cfgkey-config.toml-addr]]"),
1598 "the edge must land on the hub's own note: {}",
1599 note.content
1600 );
1601 assert!(
1602 !note.content.contains("extref"),
1603 "and never on the placeholder: {}",
1604 note.content
1605 );
1606 assert!(
1609 scope.redirects_external_ref(&rto_graph::external_ref_key(
1610 "hub::cfgkey:config.toml#addr"
1611 ))
1612 );
1613 }
1614
1615 #[test]
1616 fn a_cross_repo_edge_out_of_the_workspace_keeps_its_placeholder() {
1617 let ms = members(&["spoke"]);
1622 let scope = VaultScope {
1623 project: Some("spoke"),
1624 members: &ms,
1625 };
1626 let key = rto_graph::external_ref_key("elsewhere::cfgkey:config.toml#addr");
1627 assert!(!scope.redirects_external_ref(&key));
1628 let ex = node_linking_to("cfgkey:config.toml#addr", "addr", &key);
1629 let note = render_note_scoped(&ex, None, None, &scope);
1630 assert!(
1631 note.content
1632 .contains("→ [[spoke-extref-elsewhere-cfgkey-config.toml-addr]]"),
1633 "{}",
1634 note.content
1635 );
1636 }
1637
1638 #[test]
1639 fn a_single_project_vault_never_redirects_an_external_ref() {
1640 let key = rto_graph::external_ref_key("hub::cfgkey:config.toml#addr");
1643 assert!(!VaultScope::PROJECT.redirects_external_ref(&key));
1644 assert_eq!(
1645 scoped_note_name(&VaultScope::PROJECT, &key),
1646 note_name(&key)
1647 );
1648 }
1649
1650 fn member_summary(project: &str, fan_in: u32) -> VaultSummary {
1651 VaultSummary {
1652 project: project.to_owned(),
1653 total_nodes: 3,
1654 total_edges: 2,
1655 node_counts: vec![("fn".into(), 2)],
1656 edge_provenance: vec![("derived".into(), 2)],
1657 adrs: vec![AdrEntry {
1658 key: "adr:0001".into(),
1659 name: "First".into(),
1660 status: Some("Accepted".into()),
1661 }],
1662 debt: vec![("todo".into(), 4)], densest_files: vec![DensityEntry {
1664 path: "src/small.rs".into(),
1665 markers: 3,
1666 lines: 120,
1667 per_kloc: 25.0,
1668 }],
1669 config_secrets: None,
1670 most_called: vec![CouplingEntry {
1671 key: "sym:rust:a.rs#helper".into(),
1672 name: "helper".into(),
1673 fan_in,
1674 fan_out: 1,
1675 }],
1676 repo_url: Some(format!("https://github.com/org/{project}")),
1677 commit: Some("abcdef0123456789".into()),
1678 }
1679 }
1680
1681 #[test]
1682 fn the_workspace_home_keeps_every_members_own_aggregates() {
1683 let ws = WorkspaceSummary {
1688 name: "platform".into(),
1689 members: vec![member_summary("api", 7), member_summary("sdk", 4)],
1690 cross_links: vec![],
1691 cross_links_total: 0,
1692 };
1693 let note = render_workspace_home(&ws);
1694 assert_eq!(note.filename, HOME_NOTE);
1695 assert!(
1696 note.content
1697 .contains("# platform — workspace knowledge graph")
1698 );
1699 assert!(
1701 note.content
1702 .contains("**6 nodes**, **4 edges** across **2** member")
1703 );
1704 assert!(note.content.contains("| [[#api\\|api]] | 3 | 2 |"));
1705
1706 for project in ["api", "sdk"] {
1707 assert!(
1708 note.content.contains(&format!("\n## {project}\n")),
1709 "each member gets its own section"
1710 );
1711 }
1712 for section in [
1714 "### Structure",
1715 "### Provenance",
1716 "### Decisions (ADRs)",
1717 "### Intent debt",
1718 "#### Densest files",
1719 "### Most depended-on",
1720 ] {
1721 assert_eq!(
1722 note.content.matches(section).count(),
1723 2,
1724 "`{section}` must appear once per member: {}",
1725 note.content
1726 );
1727 }
1728 assert!(
1730 note.content
1731 .contains("**Accepted** — [[api-adr-0001|First]]")
1732 );
1733 assert!(
1734 note.content
1735 .contains("**Accepted** — [[sdk-adr-0001|First]]")
1736 );
1737 assert!(
1738 note.content
1739 .contains("[[api-sym-rust-a.rs-helper\\|helper]] | 7 |")
1740 );
1741 assert!(
1742 note.content
1743 .contains("[[sdk-file-src-small.rs\\|src/small.rs]]")
1744 );
1745 }
1746
1747 #[test]
1748 fn the_workspace_home_renders_cross_repo_links_and_marks_the_ones_it_cannot_follow() {
1749 let ws = WorkspaceSummary {
1750 name: "platform".into(),
1751 members: vec![member_summary("api", 7), member_summary("sdk", 4)],
1752 cross_links: vec![
1753 CrossLink {
1754 from_project: "sdk".into(),
1755 from_key: "cfgkey:config.toml#addr".into(),
1756 from_name: "addr".into(),
1757 kind: "links".into(),
1758 confidence: Some(0.91),
1759 to_qualified: "api::cfgkey:config.toml#addr".into(),
1760 resolves: true,
1761 },
1762 CrossLink {
1763 from_project: "sdk".into(),
1764 from_key: "cfgkey:config.toml#other".into(),
1765 from_name: "other".into(),
1766 kind: "links".into(),
1767 confidence: None,
1768 to_qualified: "absent::cfgkey:config.toml#other".into(),
1769 resolves: false,
1770 },
1771 ],
1772 cross_links_total: 2,
1773 };
1774 let note = render_workspace_home(&ws);
1775 assert!(
1777 note.content.contains(
1778 "| [[sdk-cfgkey-config.toml-addr\\|addr]] | sdk | \
1779 [[api-cfgkey-config.toml-addr\\|api::cfgkey:config.toml#addr]] | links (0.91) |"
1780 ),
1781 "{}",
1782 note.content
1783 );
1784 assert!(
1787 note.content
1788 .contains("`absent::cfgkey:config.toml#other` *(outside this workspace)*"),
1789 "{}",
1790 note.content
1791 );
1792 assert!(
1793 !note.content.contains("[[absent-"),
1794 "a dangling wikilink would read as a note someone forgot to write: {}",
1795 note.content
1796 );
1797 }
1798
1799 #[test]
1800 fn the_workspace_home_says_when_it_has_truncated_the_cross_links() {
1801 let ws = WorkspaceSummary {
1803 name: "platform".into(),
1804 members: vec![member_summary("api", 7)],
1805 cross_links: vec![CrossLink {
1806 from_project: "api".into(),
1807 from_key: "cfgkey:config.toml#addr".into(),
1808 from_name: "addr".into(),
1809 kind: "links".into(),
1810 confidence: None,
1811 to_qualified: "api::cfgkey:config.toml#addr".into(),
1812 resolves: true,
1813 }],
1814 cross_links_total: 40,
1815 };
1816 let note = render_workspace_home(&ws);
1817 assert!(note.content.contains("Showing 1 of 40"), "{}", note.content);
1818 assert!(note.content.contains("roteiro links --matrix"));
1819 }
1820
1821 #[test]
1822 fn a_workspace_with_no_cross_repo_links_says_why_rather_than_showing_nothing() {
1823 let ws = WorkspaceSummary {
1824 name: "platform".into(),
1825 members: vec![member_summary("api", 7)],
1826 cross_links: vec![],
1827 cross_links_total: 0,
1828 };
1829 let note = render_workspace_home(&ws);
1830 assert!(note.content.contains("## Cross-repo links"));
1831 assert!(
1832 note.content.contains("links --infer --write"),
1833 "an empty section must name what would fill it, or it reads as \
1834 \"these repos are unrelated\": {}",
1835 note.content
1836 );
1837 assert!(note.content.contains("**1** member repository."));
1840 }
1841
1842 fn frontmatter_field(note: &str, field: &str) -> Result<Option<String>, String> {
1852 let block = note
1853 .strip_prefix("---\n")
1854 .and_then(|rest| rest.split_once("\n---\n"))
1855 .map(|(block, _)| block)
1856 .expect("note must open with a frontmatter block");
1857 let docs = yaml_rust2::YamlLoader::load_from_str(block).map_err(|e| e.to_string())?;
1858 Ok(docs[0][field].as_str().map(ToOwned::to_owned))
1859 }
1860
1861 fn node_with(key: &str, path: Option<&str>, lang: Option<&str>) -> Explanation {
1863 Explanation {
1864 schema: rto_graph::SCHEMA,
1865 node: NodeSummary {
1866 key: key.into(),
1867 kind: "fn".into(),
1868 name: "n".into(),
1869 path: path.map(ToOwned::to_owned),
1870 lang: lang.map(ToOwned::to_owned),
1871 },
1872 meta: serde_json::Value::Null,
1873 outgoing: vec![],
1874 incoming: vec![],
1875 }
1876 }
1877
1878 #[test]
1886 fn a_backslash_or_quote_in_a_path_still_parses_back_to_itself() {
1887 for path in [
1888 r"foo\bar", r"foo\dir", "say\"hi\".rs", r"a\\b",
1892 "trailing-backslash\\",
1893 ] {
1894 let note = render_note(&node_with("file:x", Some(path), None), None, None);
1895 assert_eq!(
1896 frontmatter_field(¬e.content, "path"),
1897 Ok(Some(path.to_owned())),
1898 "path {path:?} must round-trip"
1899 );
1900 }
1901 }
1902
1903 #[test]
1906 fn a_node_key_round_trips_whatever_punctuation_it_carries() {
1907 for key in [
1908 "sym:rust:src/a.rs#Store",
1909 r"sym:rust:src\weird.rs#Thing",
1910 "sym:rust:a.rs#say\"hi\"",
1911 "cfgkey:config.toml#serve.addr",
1912 ] {
1913 let note = render_note(&node_with(key, None, None), None, None);
1914 assert_eq!(
1915 frontmatter_field(¬e.content, "key"),
1916 Ok(Some(key.to_owned())),
1917 "key {key:?} must round-trip"
1918 );
1919 }
1920 let note = render_note(
1923 &node_with("sym:rust:a.rs#say\"hi\"", None, None),
1924 None,
1925 None,
1926 );
1927 assert!(
1928 !note.content.contains("say'hi'"),
1929 "a quotation mark must be escaped, not rewritten: {}",
1930 note.content
1931 );
1932 }
1933
1934 #[test]
1936 fn a_member_project_name_round_trips() {
1937 let ms: std::collections::BTreeSet<String> =
1938 std::iter::once(r"odd\name".to_owned()).collect();
1939 let note = render_note_scoped(
1940 &node_with("file:x", None, None),
1941 None,
1942 None,
1943 &VaultScope {
1944 project: Some(r"odd\name"),
1945 members: &ms,
1946 },
1947 );
1948 assert_eq!(
1949 frontmatter_field(¬e.content, "project"),
1950 Ok(Some(r"odd\name".to_owned()))
1951 );
1952 }
1953
1954 #[test]
1959 fn a_bare_field_is_quoted_only_when_being_bare_would_change_it() {
1960 let with_status = |status: &str| {
1961 let mut ex = node_with("adr:0001", None, None);
1962 ex.meta = serde_json::json!({ "status": status });
1963 render_note(&ex, None, None)
1964 };
1965
1966 for status in [
1968 "Accepted: superseded by 0012",
1969 "Accepted # pending",
1970 "{draft}",
1971 "",
1972 ] {
1973 let note = with_status(status);
1974 assert_eq!(
1975 frontmatter_field(¬e.content, "status"),
1976 Ok(Some(status.to_owned())),
1977 "status {status:?} must round-trip"
1978 );
1979 }
1980
1981 let note = with_status("Accepted");
1984 assert!(
1985 note.content.contains("\nstatus: Accepted\n"),
1986 "a plain-safe status must not gain quotes: {}",
1987 note.content
1988 );
1989 }
1990
1991 #[test]
2003 fn a_language_that_spells_a_yaml_boolean_is_quoted() {
2004 let note = render_note(&node_with("file:x", None, Some("no")), None, None);
2005 assert!(
2006 note.content.contains("\nlang: \"no\"\n"),
2007 "a bare `no` is `false` to a 1.1 parser and must be quoted: {}",
2008 note.content
2009 );
2010 assert_eq!(
2011 frontmatter_field(¬e.content, "lang"),
2012 Ok(Some("no".to_owned())),
2013 "and it must still read back as the string: {}",
2014 note.content
2015 );
2016 let rust = render_note(&node_with("file:x", None, Some("rust")), None, None);
2018 assert!(rust.content.contains("\nlang: rust\n"), "{}", rust.content);
2019 }
2020
2021 #[test]
2023 fn control_characters_cannot_break_out_of_the_block() {
2024 for path in [
2025 "a\nb",
2026 "a\tb",
2027 "a\u{0}b",
2028 "a\u{2028}b",
2029 "a\u{7f}b",
2030 "a\u{85}b",
2031 ] {
2032 let note = render_note(&node_with("file:x", Some(path), None), None, None);
2033 assert_eq!(
2034 frontmatter_field(¬e.content, "path"),
2035 Ok(Some(path.to_owned())),
2036 "path {path:?} must round-trip"
2037 );
2038 assert_eq!(
2040 note.content.matches("\npath: ").count(),
2041 1,
2042 "the value must stay on one line: {}",
2043 note.content
2044 );
2045 }
2046 }
2047
2048 #[test]
2052 fn an_ordinary_value_is_emitted_exactly_as_before() {
2053 let note = render_note(
2054 &node_with("sym:rust:src/a.rs#Store", Some("src/a.rs"), Some("rust")),
2055 None,
2056 None,
2057 );
2058 assert!(
2059 note.content
2060 .contains("\nkey: \"sym:rust:src/a.rs#Store\"\n")
2061 );
2062 assert!(note.content.contains("\nkind: fn\n"));
2063 assert!(note.content.contains("\npath: \"src/a.rs\"\n"));
2064 assert!(note.content.contains("\nlang: rust\n"));
2065 }
2066
2067 #[test]
2071 fn the_plain_style_decision_agrees_with_a_real_yaml_parser() {
2072 for value in [
2073 "fn",
2074 "config_key",
2075 "rust",
2076 "Accepted",
2077 "a.b",
2078 "a/b",
2079 "a-b_c",
2080 "no",
2081 "yes",
2082 "true",
2083 "null",
2084 "y",
2085 "N",
2086 "",
2087 " lead",
2088 "trail ",
2089 "a: b",
2090 "a #c",
2091 "{x}",
2092 "[x]",
2093 "*x",
2094 "&x",
2095 "!x",
2096 "#x",
2097 ">x",
2098 "|x",
2099 "%x",
2100 "@x",
2101 "`x",
2102 "\"x",
2103 "'x",
2104 ",x",
2105 "123",
2106 "1.5",
2107 "-x",
2108 ".x",
2109 "a\\b",
2110 ] {
2111 let emitted = super::yaml_scalar(value);
2112 let doc = format!("v: {emitted}");
2113 let parsed = yaml_rust2::YamlLoader::load_from_str(&doc)
2114 .unwrap_or_else(|e| panic!("{value:?} emitted {emitted:?}: {e}"));
2115 assert_eq!(
2116 parsed[0]["v"].as_str(),
2117 Some(value),
2118 "{value:?} emitted as {emitted:?} did not round-trip"
2119 );
2120 }
2121 }
2122}