Skip to main content

graphify_export/
report.rs

1//! GRAPH_REPORT.md generation.
2
3use std::collections::HashMap;
4use std::fmt::Write;
5use std::fs;
6use std::path::{Path, PathBuf};
7
8use graphify_core::confidence::Confidence;
9use graphify_core::graph::KnowledgeGraph;
10use graphify_core::model::{GodNode, Surprise};
11use tracing::info;
12
13/// Input data for report generation.
14pub struct ReportInput<'a> {
15    pub graph: &'a KnowledgeGraph,
16    pub communities: &'a HashMap<usize, Vec<String>>,
17    pub cohesion_scores: &'a HashMap<usize, f64>,
18    pub community_labels: &'a HashMap<usize, String>,
19    pub god_nodes: &'a [GodNode],
20    pub surprises: &'a [Surprise],
21    pub detection_result: &'a serde_json::Value,
22    pub token_cost: &'a HashMap<String, usize>,
23    pub root: &'a str,
24    pub suggested_questions: Option<&'a [serde_json::Value]>,
25}
26
27/// Generate a comprehensive markdown analysis report.
28pub fn generate_report(input: &ReportInput) -> anyhow::Result<String> {
29    let ReportInput {
30        graph,
31        communities,
32        cohesion_scores,
33        community_labels,
34        god_nodes,
35        surprises,
36        detection_result,
37        token_cost,
38        root,
39        suggested_questions,
40    } = input;
41    let graph = *graph;
42    let communities = *communities;
43    let cohesion_scores = *cohesion_scores;
44    let community_labels = *community_labels;
45    let god_nodes = *god_nodes;
46    let surprises = *surprises;
47    let detection_result = *detection_result;
48    let token_cost = *token_cost;
49    let root = *root;
50    let suggested_questions = *suggested_questions;
51    let mut report = String::with_capacity(8192);
52
53    writeln!(report, "# ๐Ÿ“Š Graph Analysis Report")?;
54    writeln!(report)?;
55    writeln!(report, "**Root:** `{root}`")?;
56    writeln!(report)?;
57
58    writeln!(report, "## Summary")?;
59    writeln!(report)?;
60
61    let node_count = graph.node_count();
62    let edge_count = graph.edge_count();
63    let community_count = communities.len();
64
65    writeln!(report, "| Metric | Value |")?;
66    writeln!(report, "|--------|-------|")?;
67    writeln!(report, "| Nodes | {node_count} |")?;
68    writeln!(report, "| Edges | {edge_count} |")?;
69    writeln!(report, "| Communities | {community_count} |")?;
70    writeln!(report, "| Hyperedges | {} |", graph.hyperedges.len())?;
71    writeln!(report)?;
72
73    let mut extracted = 0usize;
74    let mut inferred = 0usize;
75    let mut ambiguous = 0usize;
76    for edge in graph.edges() {
77        match edge.confidence {
78            Confidence::Extracted => extracted += 1,
79            Confidence::Inferred => inferred += 1,
80            Confidence::Ambiguous => ambiguous += 1,
81        }
82    }
83    writeln!(report, "### Confidence Breakdown")?;
84    writeln!(report)?;
85    writeln!(report, "| Level | Count | Percentage |")?;
86    writeln!(report, "|-------|-------|------------|")?;
87    let total = (extracted + inferred + ambiguous).max(1);
88    writeln!(
89        report,
90        "| EXTRACTED | {} | {:.1}% |",
91        extracted,
92        extracted as f64 / total as f64 * 100.0
93    )?;
94    writeln!(
95        report,
96        "| INFERRED | {} | {:.1}% |",
97        inferred,
98        inferred as f64 / total as f64 * 100.0
99    )?;
100    writeln!(
101        report,
102        "| AMBIGUOUS | {} | {:.1}% |",
103        ambiguous,
104        ambiguous as f64 / total as f64 * 100.0
105    )?;
106    writeln!(report)?;
107
108    writeln!(report, "## ๐ŸŒŸ God Nodes (Most Connected)")?;
109    writeln!(report)?;
110    if god_nodes.is_empty() {
111        writeln!(report, "_No god nodes detected._")?;
112    } else {
113        writeln!(report, "| Node | Degree | Community |")?;
114        writeln!(report, "|------|--------|-----------|")?;
115        for gn in god_nodes {
116            let comm = gn.community.map_or_else(|| "โ€“".into(), |c| c.to_string());
117            writeln!(report, "| {} | {} | {} |", gn.label, gn.degree, comm)?;
118        }
119    }
120    writeln!(report)?;
121
122    writeln!(report, "## ๐Ÿ”ฎ Surprising Connections")?;
123    writeln!(report)?;
124    if surprises.is_empty() {
125        writeln!(report, "_No surprising connections found._")?;
126    } else {
127        for s in surprises {
128            writeln!(
129                report,
130                "- **{}** โ†’ **{}** ({})",
131                s.source, s.target, s.relation
132            )?;
133        }
134    }
135    writeln!(report)?;
136
137    if !graph.hyperedges.is_empty() {
138        writeln!(report, "## ๐Ÿ”— Hyperedges")?;
139        writeln!(report)?;
140        for he in &graph.hyperedges {
141            writeln!(
142                report,
143                "- **{}**: {} (nodes: {})",
144                he.relation,
145                he.label,
146                he.nodes.join(", ")
147            )?;
148        }
149        writeln!(report)?;
150    }
151
152    writeln!(report, "## ๐Ÿ˜๏ธ Communities")?;
153    writeln!(report)?;
154    let mut sorted_communities: Vec<_> = communities.iter().collect();
155    sorted_communities.sort_by_key(|(cid, _)| **cid);
156    for (cid, members) in &sorted_communities {
157        let label = community_labels
158            .get(cid)
159            .map_or("Unnamed", std::string::String::as_str);
160        let cohesion = cohesion_scores.get(cid).copied().unwrap_or(0.0);
161        writeln!(
162            report,
163            "### Community {} โ€” {} ({} nodes, cohesion: {:.2})",
164            cid,
165            label,
166            members.len(),
167            cohesion
168        )?;
169        writeln!(report)?;
170        for nid in members.iter().take(20) {
171            let node_label = graph
172                .get_node(nid)
173                .map_or(nid.as_str(), |n| n.label.as_str());
174            writeln!(report, "- {node_label}")?;
175        }
176        if members.len() > 20 {
177            writeln!(report, "- _โ€ฆand {} more_", members.len() - 20)?;
178        }
179        writeln!(report)?;
180    }
181
182    if ambiguous > 0 {
183        writeln!(report, "## โš ๏ธ Ambiguous Edges")?;
184        writeln!(report)?;
185        let mut count = 0;
186        for edge in graph.edges() {
187            if edge.confidence == Confidence::Ambiguous {
188                writeln!(
189                    report,
190                    "- {} โ†’ {} ({}, score: {:.2})",
191                    edge.source, edge.target, edge.relation, edge.confidence_score
192                )?;
193                count += 1;
194                if count >= 30 {
195                    writeln!(report, "- _โ€ฆand more_")?;
196                    break;
197                }
198            }
199        }
200        writeln!(report)?;
201    }
202
203    writeln!(report, "## ๐Ÿ•ณ๏ธ Knowledge Gaps")?;
204    writeln!(report)?;
205
206    let isolated: Vec<_> = graph
207        .nodes()
208        .iter()
209        .filter(|n| graph.degree(&n.id) == 0)
210        .map(|n| n.label.as_str())
211        .collect();
212    if isolated.is_empty() {
213        writeln!(report, "No isolated nodes.")?;
214    } else {
215        writeln!(report, "**Isolated nodes** ({}):", isolated.len())?;
216        for label in isolated.iter().take(20) {
217            writeln!(report, "- {label}")?;
218        }
219        if isolated.len() > 20 {
220            writeln!(report, "- _โ€ฆand {} more_", isolated.len() - 20)?;
221        }
222    }
223    writeln!(report)?;
224
225    let thin: Vec<_> = communities
226        .iter()
227        .filter(|(_, members)| members.len() < 3)
228        .collect();
229    if !thin.is_empty() {
230        writeln!(
231            report,
232            "**Thin communities** (< 3 nodes): {} communities",
233            thin.len()
234        )?;
235        writeln!(report)?;
236    }
237
238    if let Some(method) = detection_result.get("method").and_then(|v| v.as_str()) {
239        writeln!(report, "**Community detection method:** {method}")?;
240        writeln!(report)?;
241    }
242
243    if !token_cost.is_empty() {
244        writeln!(report, "## ๐Ÿ’ฐ Token Cost")?;
245        writeln!(report)?;
246        writeln!(report, "| File | Tokens |")?;
247        writeln!(report, "|------|--------|")?;
248        let mut total_tokens = 0usize;
249        for (file, &tokens) in token_cost {
250            writeln!(report, "| {file} | {tokens} |")?;
251            total_tokens += tokens;
252        }
253        writeln!(report, "| **Total** | **{total_tokens}** |")?;
254        writeln!(report)?;
255    }
256
257    if let Some(questions) = suggested_questions
258        && !questions.is_empty()
259    {
260        writeln!(report, "## โ“ Suggested Questions")?;
261        writeln!(report)?;
262        for q in questions {
263            if let Some(text) = q.as_str() {
264                writeln!(report, "1. {text}")?;
265            } else if let Some(text) = q.get("question").and_then(|v| v.as_str()) {
266                writeln!(report, "1. {text}")?;
267            }
268        }
269        writeln!(report)?;
270    }
271
272    writeln!(report, "---")?;
273    writeln!(report, "_Generated by graphify-rs_")?;
274    Ok(report)
275}
276
277/// Write the report string to `GRAPH_REPORT.md`.
278pub fn export_report(report: &str, output_dir: &Path) -> anyhow::Result<PathBuf> {
279    fs::create_dir_all(output_dir)?;
280    let path = output_dir.join("GRAPH_REPORT.md");
281    fs::write(&path, report)?;
282    info!(path = %path.display(), "exported analysis report");
283    Ok(path)
284}
285
286#[cfg(test)]
287mod tests {
288    use super::*;
289    use graphify_core::confidence::Confidence;
290    use graphify_core::graph::KnowledgeGraph;
291    use graphify_core::model::{GraphEdge, GraphNode, NodeType};
292
293    fn sample_graph() -> KnowledgeGraph {
294        let mut kg = KnowledgeGraph::new();
295        kg.add_node(GraphNode {
296            id: "a".into(),
297            label: "NodeA".into(),
298            source_file: "test.rs".into(),
299            source_location: None,
300            node_type: NodeType::Class,
301            community: Some(0),
302            extra: HashMap::new(),
303        })
304        .unwrap();
305        kg.add_node(GraphNode {
306            id: "b".into(),
307            label: "NodeB".into(),
308            source_file: "test.rs".into(),
309            source_location: None,
310            node_type: NodeType::Function,
311            community: Some(0),
312            extra: HashMap::new(),
313        })
314        .unwrap();
315        kg.add_edge(GraphEdge {
316            source: "a".into(),
317            target: "b".into(),
318            relation: "calls".into(),
319            confidence: Confidence::Extracted,
320            confidence_score: 1.0,
321            source_file: "test.rs".into(),
322            source_location: None,
323            weight: 1.0,
324            extra: HashMap::new(),
325        })
326        .unwrap();
327        kg
328    }
329
330    #[test]
331    fn generate_report_contains_sections() {
332        let kg = sample_graph();
333        let communities: HashMap<usize, Vec<String>> = [(0, vec!["a".into(), "b".into()])].into();
334        let cohesion: HashMap<usize, f64> = [(0, 0.9)].into();
335        let labels: HashMap<usize, String> = [(0, "Core".into())].into();
336
337        let report = generate_report(&ReportInput {
338            graph: &kg,
339            communities: &communities,
340            cohesion_scores: &cohesion,
341            community_labels: &labels,
342            god_nodes: &[],
343            surprises: &[],
344            detection_result: &serde_json::json!({}),
345            token_cost: &HashMap::new(),
346            root: "/test",
347            suggested_questions: None,
348        })
349        .unwrap();
350
351        assert!(report.contains("# ๐Ÿ“Š Graph Analysis Report"));
352        assert!(report.contains("## Summary"));
353        assert!(report.contains("| Nodes | 2 |"));
354        assert!(report.contains("## ๐Ÿ˜๏ธ Communities"));
355        assert!(report.contains("Core"));
356    }
357
358    #[test]
359    fn export_report_creates_file() {
360        let dir = tempfile::tempdir().unwrap();
361        let path = export_report("# Test Report\n", dir.path()).unwrap();
362        assert!(path.exists());
363        assert!(path.ends_with("GRAPH_REPORT.md"));
364    }
365}