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