Skip to main content

alloc_tracker/
report.rs

1//! Memory allocation tracking reports.
2
3use std::collections::HashMap;
4use std::fmt;
5
6/// Thread-safe memory allocation tracking report.
7///
8/// A `Report` contains the captured memory allocation statistics from a [`Session`](crate::Session)
9/// and can be safely sent to other threads for processing. Reports can be merged together
10/// and processed independently.
11///
12/// # Examples
13///
14/// ```
15/// use alloc_tracker::{Allocator, Session};
16///
17/// #[global_allocator]
18/// static ALLOCATOR: Allocator<std::alloc::System> = Allocator::system();
19///
20/// # fn main() {
21/// let session = Session::new();
22/// {
23///     let operation = session.operation("test_work");
24///     let _span = operation.measure_process();
25///     let _data = vec![1, 2, 3, 4, 5]; // This allocates memory
26/// }
27///
28/// let report = session.to_report();
29/// report.print_to_stdout();
30/// # }
31/// ```
32///
33/// # Merging reports
34///
35/// ```
36/// use alloc_tracker::{Allocator, Report, Session};
37///
38/// #[global_allocator]
39/// static ALLOCATOR: Allocator<std::alloc::System> = Allocator::system();
40///
41/// # fn main() {
42/// // Create two separate sessions
43/// let session1 = Session::new();
44/// let session2 = Session::new();
45///
46/// // Record some work in each
47/// {
48///     let op1 = session1.operation("work");
49///     let _span1 = op1.measure_process();
50///     let _data1 = vec![1, 2, 3]; // This allocates memory
51/// }
52///
53/// {
54///     let op2 = session2.operation("work");
55///     let _span2 = op2.measure_process();
56///     let _data2 = vec![4, 5, 6, 7]; // This allocates more memory
57/// }
58///
59/// // Convert to reports and merge
60/// let report1 = session1.to_report();
61/// let report2 = session2.to_report();
62/// let merged = Report::merge(&report1, &report2);
63///
64/// merged.print_to_stdout();
65/// # }
66/// ```
67#[derive(Clone, Debug, Default)]
68pub struct Report {
69    operations: HashMap<String, ReportOperation>,
70}
71
72/// Memory allocation statistics for a single operation in a report.
73#[derive(Clone, Debug)]
74#[expect(
75    clippy::struct_field_names,
76    reason = "field names are descriptive and clear"
77)]
78pub struct ReportOperation {
79    total_bytes_allocated: u64,
80    total_allocations_count: u64,
81    total_iterations: u64,
82}
83
84impl Report {
85    /// Creates an empty report.
86    #[cfg(test)]
87    #[must_use]
88    pub(crate) fn new() -> Self {
89        Self {
90            operations: HashMap::new(),
91        }
92    }
93
94    /// Creates a report from shared operation data.
95    #[must_use]
96    pub(crate) fn from_operation_data(
97        operation_data: &HashMap<String, crate::operation_metrics::OperationMetrics>,
98    ) -> Self {
99        let report_operations = operation_data
100            .iter()
101            .map(|(name, op_data)| {
102                (
103                    name.clone(),
104                    ReportOperation {
105                        total_bytes_allocated: op_data.total_bytes_allocated,
106                        total_allocations_count: op_data.total_allocations_count,
107                        total_iterations: op_data.total_iterations,
108                    },
109                )
110            })
111            .collect();
112
113        Self {
114            operations: report_operations,
115        }
116    }
117
118    /// Merges two reports into a new report.
119    ///
120    /// The resulting report contains the combined statistics from both input reports.
121    /// Operations with the same name have their statistics combined as if all spans
122    /// had been recorded through a single session.
123    ///
124    /// # Examples
125    ///
126    /// ```
127    /// use alloc_tracker::{Allocator, Report, Session};
128    ///
129    /// #[global_allocator]
130    /// static ALLOCATOR: Allocator<std::alloc::System> = Allocator::system();
131    ///
132    /// # fn main() {
133    /// let session1 = Session::new();
134    /// let session2 = Session::new();
135    ///
136    /// // Both sessions record the same operation name
137    /// {
138    ///     let op1 = session1.operation("common_work");
139    ///     let _span1 = op1.measure_process();
140    ///     let _data1 = vec![1, 2, 3]; // 3 elements
141    /// }
142    ///
143    /// {
144    ///     let op2 = session2.operation("common_work");
145    ///     let _span2 = op2.measure_process();
146    ///     let _data2 = vec![4, 5]; // 2 elements
147    /// }
148    ///
149    /// let report1 = session1.to_report();
150    /// let report2 = session2.to_report();
151    ///
152    /// // Merged report shows combined statistics (2 total iterations)
153    /// let merged = Report::merge(&report1, &report2);
154    /// # }
155    /// ```
156    #[must_use]
157    pub fn merge(a: &Self, b: &Self) -> Self {
158        let mut merged_operations = a.operations.clone();
159
160        for (name, b_op) in &b.operations {
161            merged_operations
162                .entry(name.clone())
163                .and_modify(|a_op| {
164                    a_op.total_bytes_allocated = a_op
165                        .total_bytes_allocated
166                        .checked_add(b_op.total_bytes_allocated)
167                        .expect("merging bytes allocated overflows u64 - this indicates an unrealistic scenario");
168
169                    a_op.total_allocations_count = a_op
170                        .total_allocations_count
171                        .checked_add(b_op.total_allocations_count)
172                        .expect("merging allocations count overflows u64 - this indicates an unrealistic scenario");
173
174                    a_op.total_iterations = a_op
175                        .total_iterations
176                        .checked_add(b_op.total_iterations)
177                        .expect("merging iteration counts overflows u64 - this indicates an unrealistic scenario");
178                })
179                .or_insert_with(|| b_op.clone());
180        }
181
182        Self {
183            operations: merged_operations,
184        }
185    }
186
187    /// Prints the memory allocation statistics to stdout.
188    ///
189    /// Prints nothing if no operations were captured. This may indicate that the session
190    /// was part of a "list available benchmarks" probe run instead of some real activity,
191    /// in which case printing anything might violate the output protocol the tool is speaking.
192    #[cfg_attr(test, mutants::skip)] // Too difficult to test stdout output reliably - manually tested.
193    pub fn print_to_stdout(&self) {
194        if self.is_empty() {
195            return;
196        }
197        println!("{self}");
198    }
199
200    /// Whether there is any recorded activity in this report.
201    #[must_use]
202    pub fn is_empty(&self) -> bool {
203        self.operations.is_empty() || self.operations.values().all(|op| op.total_iterations == 0)
204    }
205
206    /// Returns an iterator over the operation names and their statistics.
207    ///
208    /// This allows programmatic access to the same data that would be printed by
209    /// [`print_to_stdout()`](Self::print_to_stdout).
210    ///
211    /// # Examples
212    ///
213    /// ```
214    /// use alloc_tracker::{Allocator, Session};
215    ///
216    /// #[global_allocator]
217    /// static ALLOCATOR: Allocator<std::alloc::System> = Allocator::system();
218    ///
219    /// # fn main() {
220    /// let session = Session::new();
221    /// {
222    ///     let operation = session.operation("test_work");
223    ///     let _span = operation.measure_process();
224    ///     let _data = vec![1, 2, 3, 4, 5]; // This allocates memory
225    /// }
226    ///
227    /// let report = session.to_report();
228    /// for (name, op) in report.operations() {
229    ///     println!(
230    ///         "Operation '{}' had {} iterations",
231    ///         name,
232    ///         op.total_iterations()
233    ///     );
234    ///     println!("Mean bytes per iteration: {}", op.mean());
235    ///     println!("Total bytes: {}", op.total_bytes_allocated());
236    /// }
237    /// # }
238    /// ```
239    pub fn operations(&self) -> impl Iterator<Item = (&str, &ReportOperation)> {
240        self.operations.iter().map(|(name, op)| (name.as_str(), op))
241    }
242}
243
244impl ReportOperation {
245    /// Returns the total bytes allocated across all iterations for this operation.
246    #[must_use]
247    pub fn total_bytes_allocated(&self) -> u64 {
248        self.total_bytes_allocated
249    }
250
251    /// Returns the total number of allocations across all iterations for this operation.
252    #[must_use]
253    pub fn total_allocations_count(&self) -> u64 {
254        self.total_allocations_count
255    }
256
257    /// Returns the total number of iterations recorded for this operation.
258    #[must_use]
259    pub fn total_iterations(&self) -> u64 {
260        self.total_iterations
261    }
262
263    /// Calculates the mean bytes allocated per iteration.
264    #[expect(
265        clippy::integer_division,
266        reason = "we accept loss of precision for mean calculation"
267    )]
268    #[expect(
269        clippy::arithmetic_side_effects,
270        reason = "division by zero is guarded by if-else"
271    )]
272    #[must_use]
273    pub fn mean_bytes(&self) -> u64 {
274        if self.total_iterations == 0 {
275            0
276        } else {
277            self.total_bytes_allocated / self.total_iterations
278        }
279    }
280
281    /// Calculates the mean number of allocations per iteration.
282    #[expect(
283        clippy::integer_division,
284        reason = "we accept loss of precision for mean calculation"
285    )]
286    #[expect(
287        clippy::arithmetic_side_effects,
288        reason = "division by zero is guarded by if-else"
289    )]
290    #[must_use]
291    pub fn mean_allocations(&self) -> u64 {
292        if self.total_iterations == 0 {
293            0
294        } else {
295            self.total_allocations_count / self.total_iterations
296        }
297    }
298
299    /// Calculates the mean bytes allocated per iteration.
300    ///
301    /// This is an alias for [`mean_bytes`](Self::mean_bytes) to maintain backward compatibility.
302    #[must_use]
303    pub fn mean(&self) -> u64 {
304        self.mean_bytes()
305    }
306}
307
308impl fmt::Display for ReportOperation {
309    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
310        write!(f, "{} bytes (mean)", self.mean_bytes())
311    }
312}
313
314// No API contract to test - output format is not guaranteed.
315#[cfg_attr(coverage_nightly, coverage(off))]
316impl fmt::Display for Report {
317    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
318        if self.operations.values().all(|op| op.total_iterations == 0) {
319            writeln!(f, "No allocation statistics captured.")?;
320        } else {
321            writeln!(f, "Allocation statistics:")?;
322            writeln!(f)?;
323
324            // Sort operations by name for consistent output
325            let mut sorted_ops: Vec<_> = self.operations.iter().collect();
326            sorted_ops.sort_by_key(|(name, _)| *name);
327
328            // Calculate column widths
329            let max_name_width = sorted_ops
330                .iter()
331                .map(|(name, _)| name.len())
332                .max()
333                .unwrap_or(0)
334                .max("Operation".len());
335
336            let max_bytes_width = sorted_ops
337                .iter()
338                .map(|(_, operation)| operation.mean_bytes().to_string().len())
339                .max()
340                .unwrap_or(0)
341                .max("Mean bytes".len());
342
343            let max_count_width = sorted_ops
344                .iter()
345                .map(|(_, operation)| operation.mean_allocations().to_string().len())
346                .max()
347                .unwrap_or(0)
348                .max("Mean count".len());
349
350            // Print table header
351            writeln!(
352                f,
353                "| {:<name_width$} | {:>bytes_width$} | {:>count_width$} |",
354                "Operation",
355                "Mean bytes",
356                "Mean count",
357                name_width = max_name_width,
358                bytes_width = max_bytes_width,
359                count_width = max_count_width
360            )?;
361
362            // Print separator
363            let separator_name_width = max_name_width
364                .checked_add(2)
365                .expect("operation name width fits in memory, adding 2 cannot overflow");
366            let separator_bytes_width = max_bytes_width
367                .checked_add(2)
368                .expect("bytes width fits in memory, adding 2 cannot overflow");
369            let separator_count_width = max_count_width
370                .checked_add(2)
371                .expect("count width fits in memory, adding 2 cannot overflow");
372            writeln!(
373                f,
374                "|{:-<name_width$}|{:-<bytes_width$}|{:-<count_width$}|",
375                "",
376                "",
377                "",
378                name_width = separator_name_width,
379                bytes_width = separator_bytes_width,
380                count_width = separator_count_width
381            )?;
382
383            // Print table rows
384            for (name, operation) in sorted_ops {
385                writeln!(
386                    f,
387                    "| {:<name_width$} | {:>bytes_width$} | {:>count_width$} |",
388                    name,
389                    operation.mean_bytes(),
390                    operation.mean_allocations(),
391                    name_width = max_name_width,
392                    bytes_width = max_bytes_width,
393                    count_width = max_count_width
394                )?;
395            }
396        }
397        Ok(())
398    }
399}
400
401#[cfg(test)]
402#[cfg_attr(coverage_nightly, coverage(off))]
403mod tests {
404    use std::panic::{RefUnwindSafe, UnwindSafe};
405
406    use super::*;
407    use crate::Session;
408    use crate::allocator::register_fake_allocation;
409
410    #[test]
411    fn new_report_is_empty() {
412        let report = Report::new();
413        assert!(report.is_empty());
414    }
415
416    #[test]
417    fn report_from_empty_session_is_empty() {
418        let session = Session::new();
419        let report = session.to_report();
420        assert!(report.is_empty());
421    }
422
423    #[test]
424    fn report_from_session_with_operations_is_not_empty() {
425        let session = Session::new();
426        {
427            let operation = session.operation("test");
428            let _span = operation.measure_thread();
429            register_fake_allocation(100, 1);
430        } // Span drops here, releasing the mutable borrow
431
432        let report = session.to_report();
433        assert!(!report.is_empty());
434    }
435
436    #[test]
437    fn merge_empty_reports() {
438        let report1 = Report::new();
439        let report2 = Report::new();
440        let merged = Report::merge(&report1, &report2);
441        assert!(merged.is_empty());
442    }
443
444    #[test]
445    fn merge_empty_with_non_empty() {
446        let session = Session::new();
447        {
448            let operation = session.operation("test");
449            let _span = operation.measure_thread();
450            register_fake_allocation(100, 1);
451        } // Span drops here
452
453        let report1 = Report::new();
454        let report2 = session.to_report();
455
456        let merged1 = Report::merge(&report1, &report2);
457        let merged2 = Report::merge(&report2, &report1);
458
459        assert!(!merged1.is_empty());
460        assert!(!merged2.is_empty());
461    }
462
463    #[test]
464    fn merge_different_operations() {
465        let session1 = Session::new();
466        let session2 = Session::new();
467
468        {
469            let op1 = session1.operation("test1");
470            let _span1 = op1.measure_thread();
471            register_fake_allocation(100, 1);
472        } // Span drops here
473
474        {
475            let op2 = session2.operation("test2");
476            let _span2 = op2.measure_thread();
477            register_fake_allocation(200, 2);
478        } // Span drops here
479
480        let report1 = session1.to_report();
481        let report2 = session2.to_report();
482        let merged = Report::merge(&report1, &report2);
483
484        assert_eq!(merged.operations.len(), 2);
485        assert!(merged.operations.contains_key("test1"));
486        assert!(merged.operations.contains_key("test2"));
487    }
488
489    #[test]
490    fn merge_same_operations() {
491        let session1 = Session::new();
492        let session2 = Session::new();
493
494        {
495            let op1 = session1.operation("test");
496            let _span1 = op1.measure_thread();
497            register_fake_allocation(100, 1);
498        } // Span drops here
499
500        {
501            let op2 = session2.operation("test");
502            let _span2 = op2.measure_thread();
503            register_fake_allocation(200, 2);
504        } // Span drops here
505
506        let report1 = session1.to_report();
507        let report2 = session2.to_report();
508        let merged = Report::merge(&report1, &report2);
509
510        assert_eq!(merged.operations.len(), 1);
511        let merged_op = merged.operations.get("test").unwrap();
512        assert_eq!(merged_op.total_iterations, 2); // 1 + 1
513        assert_eq!(merged_op.total_bytes_allocated, 300); // 100 + 200
514        assert_eq!(merged_op.total_allocations_count, 3); // 1 + 2
515    }
516
517    #[test]
518    fn report_clone() {
519        let session = Session::new();
520        {
521            let operation = session.operation("test");
522            let _span = operation.measure_thread();
523            register_fake_allocation(100, 1);
524        } // Span drops here
525
526        let report1 = session.to_report();
527        let report2 = report1.clone();
528
529        assert_eq!(report1.operations.len(), report2.operations.len());
530    }
531
532    #[test]
533    fn report_operation_total_allocations_count_zero() {
534        let operation = ReportOperation {
535            total_bytes_allocated: 0,
536            total_allocations_count: 0,
537            total_iterations: 1,
538        };
539
540        assert_eq!(operation.total_allocations_count(), 0);
541    }
542
543    #[test]
544    fn report_operation_total_allocations_count_multiple() {
545        let operation = ReportOperation {
546            total_bytes_allocated: 500,
547            total_allocations_count: 25,
548            total_iterations: 5,
549        };
550
551        assert_eq!(operation.total_allocations_count(), 25);
552    }
553
554    #[test]
555    fn report_operation_total_allocations_count_consistency_with_session() {
556        let session = Session::new();
557        {
558            let operation = session.operation("test_consistency");
559            let _span = operation.measure_thread();
560            // Simulate 3 allocations
561            register_fake_allocation(300, 3);
562        } // Span drops here
563
564        let report = session.to_report();
565        let operations: Vec<_> = report.operations().collect();
566        assert_eq!(operations.len(), 1);
567
568        let (_name, report_op) = operations.first().unwrap();
569        assert_eq!(report_op.total_allocations_count(), 3);
570        assert_eq!(report_op.total_bytes_allocated(), 300);
571        assert_eq!(report_op.total_iterations(), 1);
572    }
573
574    // Static assertions for thread safety.
575    static_assertions::assert_impl_all!(Report: Send, Sync);
576    static_assertions::assert_impl_all!(ReportOperation: Send, Sync);
577
578    // Static assertions for unwind safety.
579    static_assertions::assert_impl_all!(Report: UnwindSafe, RefUnwindSafe);
580    static_assertions::assert_impl_all!(
581        ReportOperation: UnwindSafe, RefUnwindSafe
582    );
583
584    #[test]
585    fn report_operation_display_shows_mean_bytes() {
586        let operation = ReportOperation {
587            total_bytes_allocated: 1000,
588            total_allocations_count: 10,
589            total_iterations: 4,
590        };
591
592        let display_output = operation.to_string();
593        assert!(display_output.contains("bytes (mean)"));
594        assert!(display_output.contains("250")); // 1000 / 4 = 250 mean bytes
595    }
596
597    #[test]
598    fn empty_report_display_shows_no_statistics_message() {
599        let report = Report::new();
600        let display_output = report.to_string();
601        assert!(display_output.contains("No allocation statistics captured."));
602    }
603}