Skip to main content

codesynapse_core/
report.rs

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}