Skip to main content

ftui_render/
counting_writer.rs

1#![forbid(unsafe_code)]
2
3//! Counting writer for tracking bytes emitted.
4//!
5//! This module provides a wrapper around any `Write` implementation that
6//! counts the number of bytes written. This is used to verify O(changes)
7//! output size for diff-based rendering.
8//!
9//! # Usage
10//!
11//! ```
12//! use ftui_render::counting_writer::CountingWriter;
13//! use std::io::Write;
14//!
15//! let mut buffer = Vec::new();
16//! let mut writer = CountingWriter::new(&mut buffer);
17//!
18//! writer.write_all(b"Hello, world!").unwrap();
19//! assert_eq!(writer.bytes_written(), 13);
20//!
21//! writer.reset_counter();
22//! writer.write_all(b"Hi").unwrap();
23//! assert_eq!(writer.bytes_written(), 2);
24//! ```
25
26use std::io::{self, Write};
27use std::time::{Duration, Instant};
28
29/// A write wrapper that counts bytes written.
30///
31/// Wraps any `Write` implementation and tracks the total number of bytes
32/// written through it. The counter can be reset between operations.
33#[derive(Debug)]
34pub struct CountingWriter<W> {
35    /// The underlying writer.
36    inner: W,
37    /// Total bytes written since last reset.
38    bytes_written: u64,
39}
40
41impl<W> CountingWriter<W> {
42    /// Create a new counting writer wrapping the given writer.
43    #[inline]
44    pub fn new(inner: W) -> Self {
45        Self {
46            inner,
47            bytes_written: 0,
48        }
49    }
50
51    /// Get the number of bytes written since the last reset.
52    #[inline]
53    pub fn bytes_written(&self) -> u64 {
54        self.bytes_written
55    }
56
57    /// Reset the byte counter to zero.
58    #[inline]
59    pub fn reset_counter(&mut self) {
60        self.bytes_written = 0;
61    }
62
63    /// Get a reference to the underlying writer.
64    #[inline]
65    pub fn inner(&self) -> &W {
66        &self.inner
67    }
68
69    /// Get a mutable reference to the underlying writer.
70    #[inline]
71    pub fn inner_mut(&mut self) -> &mut W {
72        &mut self.inner
73    }
74
75    /// Consume the counting writer and return the inner writer.
76    #[inline]
77    pub fn into_inner(self) -> W {
78        self.inner
79    }
80}
81
82impl<W: Write> Write for CountingWriter<W> {
83    fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
84        let n = self.inner.write(buf)?;
85        self.bytes_written += n as u64;
86        Ok(n)
87    }
88
89    fn flush(&mut self) -> io::Result<()> {
90        self.inner.flush()
91    }
92
93    fn write_all(&mut self, buf: &[u8]) -> io::Result<()> {
94        self.inner.write_all(buf)?;
95        self.bytes_written += buf.len() as u64;
96        Ok(())
97    }
98}
99
100/// Statistics from a present() operation.
101///
102/// Captures metrics for verifying O(changes) output size and detecting
103/// performance regressions.
104#[derive(Debug, Clone, PartialEq, Eq)]
105pub struct PresentStats {
106    /// Bytes emitted for this frame.
107    pub bytes_emitted: u64,
108    /// Number of cells changed.
109    pub cells_changed: usize,
110    /// Number of runs (groups of consecutive changes).
111    pub run_count: usize,
112    /// Time spent in present().
113    pub duration: Duration,
114}
115
116impl PresentStats {
117    /// Create new stats with the given values.
118    #[inline]
119    pub fn new(
120        bytes_emitted: u64,
121        cells_changed: usize,
122        run_count: usize,
123        duration: Duration,
124    ) -> Self {
125        Self {
126            bytes_emitted,
127            cells_changed,
128            run_count,
129            duration,
130        }
131    }
132
133    /// Calculate bytes per cell changed.
134    ///
135    /// Returns 0.0 if no cells were changed.
136    #[inline]
137    pub fn bytes_per_cell(&self) -> f64 {
138        if self.cells_changed == 0 {
139            0.0
140        } else {
141            self.bytes_emitted as f64 / self.cells_changed as f64
142        }
143    }
144
145    /// Calculate bytes per run.
146    ///
147    /// Returns 0.0 if no runs.
148    #[inline]
149    pub fn bytes_per_run(&self) -> f64 {
150        if self.run_count == 0 {
151            0.0
152        } else {
153            self.bytes_emitted as f64 / self.run_count as f64
154        }
155    }
156
157    /// Check if output is within the expected budget.
158    ///
159    /// Uses conservative estimates for worst-case bytes per cell.
160    #[inline]
161    pub fn within_budget(&self) -> bool {
162        let budget = expected_max_bytes(self.cells_changed, self.run_count);
163        self.bytes_emitted <= budget
164    }
165
166    /// Log stats at debug level (requires tracing feature).
167    #[cfg(feature = "tracing")]
168    pub fn log(&self) {
169        tracing::debug!(
170            bytes = self.bytes_emitted,
171            cells_changed = self.cells_changed,
172            runs = self.run_count,
173            duration_us = self.duration.as_micros() as u64,
174            bytes_per_cell = format!("{:.1}", self.bytes_per_cell()),
175            "Present stats"
176        );
177    }
178
179    /// Log stats at debug level (no-op without tracing feature).
180    #[cfg(not(feature = "tracing"))]
181    pub fn log(&self) {
182        // No-op without tracing
183    }
184}
185
186impl Default for PresentStats {
187    fn default() -> Self {
188        Self {
189            bytes_emitted: 0,
190            cells_changed: 0,
191            run_count: 0,
192            duration: Duration::ZERO,
193        }
194    }
195}
196
197/// Expected bytes per cell change (approximate worst case).
198///
199/// Worst case: cursor move (10) + full SGR reset+apply (25) + 4-byte UTF-8 char
200pub const BYTES_PER_CELL_MAX: u64 = 40;
201
202/// Bytes for sync output wrapper.
203pub const SYNC_OVERHEAD: u64 = 20;
204
205/// Bytes for cursor move sequence (CUP).
206pub const BYTES_PER_CURSOR_MOVE: u64 = 10;
207
208/// Calculate expected maximum bytes for a frame with given changes.
209///
210/// This is a conservative budget for regression testing.
211#[inline]
212pub fn expected_max_bytes(cells_changed: usize, runs: usize) -> u64 {
213    // cursor move per run + cells * max_per_cell + sync overhead
214    (runs as u64 * BYTES_PER_CURSOR_MOVE)
215        + (cells_changed as u64 * BYTES_PER_CELL_MAX)
216        + SYNC_OVERHEAD
217}
218
219/// A stats collector for measuring present operations.
220///
221/// Use this to wrap present() calls and collect statistics.
222#[derive(Debug)]
223pub struct StatsCollector {
224    start: Instant,
225    cells_changed: usize,
226    run_count: usize,
227}
228
229impl StatsCollector {
230    /// Start collecting stats for a present operation.
231    #[inline]
232    pub fn start(cells_changed: usize, run_count: usize) -> Self {
233        Self {
234            start: Instant::now(),
235            cells_changed,
236            run_count,
237        }
238    }
239
240    /// Finish collecting and return stats.
241    #[inline]
242    pub fn finish(self, bytes_emitted: u64) -> PresentStats {
243        PresentStats {
244            bytes_emitted,
245            cells_changed: self.cells_changed,
246            run_count: self.run_count,
247            duration: self.start.elapsed(),
248        }
249    }
250}
251
252#[cfg(test)]
253mod tests {
254    use super::*;
255
256    // ============== CountingWriter Tests ==============
257
258    #[test]
259    fn counting_writer_basic() {
260        let mut buffer = Vec::new();
261        let mut writer = CountingWriter::new(&mut buffer);
262
263        writer.write_all(b"Hello").unwrap();
264        assert_eq!(writer.bytes_written(), 5);
265
266        writer.write_all(b", world!").unwrap();
267        assert_eq!(writer.bytes_written(), 13);
268    }
269
270    #[test]
271    fn counting_writer_reset() {
272        let mut buffer = Vec::new();
273        let mut writer = CountingWriter::new(&mut buffer);
274
275        writer.write_all(b"Hello").unwrap();
276        assert_eq!(writer.bytes_written(), 5);
277
278        writer.reset_counter();
279        assert_eq!(writer.bytes_written(), 0);
280
281        writer.write_all(b"Hi").unwrap();
282        assert_eq!(writer.bytes_written(), 2);
283    }
284
285    #[test]
286    fn counting_writer_write() {
287        let mut buffer = Vec::new();
288        let mut writer = CountingWriter::new(&mut buffer);
289
290        // write() may write partial buffer
291        let n = writer.write(b"Hello").unwrap();
292        assert_eq!(n, 5);
293        assert_eq!(writer.bytes_written(), 5);
294    }
295
296    #[test]
297    fn counting_writer_flush() {
298        let mut buffer = Vec::new();
299        let mut writer = CountingWriter::new(&mut buffer);
300
301        writer.write_all(b"test").unwrap();
302        writer.flush().unwrap();
303
304        // flush doesn't change byte count
305        assert_eq!(writer.bytes_written(), 4);
306    }
307
308    #[test]
309    fn counting_writer_into_inner() {
310        let buffer: Vec<u8> = Vec::new();
311        let writer = CountingWriter::new(buffer);
312        let inner = writer.into_inner();
313        assert!(inner.is_empty());
314    }
315
316    #[test]
317    fn counting_writer_inner_ref() {
318        let mut buffer = Vec::new();
319        let mut writer = CountingWriter::new(&mut buffer);
320        writer.write_all(b"test").unwrap();
321
322        assert_eq!(writer.inner().len(), 4);
323    }
324
325    // ============== PresentStats Tests ==============
326
327    #[test]
328    fn stats_bytes_per_cell() {
329        let stats = PresentStats::new(100, 10, 2, Duration::from_micros(50));
330        assert!((stats.bytes_per_cell() - 10.0).abs() < f64::EPSILON);
331    }
332
333    #[test]
334    fn stats_bytes_per_cell_zero() {
335        let stats = PresentStats::new(0, 0, 0, Duration::ZERO);
336        assert!((stats.bytes_per_cell() - 0.0).abs() < f64::EPSILON);
337    }
338
339    #[test]
340    fn stats_bytes_per_run() {
341        let stats = PresentStats::new(100, 10, 5, Duration::from_micros(50));
342        assert!((stats.bytes_per_run() - 20.0).abs() < f64::EPSILON);
343    }
344
345    #[test]
346    fn stats_bytes_per_run_zero() {
347        let stats = PresentStats::new(0, 0, 0, Duration::ZERO);
348        assert!((stats.bytes_per_run() - 0.0).abs() < f64::EPSILON);
349    }
350
351    #[test]
352    fn stats_within_budget_pass() {
353        // 10 cells, 2 runs
354        // Budget = 2*10 + 10*40 + 20 = 440
355        let stats = PresentStats::new(200, 10, 2, Duration::from_micros(50));
356        assert!(stats.within_budget());
357    }
358
359    #[test]
360    fn stats_within_budget_fail() {
361        // 10 cells, 2 runs
362        // Budget = 2*10 + 10*40 + 20 = 440
363        let stats = PresentStats::new(500, 10, 2, Duration::from_micros(50));
364        assert!(!stats.within_budget());
365    }
366
367    #[test]
368    fn stats_default() {
369        let stats = PresentStats::default();
370        assert_eq!(stats.bytes_emitted, 0);
371        assert_eq!(stats.cells_changed, 0);
372        assert_eq!(stats.run_count, 0);
373        assert_eq!(stats.duration, Duration::ZERO);
374    }
375
376    // ============== Budget Calculation Tests ==============
377
378    #[test]
379    fn expected_max_bytes_calculation() {
380        // 10 cells, 2 runs
381        let budget = expected_max_bytes(10, 2);
382        // 2*10 + 10*40 + 20 = 440
383        assert_eq!(budget, 440);
384    }
385
386    #[test]
387    fn expected_max_bytes_empty() {
388        let budget = expected_max_bytes(0, 0);
389        // Just sync overhead
390        assert_eq!(budget, SYNC_OVERHEAD);
391    }
392
393    #[test]
394    fn expected_max_bytes_single_cell() {
395        let budget = expected_max_bytes(1, 1);
396        // 1*10 + 1*40 + 20 = 70
397        assert_eq!(budget, 70);
398    }
399
400    // ============== StatsCollector Tests ==============
401
402    #[test]
403    fn stats_collector_basic() {
404        let collector = StatsCollector::start(10, 2);
405        std::thread::sleep(Duration::from_micros(100));
406        let stats = collector.finish(150);
407
408        assert_eq!(stats.cells_changed, 10);
409        assert_eq!(stats.run_count, 2);
410        assert_eq!(stats.bytes_emitted, 150);
411        assert!(stats.duration >= Duration::from_micros(100));
412    }
413
414    // ============== Integration Tests ==============
415
416    #[test]
417    fn full_stats_workflow() {
418        let mut buffer = Vec::new();
419        let mut writer = CountingWriter::new(&mut buffer);
420
421        // Simulate present operation
422        let collector = StatsCollector::start(5, 1);
423
424        writer.write_all(b"\x1b[1;1H").unwrap(); // CUP
425        writer.write_all(b"\x1b[0m").unwrap(); // SGR reset
426        writer.write_all(b"Hello").unwrap(); // Content
427        writer.flush().unwrap();
428
429        let stats = collector.finish(writer.bytes_written());
430
431        assert_eq!(stats.cells_changed, 5);
432        assert_eq!(stats.run_count, 1);
433        assert_eq!(stats.bytes_emitted, 6 + 4 + 5); // 15 bytes
434        assert!(stats.within_budget());
435    }
436
437    #[test]
438    fn spinner_update_budget() {
439        // Single cell update should be well under budget
440        let stats = PresentStats::new(35, 1, 1, Duration::from_micros(10));
441        assert!(
442            stats.within_budget(),
443            "Single cell update should be within budget"
444        );
445        assert!(
446            stats.bytes_emitted < 50,
447            "Spinner tick should be < 50 bytes"
448        );
449    }
450
451    #[test]
452    fn status_bar_budget() {
453        // 80-column status bar
454        let stats = PresentStats::new(2500, 80, 1, Duration::from_micros(100));
455        assert!(
456            stats.within_budget(),
457            "Status bar update should be within budget"
458        );
459        assert!(
460            stats.bytes_emitted < 3500,
461            "Status bar should be < 3500 bytes"
462        );
463    }
464
465    #[test]
466    fn full_redraw_budget() {
467        // Full 80x24 screen
468        let stats = PresentStats::new(50000, 1920, 24, Duration::from_micros(1000));
469        assert!(stats.within_budget(), "Full redraw should be within budget");
470        assert!(stats.bytes_emitted < 80000, "Full redraw should be < 80KB");
471    }
472}