Skip to main content

lean_ctx/core/
visualizer.rs

1//! Data collection and HTML rendering for the interactive visualizer.
2//!
3//! Gathers graph, knowledge, heatmap (token savings), and session data
4//! from the current project, then renders a self-contained HTML report
5//! with embedded D3.js.
6
7use 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// ---------------------------------------------------------------------------
15// Data types serialized into JSON for the HTML template
16// ---------------------------------------------------------------------------
17
18#[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
130// ---------------------------------------------------------------------------
131// Data collection
132// ---------------------------------------------------------------------------
133
134pub 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
307// ---------------------------------------------------------------------------
308// HTML rendering
309// ---------------------------------------------------------------------------
310
311pub 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}