1use 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#[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 writeln!(report, "# ๐ Graph Analysis Report").unwrap();
30 writeln!(report).unwrap();
31 writeln!(report, "**Root:** `{}`", root).unwrap();
32 writeln!(report).unwrap();
33
34 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 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 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 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 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 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 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 writeln!(report, "## ๐ณ๏ธ Knowledge Gaps").unwrap();
201 writeln!(report).unwrap();
202
203 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 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 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 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 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
280pub 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}