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 graphify_core::model::{GodNode, Surprise};
11use tracing::info;
12
13pub 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
27pub 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
277pub 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}