1use crate::types::{Edge, GraphData, Node};
2use std::collections::HashMap;
3
4pub struct GodNodeEntry {
5 pub label: String,
6 pub degree: usize,
7}
8
9pub struct SurprisingConnectionEntry {
10 pub source: String,
11 pub target: String,
12 pub relation: String,
13 pub confidence: String,
14 pub confidence_score: Option<f64>,
15 pub source_files: Vec<String>,
16 pub note: String,
17}
18
19pub struct DetectionResult {
20 pub total_files: usize,
21 pub total_words: usize,
22 pub warning: Option<String>,
23}
24
25pub struct TokenCost {
26 pub input: usize,
27 pub output: usize,
28}
29
30pub struct SuggestedQuestion {
31 pub question: Option<String>,
32 pub why: String,
33 pub question_type: Option<String>,
34}
35
36fn fmt_thousands(n: usize) -> String {
37 let s = n.to_string();
38 let mut out = String::new();
39 for (i, c) in s.chars().rev().enumerate() {
40 if i > 0 && i % 3 == 0 {
41 out.push(',');
42 }
43 out.push(c);
44 }
45 out.chars().rev().collect()
46}
47
48fn safe_community_name(label: &str) -> String {
49 let cleaned: String = label
50 .replace("\r\n", " ")
51 .replace(['\r', '\n'], " ")
52 .chars()
53 .filter(|c| !r#"\/*?:"<>|#^[]"#.contains(*c))
54 .collect::<String>()
55 .trim()
56 .to_string();
57 let re_ext = regex::Regex::new(r"(?i)\.(md|mdx|markdown)$").unwrap();
58 let cleaned = re_ext.replace(&cleaned, "").to_string();
59 if cleaned.is_empty() {
60 "unnamed".to_string()
61 } else {
62 cleaned
63 }
64}
65
66fn is_file_node(node: &Node) -> bool {
67 node.label.ends_with("()") || node.label.starts_with('.')
68}
69
70#[allow(clippy::too_many_arguments)]
71pub fn generate(
72 graph: &GraphData,
73 communities: &HashMap<usize, Vec<String>>,
74 cohesion_scores: &HashMap<usize, f64>,
75 community_labels: &HashMap<usize, String>,
76 god_node_list: &[GodNodeEntry],
77 surprise_list: &[SurprisingConnectionEntry],
78 detection_result: &DetectionResult,
79 token_cost: &TokenCost,
80 root: &str,
81 suggested_questions: Option<&[SuggestedQuestion]>,
82 min_community_size: usize,
83 built_at_commit: Option<&str>,
84) -> String {
85 let today = chrono::Local::now().format("%Y-%m-%d").to_string();
86
87 let node_map: HashMap<&str, &Node> = graph.nodes.iter().map(|n| (n.id.as_str(), n)).collect();
88
89 let confidences: Vec<&str> = graph.edges.iter().map(|e| e.confidence.as_str()).collect();
90 let total = confidences.len().max(1);
91 let ext_pct = (confidences.iter().filter(|&&c| c == "EXTRACTED").count() * 100) / total;
92 let inf_pct = (confidences.iter().filter(|&&c| c == "INFERRED").count() * 100) / total;
93 let amb_pct = (confidences.iter().filter(|&&c| c == "AMBIGUOUS").count() * 100) / total;
94
95 let inf_edges: Vec<&Edge> = graph
96 .edges
97 .iter()
98 .filter(|e| e.confidence == "INFERRED")
99 .collect();
100
101 let thin_count = communities
102 .values()
103 .filter(|nodes| {
104 let real = nodes
105 .iter()
106 .filter(|id| {
107 node_map
108 .get(id.as_str())
109 .map(|n| !is_file_node(n))
110 .unwrap_or(true)
111 })
112 .count();
113 real > 0 && real < min_community_size
114 })
115 .count();
116
117 let non_empty: Vec<usize> = communities
118 .keys()
119 .copied()
120 .filter(|cid| {
121 communities[cid].iter().any(|id| {
122 node_map
123 .get(id.as_str())
124 .map(|n| !is_file_node(n))
125 .unwrap_or(true)
126 })
127 })
128 .collect();
129
130 let shown_count = non_empty.len() - thin_count;
131
132 let mut lines: Vec<String> = vec![
133 format!("# Graph Report - {} ({})", root, today),
134 String::new(),
135 "## Corpus Check".to_string(),
136 ];
137
138 if let Some(warn) = &detection_result.warning {
139 lines.push(format!("- {}", warn));
140 } else {
141 lines.push(format!(
142 "- {} files · ~{} words",
143 detection_result.total_files,
144 fmt_thousands(detection_result.total_words)
145 ));
146 lines
147 .push("- Verdict: corpus is large enough that graph structure adds value.".to_string());
148 }
149
150 let summary_suffix = if thin_count > 0 {
151 format!(" ({} shown, {} thin omitted)", shown_count, thin_count)
152 } else {
153 String::new()
154 };
155
156 lines.push(String::new());
157 lines.push("## Summary".to_string());
158 lines.push(format!(
159 "- {} nodes · {} edges · {} communities{}",
160 graph.nodes.len(),
161 graph.edges.len(),
162 communities.len(),
163 summary_suffix
164 ));
165
166 let inf_avg_part = if !inf_edges.is_empty() {
167 format!(" · INFERRED: {} edges", inf_edges.len())
168 } else {
169 String::new()
170 };
171 lines.push(format!(
172 "- Extraction: {}% EXTRACTED · {}% INFERRED · {}% AMBIGUOUS{}",
173 ext_pct, inf_pct, amb_pct, inf_avg_part
174 ));
175 lines.push(format!(
176 "- Token cost: {} input · {} output",
177 fmt_thousands(token_cost.input),
178 fmt_thousands(token_cost.output)
179 ));
180
181 if let Some(commit) = built_at_commit {
182 lines.push(String::new());
183 lines.push("## Graph Freshness".to_string());
184 lines.push(format!(
185 "- Built from commit: `{}`",
186 &commit[..commit.len().min(8)]
187 ));
188 lines.push(
189 "- Run `git rev-parse HEAD` and compare to check if the graph is stale.".to_string(),
190 );
191 lines.push("- Run `codesynapse update .` after code changes (no API cost).".to_string());
192 }
193
194 if !non_empty.is_empty() {
195 lines.push(String::new());
196 lines.push("## Community Hubs (Navigation)".to_string());
197 let mut sorted_cids: Vec<usize> = non_empty.clone();
198 sorted_cids.sort();
199 for cid in &sorted_cids {
200 let label = community_labels
201 .get(cid)
202 .map(|s| s.as_str())
203 .unwrap_or("unnamed");
204 let safe = safe_community_name(label);
205 lines.push(format!("- [[_COMMUNITY_{}|{}]]", safe, label));
206 }
207 }
208
209 lines.push(String::new());
210 lines.push("## God Nodes (most connected - your core abstractions)".to_string());
211 for (i, node) in god_node_list.iter().enumerate() {
212 lines.push(format!(
213 "{}. `{}` - {} edges",
214 i + 1,
215 node.label,
216 node.degree
217 ));
218 }
219
220 lines.push(String::new());
221 lines.push("## Surprising Connections (you probably didn't know these)".to_string());
222 if surprise_list.is_empty() {
223 lines.push(
224 "- None detected - all connections are within the same source files.".to_string(),
225 );
226 } else {
227 for s in surprise_list {
228 let conf_tag = if s.confidence == "INFERRED" {
229 if let Some(score) = s.confidence_score {
230 format!("INFERRED {:.2}", score)
231 } else {
232 s.confidence.clone()
233 }
234 } else {
235 s.confidence.clone()
236 };
237 let sem_tag = if s.relation == "semantically_similar_to" {
238 " [semantically similar]"
239 } else {
240 ""
241 };
242 lines.push(format!(
243 "- `{}` --{}--> `{}` [{}]{}",
244 s.source, s.relation, s.target, conf_tag, sem_tag
245 ));
246 let f0 = s.source_files.first().map(|s| s.as_str()).unwrap_or("");
247 let f1 = s.source_files.get(1).map(|s| s.as_str()).unwrap_or("");
248 let note_part = if s.note.is_empty() {
249 String::new()
250 } else {
251 format!(" _{}_", s.note)
252 };
253 lines.push(format!(" {} → {}{}", f0, f1, note_part));
254 }
255 }
256
257 if let Some(hyperedges) = &graph.hyperedges {
258 if !hyperedges.is_empty() {
259 lines.push(String::new());
260 lines.push("## Hyperedges (group relationships)".to_string());
261 for h in hyperedges {
262 let node_labels = h.members.join(", ");
263 lines.push(format!("- **{}** — {}", h.label, node_labels));
264 }
265 }
266 }
267
268 lines.push(String::new());
269 lines.push(format!(
270 "## Communities ({} total, {} thin omitted)",
271 communities.len(),
272 thin_count
273 ));
274
275 let mut sorted_cids: Vec<usize> = communities.keys().copied().collect();
276 sorted_cids.sort();
277 for cid in &sorted_cids {
278 let nodes = &communities[cid];
279 let label = community_labels
280 .get(cid)
281 .map(|s| s.as_str())
282 .unwrap_or("unnamed");
283 let score = cohesion_scores.get(cid).copied().unwrap_or(0.0);
284 let real_nodes: Vec<&str> = nodes
285 .iter()
286 .filter(|id| {
287 node_map
288 .get(id.as_str())
289 .map(|n| !is_file_node(n))
290 .unwrap_or(true)
291 })
292 .map(|s| s.as_str())
293 .collect();
294 if real_nodes.is_empty() || real_nodes.len() < min_community_size {
295 continue;
296 }
297 let display: Vec<String> = real_nodes[..real_nodes.len().min(8)]
298 .iter()
299 .map(|id| {
300 node_map
301 .get(*id)
302 .map(|n| n.label.as_str())
303 .unwrap_or(id)
304 .to_string()
305 })
306 .collect();
307 let suffix = if real_nodes.len() > 8 {
308 format!(" (+{} more)", real_nodes.len() - 8)
309 } else {
310 String::new()
311 };
312 lines.push(String::new());
313 lines.push(format!("### Community {} - \"{}\"", cid, label));
314 lines.push(format!("Cohesion: {:.2}", score));
315 lines.push(format!(
316 "Nodes ({}): {}{}",
317 real_nodes.len(),
318 display.join(", "),
319 suffix
320 ));
321 }
322
323 let ambiguous: Vec<&Edge> = graph
324 .edges
325 .iter()
326 .filter(|e| e.confidence == "AMBIGUOUS")
327 .collect();
328 if !ambiguous.is_empty() {
329 lines.push(String::new());
330 lines.push("## Ambiguous Edges - Review These".to_string());
331 for e in &ambiguous {
332 let ul = node_map
333 .get(e.source.as_str())
334 .map(|n| n.label.as_str())
335 .unwrap_or(&e.source);
336 let vl = node_map
337 .get(e.target.as_str())
338 .map(|n| n.label.as_str())
339 .unwrap_or(&e.target);
340 lines.push(format!("- `{}` → `{}` [AMBIGUOUS]", ul, vl));
341 lines.push(format!(
342 " {} · relation: {}",
343 e.source_file.as_deref().unwrap_or(""),
344 e.relation
345 ));
346 }
347 }
348
349 let degree_map: HashMap<&str, usize> = {
350 let mut m: HashMap<&str, usize> = HashMap::new();
351 for e in &graph.edges {
352 *m.entry(e.source.as_str()).or_insert(0) += 1;
353 *m.entry(e.target.as_str()).or_insert(0) += 1;
354 }
355 m
356 };
357 let isolated: Vec<&Node> = graph
358 .nodes
359 .iter()
360 .filter(|n| {
361 *degree_map.get(n.id.as_str()).unwrap_or(&0) <= 1
362 && !is_file_node(n)
363 && n.file_type != "rationale"
364 })
365 .collect();
366 let thin_communities_count = communities
367 .values()
368 .filter(|nodes| {
369 let cnt = nodes
370 .iter()
371 .filter(|id| {
372 node_map
373 .get(id.as_str())
374 .map(|n| !is_file_node(n))
375 .unwrap_or(true)
376 })
377 .count();
378 cnt > 0 && cnt < 3
379 })
380 .count();
381 let gap_count = isolated.len() + thin_communities_count;
382
383 if gap_count > 0 || amb_pct > 20 {
384 lines.push(String::new());
385 lines.push("## Knowledge Gaps".to_string());
386 if !isolated.is_empty() {
387 let isolated_labels: Vec<String> = isolated[..isolated.len().min(5)]
388 .iter()
389 .map(|n| format!("`{}`", n.label))
390 .collect();
391 let suffix = if isolated.len() > 5 {
392 format!(" (+{} more)", isolated.len() - 5)
393 } else {
394 String::new()
395 };
396 lines.push(format!(
397 "- **{} isolated node(s):** {}{}",
398 isolated.len(),
399 isolated_labels.join(", "),
400 suffix
401 ));
402 lines.push(
403 " These have ≤1 connection - possible missing edges or undocumented components."
404 .to_string(),
405 );
406 }
407 if thin_communities_count > 0 {
408 lines.push(format!(
409 "- **{} thin communities (<{} nodes) omitted from report** — run `codesynapse query` to explore isolated nodes.",
410 thin_communities_count, min_community_size
411 ));
412 }
413 if amb_pct > 20 {
414 lines.push(format!(
415 "- **High ambiguity: {}% of edges are AMBIGUOUS.** Review the Ambiguous Edges section above.",
416 amb_pct
417 ));
418 }
419 }
420
421 if let Some(questions) = suggested_questions {
422 if !questions.is_empty() {
423 lines.push(String::new());
424 lines.push("## Suggested Questions".to_string());
425 let no_signal = questions.len() == 1
426 && questions[0].question_type.as_deref().unwrap_or("") == "no_signal";
427 if no_signal {
428 lines.push(format!("_{}_", questions[0].why));
429 } else {
430 lines.push("_Questions this graph is uniquely positioned to answer:_".to_string());
431 lines.push(String::new());
432 for q in questions {
433 if let Some(question) = &q.question {
434 lines.push(format!("- **{}**", question));
435 lines.push(format!(" _{}_", q.why));
436 }
437 }
438 }
439 }
440 }
441
442 lines.join("\n")
443}
444
445#[cfg(test)]
446mod tests {
447 use super::*;
448 use crate::types::{Edge, Node};
449 use std::collections::HashMap;
450
451 fn make_node(id: &str, label: &str) -> Node {
452 Node {
453 id: id.into(),
454 label: label.into(),
455 file_type: "code".into(),
456 source_file: "src.py".into(),
457 source_location: None,
458 community: None,
459 rationale: None,
460 docstring: None,
461 metadata: HashMap::new(),
462 }
463 }
464
465 fn make_edge(src: &str, tgt: &str, conf: &str, rel: &str) -> Edge {
466 Edge {
467 source: src.into(),
468 target: tgt.into(),
469 relation: rel.into(),
470 confidence: conf.into(),
471 source_file: Some("src.py".into()),
472 weight: 1.0,
473 context: None,
474 }
475 }
476
477 #[allow(clippy::type_complexity)]
478 fn test_inputs() -> (
479 GraphData,
480 HashMap<usize, Vec<String>>,
481 HashMap<usize, f64>,
482 HashMap<usize, String>,
483 Vec<GodNodeEntry>,
484 Vec<SurprisingConnectionEntry>,
485 DetectionResult,
486 TokenCost,
487 ) {
488 let graph = GraphData {
489 nodes: vec![
490 make_node("n1", "Foo"),
491 make_node("n2", "Bar"),
492 make_node("n3", "Baz"),
493 make_node("n4", "Qux"),
494 ],
495 edges: vec![
496 make_edge("n1", "n2", "EXTRACTED", "calls"),
497 make_edge("n2", "n3", "INFERRED", "references"),
498 make_edge("n3", "n4", "AMBIGUOUS", "imports"),
499 ],
500 hyperedges: None,
501 };
502 let communities: HashMap<usize, Vec<String>> = {
503 let mut m = HashMap::new();
504 m.insert(0, vec!["n1".into(), "n2".into(), "n3".into()]);
505 m.insert(1, vec!["n4".into()]);
506 m
507 };
508 let cohesion: HashMap<usize, f64> = {
509 let mut m = HashMap::new();
510 m.insert(0, 0.75);
511 m.insert(1, 0.50);
512 m
513 };
514 let labels: HashMap<usize, String> = {
515 let mut m = HashMap::new();
516 m.insert(0, "Community 0".into());
517 m.insert(1, "Community 1".into());
518 m
519 };
520 let gods = vec![GodNodeEntry {
521 label: "Foo".into(),
522 degree: 5,
523 }];
524 let surprises = vec![SurprisingConnectionEntry {
525 source: "Foo".into(),
526 target: "Baz".into(),
527 relation: "calls".into(),
528 confidence: "EXTRACTED".into(),
529 confidence_score: None,
530 source_files: vec!["a.py".into(), "b.py".into()],
531 note: String::new(),
532 }];
533 let detection = DetectionResult {
534 total_files: 4,
535 total_words: 62400,
536 warning: None,
537 };
538 let tokens = TokenCost {
539 input: 1200,
540 output: 300,
541 };
542 (
543 graph,
544 communities,
545 cohesion,
546 labels,
547 gods,
548 surprises,
549 detection,
550 tokens,
551 )
552 }
553
554 fn run_generate(min_community_size: usize) -> String {
555 let (graph, communities, cohesion, labels, gods, surprises, detection, tokens) =
556 test_inputs();
557 generate(
558 &graph,
559 &communities,
560 &cohesion,
561 &labels,
562 &gods,
563 &surprises,
564 &detection,
565 &tokens,
566 "./project",
567 None,
568 min_community_size,
569 None,
570 )
571 }
572
573 #[test]
574 fn test_report_contains_header() {
575 let report = run_generate(3);
576 assert!(
577 report.contains("# Graph Report"),
578 "header missing: {report}"
579 );
580 }
581
582 #[test]
583 fn test_report_contains_corpus_check() {
584 let report = run_generate(3);
585 assert!(
586 report.contains("## Corpus Check"),
587 "missing corpus check: {report}"
588 );
589 }
590
591 #[test]
592 fn test_report_contains_god_nodes() {
593 let report = run_generate(3);
594 assert!(
595 report.contains("## God Nodes"),
596 "missing god nodes: {report}"
597 );
598 }
599
600 #[test]
601 fn test_report_contains_surprising_connections() {
602 let report = run_generate(3);
603 assert!(
604 report.contains("## Surprising Connections"),
605 "missing surprising connections: {report}"
606 );
607 }
608
609 #[test]
610 fn test_report_contains_communities() {
611 let report = run_generate(3);
612 assert!(
613 report.contains("## Communities"),
614 "missing communities: {report}"
615 );
616 }
617
618 #[test]
619 fn test_report_contains_ambiguous_section() {
620 let report = run_generate(3);
621 assert!(
622 report.contains("## Ambiguous Edges"),
623 "missing ambiguous edges section: {report}"
624 );
625 }
626
627 #[test]
628 fn test_report_shows_token_cost() {
629 let report = run_generate(3);
630 assert!(
631 report.contains("Token cost"),
632 "token cost label missing: {report}"
633 );
634 assert!(report.contains("1,200"), "1,200 missing: {report}");
635 }
636
637 #[test]
638 fn test_report_shows_raw_cohesion_scores() {
639 let report = run_generate(1);
640 assert!(report.contains("Cohesion:"), "cohesion missing: {report}");
641 assert!(!report.contains('✓'), "unexpected ✓: {report}");
642 assert!(!report.contains('⚠'), "unexpected ⚠: {report}");
643 }
644}