Skip to main content

memscope_rs/render_engine/dashboard/renderer/
event_dto.rs

1//! Lightweight event transfer objects for dashboard serialization.
2//!
3//! These DTOs avoid exposing the full internal `MemoryEvent` struct to
4//! the frontend while still providing enough detail for time-travel,
5//! filtering, and correlation views.
6
7use serde::{Deserialize, Serialize};
8
9/// A lightweight, serializable view of a `MemoryEvent` for dashboard use.
10///
11/// Contains only the fields needed by the frontend for timeline,
12/// allocation, and correlation views.
13#[derive(Debug, Clone, Serialize, Deserialize)]
14pub struct DashboardEventDTO {
15    /// Event timestamp (nanoseconds since epoch)
16    pub timestamp: u64,
17    /// Event type as a string: "Allocate", "Deallocate", "Reallocate", "Move", "Clone"
18    pub event_type: String,
19    /// Memory pointer address (formatted as hex string)
20    pub ptr: String,
21    /// Allocation size in bytes
22    pub size: usize,
23    /// Thread identifier
24    pub thread_id: u64,
25    /// Optional task identifier
26    pub task_id: Option<u64>,
27    /// Optional variable name
28    pub var_name: Option<String>,
29    /// Optional type name
30    pub type_name: Option<String>,
31    /// Optional source file path
32    pub source_file: Option<String>,
33    /// Optional source line number
34    pub source_line: Option<u32>,
35}
36
37impl From<&crate::event_store::event::MemoryEvent> for DashboardEventDTO {
38    fn from(e: &crate::event_store::event::MemoryEvent) -> Self {
39        Self {
40            timestamp: e.timestamp,
41            event_type: e.event_type.to_string(),
42            ptr: format!("0x{:x}", e.ptr),
43            size: e.size,
44            thread_id: e.thread_id,
45            task_id: e.task_id,
46            var_name: e.var_name.clone(),
47            type_name: e.type_name.clone(),
48            source_file: e.source_file.clone(),
49            source_line: e.source_line,
50        }
51    }
52}
53
54/// Frontend data index for cross-referencing allocations by various keys.
55///
56/// This index is built once during dashboard initialization and embedded
57/// in the JSON payload so the frontend can answer queries without
58/// linear scans.
59#[derive(Debug, Clone, Serialize, Deserialize, Default)]
60pub struct DataIndex {
61    /// Maps pointer hex-string to list of allocation indices
62    pub ptr_to_allocation: std::collections::HashMap<String, Vec<usize>>,
63    /// Maps thread ID to list of allocation indices
64    pub thread_id_to_allocations: std::collections::HashMap<u64, Vec<usize>>,
65    /// Maps thread ID to list of event indices
66    pub thread_id_to_events: std::collections::HashMap<u64, Vec<usize>>,
67    /// Maps type name to list of allocation indices
68    pub type_name_to_allocations: std::collections::HashMap<String, Vec<usize>>,
69    /// Maps variable name to list of allocation indices
70    pub var_name_to_allocations: std::collections::HashMap<String, Vec<usize>>,
71    /// Maps allocation pointer to passport index
72    pub allocation_ptr_to_passport: std::collections::HashMap<String, Vec<usize>>,
73    /// Maps allocation pointer to unsafe report index
74    pub allocation_ptr_to_unsafe_reports: std::collections::HashMap<String, Vec<usize>>,
75    /// Maps source location "file:line" to list of allocation indices
76    pub source_location_to_allocations: std::collections::HashMap<String, Vec<usize>>,
77}
78
79/// Summary of event data included in the dashboard JSON.
80#[derive(Debug, Clone, Serialize, Deserialize)]
81pub struct EventSummary {
82    /// Total number of raw events in the event store
83    pub total_event_count: usize,
84    /// Number of events included in the dashboard payload
85    pub exported_event_count: usize,
86    /// Whether a subset of events was exported
87    pub is_sampled: bool,
88    /// Description of the sampling strategy
89    pub sampling_strategy: String,
90    /// Total number of allocations (from event reconstruction)
91    pub total_allocations: usize,
92    /// Number of active allocations
93    pub active_allocations: usize,
94}
95
96impl EventSummary {
97    pub fn new(
98        total_event_count: usize,
99        exported_event_count: usize,
100        total_allocations: usize,
101        active_allocations: usize,
102    ) -> Self {
103        let is_sampled = exported_event_count < total_event_count;
104        let strategy = if is_sampled {
105            format!("truncated to {} events", exported_event_count)
106        } else {
107            "complete".to_string()
108        };
109        Self {
110            total_event_count,
111            exported_event_count,
112            is_sampled,
113            sampling_strategy: strategy,
114            total_allocations,
115            active_allocations,
116        }
117    }
118}
119
120/// Build a `DataIndex` from allocation info and events.
121///
122/// This is called once during dashboard initialization and the result
123/// is cached in the JSON payload so the frontend can use it immediately.
124pub fn build_data_index(
125    allocations: &[super::types::AllocationInfo],
126    events: &[DashboardEventDTO],
127    passport_details: &[super::types::PassportDetail],
128    unsafe_reports: &[super::types::UnsafeReport],
129) -> DataIndex {
130    use std::collections::HashMap;
131
132    let mut ptr_to_allocation: HashMap<String, Vec<usize>> = HashMap::new();
133    let mut thread_id_to_allocations: HashMap<u64, Vec<usize>> = HashMap::new();
134    let mut thread_id_to_events: HashMap<u64, Vec<usize>> = HashMap::new();
135    let mut type_name_to_allocations: HashMap<String, Vec<usize>> = HashMap::new();
136    let mut var_name_to_allocations: HashMap<String, Vec<usize>> = HashMap::new();
137    let mut allocation_ptr_to_passport: HashMap<String, Vec<usize>> = HashMap::new();
138    let mut allocation_ptr_to_unsafe_reports: HashMap<String, Vec<usize>> = HashMap::new();
139    let mut source_location_to_allocations: HashMap<String, Vec<usize>> = HashMap::new();
140
141    for (idx, alloc) in allocations.iter().enumerate() {
142        ptr_to_allocation
143            .entry(alloc.address.clone())
144            .or_default()
145            .push(idx);
146
147        // Parse thread_id from "Thread-N" format back to numeric
148        if let Some(tid) = parse_thread_id(&alloc.thread_id) {
149            thread_id_to_allocations.entry(tid).or_default().push(idx);
150        }
151
152        let tn = alloc.type_name.to_lowercase();
153        if !tn.is_empty() && tn != "unknown" {
154            type_name_to_allocations
155                .entry(alloc.type_name.clone())
156                .or_default()
157                .push(idx);
158        }
159
160        let vn = alloc.var_name.to_lowercase();
161        if !vn.is_empty() && vn != "unknown" {
162            var_name_to_allocations
163                .entry(alloc.var_name.clone())
164                .or_default()
165                .push(idx);
166        }
167
168        if let (Some(ref file), Some(line)) = (&alloc.source_file, alloc.source_line) {
169            let loc = format!("{}:{}", file, line);
170            source_location_to_allocations
171                .entry(loc)
172                .or_default()
173                .push(idx);
174        } else if let Some(ref file) = &alloc.source_file {
175            let loc = format!("{}:0", file);
176            source_location_to_allocations
177                .entry(loc)
178                .or_default()
179                .push(idx);
180        }
181    }
182
183    for (idx, event) in events.iter().enumerate() {
184        thread_id_to_events
185            .entry(event.thread_id)
186            .or_default()
187            .push(idx);
188    }
189
190    for (idx, passport) in passport_details.iter().enumerate() {
191        allocation_ptr_to_passport
192            .entry(passport.allocation_ptr.clone())
193            .or_default()
194            .push(idx);
195    }
196
197    for (idx, report) in unsafe_reports.iter().enumerate() {
198        allocation_ptr_to_unsafe_reports
199            .entry(report.allocation_ptr.clone())
200            .or_default()
201            .push(idx);
202    }
203
204    DataIndex {
205        ptr_to_allocation,
206        thread_id_to_allocations,
207        thread_id_to_events,
208        type_name_to_allocations,
209        var_name_to_allocations,
210        allocation_ptr_to_passport,
211        allocation_ptr_to_unsafe_reports,
212        source_location_to_allocations,
213    }
214}
215
216/// Parse a "Thread-N" format string back to a numeric thread ID.
217fn parse_thread_id(raw: &str) -> Option<u64> {
218    if let Some(stripped) = raw.strip_prefix("Thread-") {
219        stripped.parse::<u64>().ok()
220    } else {
221        raw.parse::<u64>().ok()
222    }
223}
224
225#[cfg(test)]
226mod tests {
227    use super::*;
228
229    /// Objective: Verify that DashboardEventDTO is correctly constructed
230    /// from a MemoryEvent.
231    /// Invariants: All relevant fields are preserved in the DTO.
232    #[test]
233    fn test_dto_from_memory_event() {
234        use crate::event_store::event::MemoryEvent;
235        let mut event = MemoryEvent::allocate(0x1000, 64, 1);
236        event.var_name = Some("buf".to_string());
237        event.type_name = Some("[u8; 64]".to_string());
238        event.source_file = Some("src/main.rs".to_string());
239        event.source_line = Some(42);
240        event.task_id = Some(7);
241
242        let dto = DashboardEventDTO::from(&event);
243
244        assert_eq!(dto.ptr, "0x1000");
245        assert_eq!(dto.size, 64);
246        assert_eq!(dto.thread_id, 1);
247        assert_eq!(dto.var_name.as_deref(), Some("buf"));
248        assert_eq!(dto.event_type, "Allocate");
249        assert_eq!(dto.task_id, Some(7));
250        assert_eq!(dto.source_file.as_deref(), Some("src/main.rs"));
251        assert_eq!(dto.source_line, Some(42));
252    }
253
254    /// Objective: Verify that EventSummary correctly reports complete
255    /// vs sampled data.
256    /// Invariants: When all events are exported, is_sampled is false.
257    #[test]
258    fn test_event_summary_complete() {
259        let s = EventSummary::new(100, 100, 50, 25);
260        assert!(!s.is_sampled);
261        assert_eq!(s.total_event_count, 100);
262        assert_eq!(s.exported_event_count, 100);
263    }
264
265    /// Objective: Verify that EventSummary correctly reports truncated data.
266    /// Invariants: When fewer events are exported, is_sampled is true.
267    #[test]
268    fn test_event_summary_sampled() {
269        let s = EventSummary::new(1000, 100, 500, 200);
270        assert!(s.is_sampled);
271        assert_eq!(s.total_event_count, 1000);
272        assert_eq!(s.exported_event_count, 100);
273    }
274
275    /// Objective: Verify that build_data_index creates indexes correctly
276    /// from allocation info.
277    /// Invariants: Each allocation is indexed by its address, type, var name.
278    #[test]
279    fn test_build_data_index_with_allocations() {
280        use crate::render_engine::dashboard::renderer::types::AllocationInfo;
281
282        let alloc = AllocationInfo {
283            address: "0x1000".to_string(),
284            type_name: "Vec<u8>".to_string(),
285            size: 64,
286            var_name: "buffer".to_string(),
287            timestamp: "0".to_string(),
288            thread_id: "Thread-1".to_string(),
289            immutable_borrows: 0,
290            mutable_borrows: 0,
291            is_clone: false,
292            clone_count: 0,
293            timestamp_alloc: 1000,
294            timestamp_dealloc: 0,
295            lifetime_ms: 0.0,
296            is_leaked: false,
297            allocation_type: "heap".to_string(),
298            is_smart_pointer: false,
299            smart_pointer_type: String::new(),
300            source_file: Some("src/main.rs".to_string()),
301            source_line: Some(42),
302            module_path: None,
303            generation_id: 0,
304            provenance: String::new(),
305            evidence: Default::default(),
306            confidence: Default::default(),
307            layout_snapshot: None,
308        };
309
310        let index = build_data_index(&[alloc], &[], &[], &[]);
311
312        assert!(index.ptr_to_allocation.contains_key("0x1000"));
313        assert!(index.type_name_to_allocations.contains_key("Vec<u8>"));
314        assert!(index.var_name_to_allocations.contains_key("buffer"));
315        assert!(index
316            .source_location_to_allocations
317            .contains_key("src/main.rs:42"));
318    }
319
320    /// Objective: Verify that build_data_index handles empty inputs.
321    /// Invariants: No panic, all index maps are empty.
322    #[test]
323    fn test_build_data_index_empty() {
324        let index = build_data_index(&[], &[], &[], &[]);
325        assert!(index.ptr_to_allocation.is_empty());
326        assert!(index.thread_id_to_allocations.is_empty());
327        assert!(index.type_name_to_allocations.is_empty());
328    }
329
330    /// Objective: Verify that parse_thread_id handles various formats.
331    /// Invariants: "Thread-N" returns Some(N), plain numbers return Some(n),
332    /// arbitrary strings return None.
333    #[test]
334    fn test_parse_thread_id() {
335        assert_eq!(parse_thread_id("Thread-1"), Some(1));
336        assert_eq!(parse_thread_id("Thread-42"), Some(42));
337        assert_eq!(parse_thread_id("42"), Some(42));
338        assert_eq!(parse_thread_id("main"), None);
339    }
340
341    /// Objective: Verify that DTO serialization round-trips correctly.
342    /// Invariants: Serialize + deserialize preserves all fields.
343    #[test]
344    fn test_dto_serialization_roundtrip() {
345        let dto = DashboardEventDTO {
346            timestamp: 1234,
347            event_type: "Allocate".to_string(),
348            ptr: "0x1000".to_string(),
349            size: 64,
350            thread_id: 1,
351            task_id: Some(7),
352            var_name: Some("buf".to_string()),
353            type_name: Some("[u8]".to_string()),
354            source_file: Some("src/lib.rs".to_string()),
355            source_line: Some(10),
356        };
357
358        let json = serde_json::to_string(&dto).unwrap();
359        let deserialized: DashboardEventDTO = serde_json::from_str(&json).unwrap();
360
361        assert_eq!(deserialized.ptr, "0x1000");
362        assert_eq!(deserialized.event_type, "Allocate");
363        assert_eq!(deserialized.task_id, Some(7));
364        assert_eq!(deserialized.source_line, Some(10));
365    }
366}