1use std::fmt::Write as _;
13
14use rto_graph::Explanation;
15
16pub const HOME_NOTE: &str = "_Home.md";
18
19#[derive(Debug, Clone, PartialEq, Eq)]
21pub struct VaultNote {
22 pub filename: String,
24 pub content: String,
26}
27
28#[must_use]
35pub fn note_name(key: &str) -> String {
36 const MAX: usize = 200;
40 let mut out = String::with_capacity(key.len());
41 let mut prev_dash = false;
42 for c in key.chars() {
43 if c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-') {
44 out.push(c);
45 prev_dash = false;
46 } else if !prev_dash {
47 out.push('-');
48 prev_dash = true;
49 }
50 }
51 let out = out.trim_matches('-');
52 if out.len() <= MAX {
53 out.to_owned()
54 } else {
55 format!("{}-{:016x}", &out[..MAX - 17], fnv1a64(key.as_bytes()))
56 }
57}
58
59fn fnv1a64(bytes: &[u8]) -> u64 {
62 let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
63 for &b in bytes {
64 hash ^= u64::from(b);
65 hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
66 }
67 hash
68}
69
70#[must_use]
74pub fn render_note(ex: &Explanation) -> VaultNote {
75 let meta = &ex.meta;
76 let status = meta.get("status").and_then(|v| v.as_str());
77 let content = meta.get("content").and_then(|v| v.as_str());
78
79 let mut c = String::new();
80 c.push_str("---\n");
81 let _ = writeln!(c, "key: \"{}\"", ex.node.key.replace('"', "'"));
82 let _ = writeln!(c, "kind: {}", ex.node.kind);
83 if let Some(path) = &ex.node.path {
84 let _ = writeln!(c, "path: \"{path}\"");
85 }
86 if let Some(lang) = &ex.node.lang {
87 let _ = writeln!(c, "lang: {lang}");
88 }
89 if let Some(status) = status {
90 let _ = writeln!(c, "status: {status}");
91 }
92 c.push_str("tags:\n");
94 let _ = writeln!(c, " - roteiro/kind/{}", tag_slug(&ex.node.kind));
95 if let Some(lang) = &ex.node.lang {
96 let _ = writeln!(c, " - roteiro/lang/{}", tag_slug(lang));
97 }
98 if let Some(status) = status {
99 let _ = writeln!(c, " - roteiro/status/{}", tag_slug(status));
100 }
101 c.push_str("---\n\n");
102
103 let _ = writeln!(c, "# {}", ex.node.name);
104 if let Some(status) = status {
105 let _ = writeln!(c, "\n> **Status:** {status}");
106 }
107
108 if let Some(content) = content.map(str::trim).filter(|s| !s.is_empty()) {
110 c.push_str("\n## Content\n\n");
111 c.push_str(content);
112 c.push('\n');
113 }
114
115 if !ex.outgoing.is_empty() {
116 c.push_str("\n## Outgoing\n\n");
117 for e in &ex.outgoing {
118 let _ = writeln!(
119 c,
120 "- {} ({}){} → [[{}]]",
121 e.kind,
122 e.provenance,
123 confidence(e.confidence),
124 note_name(&e.node)
125 );
126 }
127 }
128 if !ex.incoming.is_empty() {
129 c.push_str("\n## Incoming\n\n");
130 for e in &ex.incoming {
131 let _ = writeln!(
132 c,
133 "- [[{}]] {} ({}){} →",
134 note_name(&e.node),
135 e.kind,
136 e.provenance,
137 confidence(e.confidence)
138 );
139 }
140 }
141
142 VaultNote {
143 filename: format!("{}.md", note_name(&ex.node.key)),
144 content: c,
145 }
146}
147
148fn confidence(c: Option<f64>) -> String {
150 c.map_or_else(String::new, |c| format!(" ({c:.2})"))
151}
152
153fn tag_slug(s: &str) -> String {
156 let mut out = String::with_capacity(s.len());
157 let mut prev_dash = false;
158 for ch in s.chars() {
159 if ch.is_ascii_alphanumeric() {
160 out.push(ch.to_ascii_lowercase());
161 prev_dash = false;
162 } else if !prev_dash {
163 out.push('-');
164 prev_dash = true;
165 }
166 }
167 out.trim_matches('-').to_owned()
168}
169
170#[derive(Debug, Clone)]
172pub struct AdrEntry {
173 pub key: String,
175 pub name: String,
177 pub status: Option<String>,
179}
180
181#[derive(Debug, Clone, Default)]
183pub struct VaultSummary {
184 pub project: String,
186 pub total_nodes: usize,
188 pub total_edges: usize,
190 pub node_counts: Vec<(String, usize)>,
192 pub edge_provenance: Vec<(String, usize)>,
194 pub adrs: Vec<AdrEntry>,
196 pub debt: Vec<(String, usize)>,
198}
199
200#[must_use]
204pub fn render_home(s: &VaultSummary) -> VaultNote {
205 let mut c = String::new();
206 c.push_str("---\ntags:\n - roteiro/home\n---\n\n");
207 let _ = writeln!(c, "# {} — knowledge graph", s.project);
208 c.push_str("\n*Generated by [Roteiro](https://roteiro.dev). Every fact is ");
209 c.push_str("provenance-tagged: `derived` (extracted), `authored` (intent), ");
210 c.push_str("or `inferred` (a scored suggestion).*\n");
211 let _ = writeln!(
212 c,
213 "\n**{} nodes**, **{} edges** across the scanned project.",
214 s.total_nodes, s.total_edges
215 );
216
217 c.push_str("\n## Structure\n\n| Kind | Count |\n| --- | --- |\n");
218 for (kind, n) in &s.node_counts {
219 let _ = writeln!(c, "| {kind} | {n} |");
220 }
221
222 if !s.edge_provenance.is_empty() {
223 c.push_str("\n## Provenance\n\n| Provenance | Edges |\n| --- | --- |\n");
224 for (prov, n) in &s.edge_provenance {
225 let _ = writeln!(c, "| {prov} | {n} |");
226 }
227 }
228
229 c.push_str("\n## Decisions (ADRs)\n\n");
230 if s.adrs.is_empty() {
231 c.push_str("*No ADRs found.*\n");
232 } else {
233 for adr in &s.adrs {
234 let status = adr.status.as_deref().unwrap_or("—");
235 let _ = writeln!(
236 c,
237 "- **{status}** — [[{}|{}]]",
238 note_name(&adr.key),
239 adr.name
240 );
241 }
242 }
243
244 c.push_str("\n## Intent debt\n\n");
245 if s.debt.is_empty() {
246 c.push_str("*None recorded.*\n");
247 } else {
248 c.push_str("| Category | Count |\n| --- | --- |\n");
249 for (cat, n) in &s.debt {
250 let _ = writeln!(c, "| {cat} | {n} |");
251 }
252 }
253
254 c.push_str(
255 "\n## Navigating this vault\n\n\
256 - Open the **graph view** to see the whole codebase; notes are coloured/\
257 filterable by their `roteiro/kind/*`, `roteiro/lang/*` and \
258 `roteiro/status/*` tags.\n\
259 - Each note carries its captured **content** (doc comments, prose, PDF/\
260 image text) and its provenance-labelled incoming/outgoing links.\n\
261 - Start from an ADR above, or search the tag pane for a kind.\n",
262 );
263
264 VaultNote {
265 filename: HOME_NOTE.to_owned(),
266 content: c,
267 }
268}
269
270#[cfg(test)]
271mod tests {
272 use super::{AdrEntry, HOME_NOTE, VaultSummary, note_name, render_home, render_note};
273 use rto_graph::{EdgeRef, Explanation, NodeSummary};
274
275 #[test]
276 fn note_name_is_safe_and_stable() {
277 assert_eq!(
278 note_name("sym:rust:src/a.rs#Store"),
279 "sym-rust-src-a.rs-Store"
280 );
281 assert_eq!(note_name("adr:0001"), "adr-0001");
282 assert_eq!(note_name("file:src/main.rs"), "file-src-main.rs");
283 }
284
285 #[test]
286 fn render_note_emits_frontmatter_and_wikilinks() {
287 let ex = Explanation {
288 schema: rto_graph::SCHEMA,
289 node: NodeSummary {
290 key: "sym:rust:a.rs#main".into(),
291 kind: "fn".into(),
292 name: "main".into(),
293 path: Some("a.rs".into()),
294 lang: Some("rust".into()),
295 },
296 meta: serde_json::Value::Null,
297 outgoing: vec![EdgeRef {
298 kind: "calls".into(),
299 provenance: "derived",
300 confidence: None,
301 node: "sym:rust:a.rs#helper".into(),
302 }],
303 incoming: vec![EdgeRef {
304 kind: "references".into(),
305 provenance: "authored",
306 confidence: None,
307 node: "adr:0001".into(),
308 }],
309 };
310 let note = render_note(&ex);
311 assert_eq!(note.filename, "sym-rust-a.rs-main.md");
312 assert!(note.content.contains("kind: fn"));
313 assert!(note.content.contains("# main"));
314 assert!(
315 note.content
316 .contains("- calls (derived) → [[sym-rust-a.rs-helper]]")
317 );
318 assert!(
319 note.content
320 .contains("- [[adr-0001]] references (authored) →")
321 );
322 assert!(note.content.contains("- roteiro/kind/fn"));
324 assert!(note.content.contains("- roteiro/lang/rust"));
325 }
326
327 #[test]
328 fn note_name_bounds_long_keys_deterministically() {
329 let long = format!("import:rust:{}", "a::b::c,".repeat(60));
330 let a = note_name(&long);
331 let b = note_name(&long);
332 assert_eq!(a, b, "deterministic");
333 assert!(
334 a.len() <= 205,
335 "bounded under the filename limit: {}",
336 a.len()
337 );
338 assert_ne!(
339 note_name(&format!("{long}x")),
340 a,
341 "different keys stay distinct after truncation"
342 );
343 }
344
345 #[test]
346 fn render_note_surfaces_content_and_status() {
347 let ex = Explanation {
348 schema: rto_graph::SCHEMA,
349 node: NodeSummary {
350 key: "adr:0001".into(),
351 kind: "adr".into(),
352 name: "Build Roteiro".into(),
353 path: Some("docs/adr/0001.md".into()),
354 lang: None,
355 },
356 meta: serde_json::json!({ "status": "Accepted", "content": "The decision text." }),
357 outgoing: vec![],
358 incoming: vec![],
359 };
360 let note = render_note(&ex);
361 assert!(note.content.contains("status: Accepted"));
362 assert!(note.content.contains("- roteiro/status/accepted"));
363 assert!(note.content.contains("> **Status:** Accepted"));
364 assert!(note.content.contains("## Content\n\nThe decision text."));
365 }
366
367 #[test]
368 fn render_note_shows_inferred_confidence() {
369 let ex = Explanation {
370 schema: rto_graph::SCHEMA,
371 node: NodeSummary {
372 key: "file:a.md".into(),
373 kind: "file".into(),
374 name: "a.md".into(),
375 path: Some("a.md".into()),
376 lang: None,
377 },
378 meta: serde_json::Value::Null,
379 outgoing: vec![EdgeRef {
380 kind: "related".into(),
381 provenance: "inferred",
382 confidence: Some(0.82),
383 node: "file:b.md".into(),
384 }],
385 incoming: vec![],
386 };
387 let note = render_note(&ex);
388 assert!(
389 note.content
390 .contains("related (inferred) (0.82) → [[file-b.md]]"),
391 "{}",
392 note.content
393 );
394 }
395
396 #[test]
397 fn render_home_summarises_the_graph() {
398 let summary = VaultSummary {
399 project: "demo".into(),
400 total_nodes: 3,
401 total_edges: 2,
402 node_counts: vec![("fn".into(), 2), ("adr".into(), 1)],
403 edge_provenance: vec![("derived".into(), 1), ("authored".into(), 1)],
404 adrs: vec![AdrEntry {
405 key: "adr:0001".into(),
406 name: "First".into(),
407 status: Some("Accepted".into()),
408 }],
409 debt: vec![("todo".into(), 4)],
410 };
411 let note = render_home(&summary);
412 assert_eq!(note.filename, HOME_NOTE);
413 assert!(note.content.contains("# demo — knowledge graph"));
414 assert!(note.content.contains("**3 nodes**, **2 edges**"));
415 assert!(note.content.contains("| fn | 2 |"));
416 assert!(note.content.contains("| derived | 1 |"));
417 assert!(note.content.contains("**Accepted** — [[adr-0001|First]]"));
418 assert!(note.content.contains("| todo | 4 |"));
419 }
420}