1use serde::Serialize;
8
9use crate::core::heatmap::HeatMap;
10use crate::core::knowledge::{KnowledgeFact, ProjectKnowledge};
11use crate::core::property_graph::CodeGraph;
12use crate::core::session::{SessionState, SessionStats};
13
14#[derive(Serialize)]
19pub struct VisualizerData {
20 pub graph: GraphData,
21 pub knowledge: Vec<KnowledgeEntry>,
22 pub savings: SavingsData,
23 pub history: SessionHistory,
24}
25
26#[derive(Serialize)]
27pub struct GraphData {
28 pub nodes: Vec<GraphNode>,
29 pub edges: Vec<GraphEdge>,
30}
31
32#[derive(Serialize)]
33pub struct GraphNode {
34 pub id: String,
35 pub kind: String,
36 pub label: String,
37}
38
39#[derive(Serialize)]
40pub struct GraphEdge {
41 pub source: String,
42 pub target: String,
43 pub kind: String,
44 pub weight: f64,
45}
46
47#[derive(Serialize)]
48pub struct KnowledgeEntry {
49 pub category: String,
50 pub key: String,
51 pub value: String,
52 pub confidence: f32,
53 pub archetype: String,
54 pub created_at: String,
55 pub valid_from: Option<String>,
56 pub valid_until: Option<String>,
57 pub retrieval_count: u32,
58 pub confirmation_count: u32,
59}
60
61#[derive(Serialize)]
62pub struct SavingsData {
63 pub files: Vec<FileSavingsEntry>,
64 pub total_original: u64,
65 pub total_saved: u64,
66 pub overall_ratio: f32,
67}
68
69#[derive(Serialize)]
70pub struct FileSavingsEntry {
71 pub path: String,
72 pub access_count: u32,
73 pub original_tokens: u64,
74 pub saved_tokens: u64,
75 pub compression_ratio: f32,
76}
77
78#[derive(Serialize)]
79pub struct SessionHistory {
80 pub session_id: String,
81 pub started_at: String,
82 pub task: Option<String>,
83 pub stats: SessionStatsEntry,
84 pub files_touched: Vec<FileTouchedEntry>,
85 pub findings: Vec<FindingEntry>,
86 pub decisions: Vec<DecisionEntry>,
87 pub progress: Vec<ProgressEntryViz>,
88}
89
90#[derive(Serialize)]
91pub struct SessionStatsEntry {
92 pub total_tool_calls: u32,
93 pub total_tokens_saved: u64,
94 pub total_tokens_input: u64,
95 pub cache_hits: u32,
96 pub files_read: u32,
97 pub commands_run: u32,
98}
99
100#[derive(Serialize)]
101pub struct FileTouchedEntry {
102 pub path: String,
103 pub read_count: u32,
104 pub modified: bool,
105 pub mode: String,
106 pub tokens: usize,
107}
108
109#[derive(Serialize)]
110pub struct FindingEntry {
111 pub file: Option<String>,
112 pub summary: String,
113 pub timestamp: String,
114}
115
116#[derive(Serialize)]
117pub struct DecisionEntry {
118 pub summary: String,
119 pub rationale: Option<String>,
120 pub timestamp: String,
121}
122
123#[derive(Serialize)]
124pub struct ProgressEntryViz {
125 pub action: String,
126 pub detail: Option<String>,
127 pub timestamp: String,
128}
129
130pub fn collect_data(project_root: &str) -> VisualizerData {
135 let graph = collect_graph(project_root);
136 let knowledge = collect_knowledge(project_root);
137 let savings = collect_savings();
138 let history = collect_session(project_root);
139
140 VisualizerData {
141 graph,
142 knowledge,
143 savings,
144 history,
145 }
146}
147
148fn collect_graph(project_root: &str) -> GraphData {
149 let Ok(cg) = CodeGraph::open(project_root) else {
150 return GraphData {
151 nodes: Vec::new(),
152 edges: Vec::new(),
153 };
154 };
155
156 let flat_edges = cg.all_edges_flat().unwrap_or_default();
157
158 let mut node_set = std::collections::HashSet::new();
159 let mut nodes = Vec::new();
160 let mut edges = Vec::new();
161
162 for (src, tgt, kind, weight) in &flat_edges {
163 for path in [src, tgt] {
164 if node_set.insert(path.clone()) {
165 let label = path.rsplit('/').next().unwrap_or(path).to_string();
166 nodes.push(GraphNode {
167 id: path.clone(),
168 kind: "file".to_string(),
169 label,
170 });
171 }
172 }
173 edges.push(GraphEdge {
174 source: src.clone(),
175 target: tgt.clone(),
176 kind: kind.clone(),
177 weight: *weight,
178 });
179 }
180
181 GraphData { nodes, edges }
182}
183
184fn collect_knowledge(project_root: &str) -> Vec<KnowledgeEntry> {
185 let Some(pk) = ProjectKnowledge::load(project_root) else {
186 return Vec::new();
187 };
188 pk.facts
189 .iter()
190 .map(|f: &KnowledgeFact| KnowledgeEntry {
191 category: f.category.clone(),
192 key: f.key.clone(),
193 value: f.value.clone(),
194 confidence: f.confidence,
195 archetype: format!("{:?}", f.archetype),
196 created_at: f.created_at.to_rfc3339(),
197 valid_from: f.valid_from.map(|d| d.to_rfc3339()),
198 valid_until: f.valid_until.map(|d| d.to_rfc3339()),
199 retrieval_count: f.retrieval_count,
200 confirmation_count: f.confirmation_count,
201 })
202 .collect()
203}
204
205fn collect_savings() -> SavingsData {
206 let hm = HeatMap::load();
207 let top = hm.top_files(500);
208
209 let mut total_original = 0u64;
210 let mut total_saved = 0u64;
211
212 let files: Vec<FileSavingsEntry> = top
213 .into_iter()
214 .map(|e| {
215 total_original += e.total_original_tokens;
216 total_saved += e.total_tokens_saved;
217 FileSavingsEntry {
218 path: e.path.clone(),
219 access_count: e.access_count,
220 original_tokens: e.total_original_tokens,
221 saved_tokens: e.total_tokens_saved,
222 compression_ratio: e.avg_compression_ratio,
223 }
224 })
225 .collect();
226
227 let overall_ratio = if total_original > 0 {
228 total_saved as f32 / total_original as f32
229 } else {
230 0.0
231 };
232
233 SavingsData {
234 files,
235 total_original,
236 total_saved,
237 overall_ratio,
238 }
239}
240
241fn collect_session(project_root: &str) -> SessionHistory {
242 let session = SessionState::load_latest_for_project_root(project_root)
243 .or_else(SessionState::load_global_latest_pointer)
244 .unwrap_or_default();
245
246 map_session(&session)
247}
248
249fn map_session(s: &SessionState) -> SessionHistory {
250 SessionHistory {
251 session_id: s.id.clone(),
252 started_at: s.started_at.to_rfc3339(),
253 task: s.task.as_ref().map(|t| t.description.clone()),
254 stats: map_stats(&s.stats),
255 files_touched: s
256 .files_touched
257 .iter()
258 .map(|f| FileTouchedEntry {
259 path: f.path.clone(),
260 read_count: f.read_count,
261 modified: f.modified,
262 mode: f.last_mode.clone(),
263 tokens: f.tokens,
264 })
265 .collect(),
266 findings: s
267 .findings
268 .iter()
269 .map(|f| FindingEntry {
270 file: f.file.clone(),
271 summary: f.summary.clone(),
272 timestamp: f.timestamp.to_rfc3339(),
273 })
274 .collect(),
275 decisions: s
276 .decisions
277 .iter()
278 .map(|d| DecisionEntry {
279 summary: d.summary.clone(),
280 rationale: d.rationale.clone(),
281 timestamp: d.timestamp.to_rfc3339(),
282 })
283 .collect(),
284 progress: s
285 .progress
286 .iter()
287 .map(|p| ProgressEntryViz {
288 action: p.action.clone(),
289 detail: p.detail.clone(),
290 timestamp: p.timestamp.to_rfc3339(),
291 })
292 .collect(),
293 }
294}
295
296fn map_stats(s: &SessionStats) -> SessionStatsEntry {
297 SessionStatsEntry {
298 total_tool_calls: s.total_tool_calls,
299 total_tokens_saved: s.total_tokens_saved,
300 total_tokens_input: s.total_tokens_input,
301 cache_hits: s.cache_hits,
302 files_read: s.files_read,
303 commands_run: s.commands_run,
304 }
305}
306
307pub fn render_html(data: &VisualizerData) -> String {
312 let json = serde_json::to_string(data).unwrap_or_else(|_| "{}".to_string());
313 let template = include_str!("../assets/visualizer.html");
314 template.replace("/*__VISUALIZER_DATA__*/", &format!("const DATA = {json};"))
315}
316
317#[cfg(test)]
318mod tests {
319 use super::*;
320
321 #[test]
322 fn empty_data_renders_valid_html() {
323 let data = VisualizerData {
324 graph: GraphData {
325 nodes: Vec::new(),
326 edges: Vec::new(),
327 },
328 knowledge: Vec::new(),
329 savings: SavingsData {
330 files: Vec::new(),
331 total_original: 0,
332 total_saved: 0,
333 overall_ratio: 0.0,
334 },
335 history: SessionHistory {
336 session_id: "test".to_string(),
337 started_at: "2024-01-01T00:00:00Z".to_string(),
338 task: None,
339 stats: SessionStatsEntry {
340 total_tool_calls: 0,
341 total_tokens_saved: 0,
342 total_tokens_input: 0,
343 cache_hits: 0,
344 files_read: 0,
345 commands_run: 0,
346 },
347 files_touched: Vec::new(),
348 findings: Vec::new(),
349 decisions: Vec::new(),
350 progress: Vec::new(),
351 },
352 };
353 let html = render_html(&data);
354 assert!(html.contains("<!DOCTYPE html>"));
355 assert!(html.contains("const DATA ="));
356 assert!(!html.contains("/*__VISUALIZER_DATA__*/"));
357 }
358
359 #[test]
360 fn savings_ratio_zero_on_empty() {
361 let s = collect_savings();
362 assert!(s.overall_ratio >= 0.0);
363 }
364
365 #[test]
366 fn graph_node_label_uses_filename() {
367 let node = GraphNode {
368 id: "src/core/main.rs".to_string(),
369 kind: "file".to_string(),
370 label: "main.rs".to_string(),
371 };
372 assert_eq!(node.label, "main.rs");
373 }
374}