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 web_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    #[must_use]
66    pub fn inner(&self) -> &W {
67        &self.inner
68    }
69
70    /// Get a mutable reference to the underlying writer.
71    #[inline]
72    #[must_use]
73    pub fn inner_mut(&mut self) -> &mut W {
74        &mut self.inner
75    }
76
77    /// Consume the counting writer and return the inner writer.
78    #[inline]
79    #[must_use]
80    pub fn into_inner(self) -> W {
81        self.inner
82    }
83}
84
85impl<W: Write> Write for CountingWriter<W> {
86    fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
87        let n = self.inner.write(buf)?;
88        self.bytes_written += n as u64;
89        Ok(n)
90    }
91
92    fn flush(&mut self) -> io::Result<()> {
93        self.inner.flush()
94    }
95
96    fn write_all(&mut self, buf: &[u8]) -> io::Result<()> {
97        self.inner.write_all(buf)?;
98        self.bytes_written += buf.len() as u64;
99        Ok(())
100    }
101}
102
103/// Statistics from a present() operation.
104///
105/// Captures metrics for verifying O(changes) output size and detecting
106/// performance regressions.
107#[derive(Debug, Clone, PartialEq, Eq)]
108pub struct PresentStats {
109    /// Bytes emitted for this frame.
110    pub bytes_emitted: u64,
111    /// Number of cells changed.
112    pub cells_changed: usize,
113    /// Number of runs (groups of consecutive changes).
114    pub run_count: usize,
115    /// Time spent in present().
116    pub duration: Duration,
117}
118
119impl PresentStats {
120    /// Create new stats with the given values.
121    #[inline]
122    pub fn new(
123        bytes_emitted: u64,
124        cells_changed: usize,
125        run_count: usize,
126        duration: Duration,
127    ) -> Self {
128        Self {
129            bytes_emitted,
130            cells_changed,
131            run_count,
132            duration,
133        }
134    }
135
136    /// Calculate bytes per cell changed.
137    ///
138    /// Returns 0.0 if no cells were changed.
139    #[inline]
140    pub fn bytes_per_cell(&self) -> f64 {
141        if self.cells_changed == 0 {
142            0.0
143        } else {
144            self.bytes_emitted as f64 / self.cells_changed as f64
145        }
146    }
147
148    /// Calculate bytes per run.
149    ///
150    /// Returns 0.0 if no runs.
151    #[inline]
152    pub fn bytes_per_run(&self) -> f64 {
153        if self.run_count == 0 {
154            0.0
155        } else {
156            self.bytes_emitted as f64 / self.run_count as f64
157        }
158    }
159
160    /// Check if output is within the expected budget.
161    ///
162    /// Uses conservative estimates for worst-case bytes per cell.
163    #[inline]
164    pub fn within_budget(&self) -> bool {
165        let budget = expected_max_bytes(self.cells_changed, self.run_count);
166        self.bytes_emitted <= budget
167    }
168
169    /// Log stats at debug level (requires tracing feature).
170    #[cfg(feature = "tracing")]
171    pub fn log(&self) {
172        tracing::debug!(
173            bytes = self.bytes_emitted,
174            cells_changed = self.cells_changed,
175            runs = self.run_count,
176            duration_us = self.duration.as_micros() as u64,
177            bytes_per_cell = format!("{:.1}", self.bytes_per_cell()),
178            "Present stats"
179        );
180    }
181
182    /// Log stats at debug level (no-op without tracing feature).
183    #[cfg(not(feature = "tracing"))]
184    pub fn log(&self) {
185        // No-op without tracing
186    }
187}
188
189impl Default for PresentStats {
190    fn default() -> Self {
191        Self {
192            bytes_emitted: 0,
193            cells_changed: 0,
194            run_count: 0,
195            duration: Duration::ZERO,
196        }
197    }
198}
199
200/// Expected bytes per cell change (approximate worst case).
201///
202/// Worst case: cursor move (10) + full SGR reset+apply (25) + 4-byte UTF-8 char
203pub const BYTES_PER_CELL_MAX: u64 = 40;
204
205/// Bytes for sync output wrapper.
206pub const SYNC_OVERHEAD: u64 = 20;
207
208/// Bytes for cursor move sequence (CUP).
209pub const BYTES_PER_CURSOR_MOVE: u64 = 10;
210
211/// Calculate expected maximum bytes for a frame with given changes.
212///
213/// This is a conservative budget for regression testing.
214#[inline]
215pub fn expected_max_bytes(cells_changed: usize, runs: usize) -> u64 {
216    // cursor move per run + cells * max_per_cell + sync overhead
217    (runs as u64 * BYTES_PER_CURSOR_MOVE)
218        + (cells_changed as u64 * BYTES_PER_CELL_MAX)
219        + SYNC_OVERHEAD
220}
221
222/// A stats collector for measuring present operations.
223///
224/// Use this to wrap present() calls and collect statistics.
225#[derive(Debug)]
226pub struct StatsCollector {
227    start: Instant,
228    cells_changed: usize,
229    run_count: usize,
230}
231
232impl StatsCollector {
233    /// Start collecting stats for a present operation.
234    #[inline]
235    pub fn start(cells_changed: usize, run_count: usize) -> Self {
236        Self {
237            start: Instant::now(),
238            cells_changed,
239            run_count,
240        }
241    }
242
243    /// Finish collecting and return stats.
244    #[inline]
245    pub fn finish(self, bytes_emitted: u64) -> PresentStats {
246        PresentStats {
247            bytes_emitted,
248            cells_changed: self.cells_changed,
249            run_count: self.run_count,
250            duration: self.start.elapsed(),
251        }
252    }
253}
254
255#[cfg(test)]
256mod tests {
257    use super::*;
258
259    // ============== CountingWriter Tests ==============
260
261    #[test]
262    fn counting_writer_basic() {
263        let mut buffer = Vec::new();
264        let mut writer = CountingWriter::new(&mut buffer);
265
266        writer.write_all(b"Hello").unwrap();
267        assert_eq!(writer.bytes_written(), 5);
268
269        writer.write_all(b", world!").unwrap();
270        assert_eq!(writer.bytes_written(), 13);
271    }
272
273    #[test]
274    fn counting_writer_reset() {
275        let mut buffer = Vec::new();
276        let mut writer = CountingWriter::new(&mut buffer);
277
278        writer.write_all(b"Hello").unwrap();
279        assert_eq!(writer.bytes_written(), 5);
280
281        writer.reset_counter();
282        assert_eq!(writer.bytes_written(), 0);
283
284        writer.write_all(b"Hi").unwrap();
285        assert_eq!(writer.bytes_written(), 2);
286    }
287
288    #[test]
289    fn counting_writer_write() {
290        let mut buffer = Vec::new();
291        let mut writer = CountingWriter::new(&mut buffer);
292
293        // write() may write partial buffer
294        let n = writer.write(b"Hello").unwrap();
295        assert_eq!(n, 5);
296        assert_eq!(writer.bytes_written(), 5);
297    }
298
299    #[test]
300    fn counting_writer_flush() {
301        let mut buffer = Vec::new();
302        let mut writer = CountingWriter::new(&mut buffer);
303
304        writer.write_all(b"test").unwrap();
305        writer.flush().unwrap();
306
307        // flush doesn't change byte count
308        assert_eq!(writer.bytes_written(), 4);
309    }
310
311    #[test]
312    fn counting_writer_into_inner() {
313        let buffer: Vec<u8> = Vec::new();
314        let writer = CountingWriter::new(buffer);
315        let inner = writer.into_inner();
316        assert!(inner.is_empty());
317    }
318
319    #[test]
320    fn counting_writer_inner_ref() {
321        let mut buffer = Vec::new();
322        let mut writer = CountingWriter::new(&mut buffer);
323        writer.write_all(b"test").unwrap();
324
325        assert_eq!(writer.inner().len(), 4);
326    }
327
328    // ============== PresentStats Tests ==============
329
330    #[test]
331    fn stats_bytes_per_cell() {
332        let stats = PresentStats::new(100, 10, 2, Duration::from_micros(50));
333        assert!((stats.bytes_per_cell() - 10.0).abs() < f64::EPSILON);
334    }
335
336    #[test]
337    fn stats_bytes_per_cell_zero() {
338        let stats = PresentStats::new(0, 0, 0, Duration::ZERO);
339        assert!((stats.bytes_per_cell() - 0.0).abs() < f64::EPSILON);
340    }
341
342    #[test]
343    fn stats_bytes_per_run() {
344        let stats = PresentStats::new(100, 10, 5, Duration::from_micros(50));
345        assert!((stats.bytes_per_run() - 20.0).abs() < f64::EPSILON);
346    }
347
348    #[test]
349    fn stats_bytes_per_run_zero() {
350        let stats = PresentStats::new(0, 0, 0, Duration::ZERO);
351        assert!((stats.bytes_per_run() - 0.0).abs() < f64::EPSILON);
352    }
353
354    #[test]
355    fn stats_within_budget_pass() {
356        // 10 cells, 2 runs
357        // Budget = 2*10 + 10*40 + 20 = 440
358        let stats = PresentStats::new(200, 10, 2, Duration::from_micros(50));
359        assert!(stats.within_budget());
360    }
361
362    #[test]
363    fn stats_within_budget_fail() {
364        // 10 cells, 2 runs
365        // Budget = 2*10 + 10*40 + 20 = 440
366        let stats = PresentStats::new(500, 10, 2, Duration::from_micros(50));
367        assert!(!stats.within_budget());
368    }
369
370    #[test]
371    fn stats_default() {
372        let stats = PresentStats::default();
373        assert_eq!(stats.bytes_emitted, 0);
374        assert_eq!(stats.cells_changed, 0);
375        assert_eq!(stats.run_count, 0);
376        assert_eq!(stats.duration, Duration::ZERO);
377    }
378
379    // ============== Budget Calculation Tests ==============
380
381    #[test]
382    fn expected_max_bytes_calculation() {
383        // 10 cells, 2 runs
384        let budget = expected_max_bytes(10, 2);
385        // 2*10 + 10*40 + 20 = 440
386        assert_eq!(budget, 440);
387    }
388
389    #[test]
390    fn expected_max_bytes_empty() {
391        let budget = expected_max_bytes(0, 0);
392        // Just sync overhead
393        assert_eq!(budget, SYNC_OVERHEAD);
394    }
395
396    #[test]
397    fn expected_max_bytes_single_cell() {
398        let budget = expected_max_bytes(1, 1);
399        // 1*10 + 1*40 + 20 = 70
400        assert_eq!(budget, 70);
401    }
402
403    // ============== StatsCollector Tests ==============
404
405    #[test]
406    fn stats_collector_basic() {
407        let collector = StatsCollector::start(10, 2);
408        std::thread::sleep(Duration::from_micros(100));
409        let stats = collector.finish(150);
410
411        assert_eq!(stats.cells_changed, 10);
412        assert_eq!(stats.run_count, 2);
413        assert_eq!(stats.bytes_emitted, 150);
414        assert!(stats.duration >= Duration::from_micros(100));
415    }
416
417    // ============== Integration Tests ==============
418
419    #[test]
420    fn full_stats_workflow() {
421        let mut buffer = Vec::new();
422        let mut writer = CountingWriter::new(&mut buffer);
423
424        // Simulate present operation
425        let collector = StatsCollector::start(5, 1);
426
427        writer.write_all(b"\x1b[1;1H").unwrap(); // CUP
428        writer.write_all(b"\x1b[0m").unwrap(); // SGR reset
429        writer.write_all(b"Hello").unwrap(); // Content
430        writer.flush().unwrap();
431
432        let stats = collector.finish(writer.bytes_written());
433
434        assert_eq!(stats.cells_changed, 5);
435        assert_eq!(stats.run_count, 1);
436        assert_eq!(stats.bytes_emitted, 6 + 4 + 5); // 15 bytes
437        assert!(stats.within_budget());
438    }
439
440    #[test]
441    fn spinner_update_budget() {
442        // Single cell update should be well under budget
443        let stats = PresentStats::new(35, 1, 1, Duration::from_micros(10));
444        assert!(
445            stats.within_budget(),
446            "Single cell update should be within budget"
447        );
448        assert!(
449            stats.bytes_emitted < 50,
450            "Spinner tick should be < 50 bytes"
451        );
452    }
453
454    #[test]
455    fn status_bar_budget() {
456        // 80-column status bar
457        let stats = PresentStats::new(2500, 80, 1, Duration::from_micros(100));
458        assert!(
459            stats.within_budget(),
460            "Status bar update should be within budget"
461        );
462        assert!(
463            stats.bytes_emitted < 3500,
464            "Status bar should be < 3500 bytes"
465        );
466    }
467
468    #[test]
469    fn full_redraw_budget() {
470        // Full 80x24 screen
471        let stats = PresentStats::new(50000, 1920, 24, Duration::from_micros(1000));
472        assert!(stats.within_budget(), "Full redraw should be within budget");
473        assert!(stats.bytes_emitted < 80000, "Full redraw should be < 80KB");
474    }
475
476    // --- CountingWriter edge cases ---
477
478    #[test]
479    fn counting_writer_debug() {
480        let buffer: Vec<u8> = Vec::new();
481        let writer = CountingWriter::new(buffer);
482        let dbg = format!("{:?}", writer);
483        assert!(dbg.contains("CountingWriter"), "Debug: {dbg}");
484    }
485
486    #[test]
487    fn counting_writer_inner_mut() {
488        let mut writer = CountingWriter::new(Vec::<u8>::new());
489        writer.write_all(b"hello").unwrap();
490        // Modify inner via inner_mut
491        writer.inner_mut().push(b'!');
492        assert_eq!(writer.inner(), &b"hello!"[..]);
493        // Byte counter unchanged by direct inner manipulation
494        assert_eq!(writer.bytes_written(), 5);
495    }
496
497    #[test]
498    fn counting_writer_empty_write() {
499        let mut buffer = Vec::new();
500        let mut writer = CountingWriter::new(&mut buffer);
501        writer.write_all(b"").unwrap();
502        assert_eq!(writer.bytes_written(), 0);
503        let n = writer.write(b"").unwrap();
504        assert_eq!(n, 0);
505        assert_eq!(writer.bytes_written(), 0);
506    }
507
508    #[test]
509    fn counting_writer_multiple_resets() {
510        let mut buffer = Vec::new();
511        let mut writer = CountingWriter::new(&mut buffer);
512        writer.write_all(b"abc").unwrap();
513        writer.reset_counter();
514        writer.reset_counter();
515        assert_eq!(writer.bytes_written(), 0);
516        writer.write_all(b"de").unwrap();
517        assert_eq!(writer.bytes_written(), 2);
518    }
519
520    #[test]
521    fn counting_writer_accumulates_u64() {
522        let mut buffer = Vec::new();
523        let mut writer = CountingWriter::new(&mut buffer);
524        // Write enough to test u64 accumulation (though not near overflow)
525        for _ in 0..1000 {
526            writer.write_all(b"x").unwrap();
527        }
528        assert_eq!(writer.bytes_written(), 1000);
529    }
530
531    #[test]
532    fn counting_writer_multiple_flushes() {
533        let mut buffer = Vec::new();
534        let mut writer = CountingWriter::new(&mut buffer);
535        writer.write_all(b"test").unwrap();
536        writer.flush().unwrap();
537        writer.flush().unwrap();
538        writer.flush().unwrap();
539        assert_eq!(writer.bytes_written(), 4);
540    }
541
542    #[test]
543    fn counting_writer_into_inner_preserves_data() {
544        let mut writer = CountingWriter::new(Vec::<u8>::new());
545        writer.write_all(b"hello world").unwrap();
546        let inner = writer.into_inner();
547        assert_eq!(&inner, b"hello world");
548    }
549
550    #[test]
551    fn counting_writer_initial_state() {
552        let buffer: Vec<u8> = Vec::new();
553        let writer = CountingWriter::new(buffer);
554        assert_eq!(writer.bytes_written(), 0);
555        assert!(writer.inner().is_empty());
556    }
557
558    // --- PresentStats edge cases ---
559
560    #[test]
561    fn present_stats_debug_clone_eq() {
562        let a = PresentStats::new(100, 10, 2, Duration::from_micros(50));
563        let dbg = format!("{:?}", a);
564        assert!(dbg.contains("PresentStats"), "Debug: {dbg}");
565        let cloned = a.clone();
566        assert_eq!(a, cloned);
567        let b = PresentStats::new(200, 10, 2, Duration::from_micros(50));
568        assert_ne!(a, b);
569    }
570
571    #[test]
572    fn present_stats_log_noop() {
573        let stats = PresentStats::default();
574        stats.log(); // Should not panic (noop without tracing)
575    }
576
577    #[test]
578    fn present_stats_large_values() {
579        let stats = PresentStats::new(u64::MAX, usize::MAX, usize::MAX, Duration::MAX);
580        assert_eq!(stats.bytes_emitted, u64::MAX);
581        assert_eq!(stats.cells_changed, usize::MAX);
582    }
583
584    #[test]
585    fn present_stats_bytes_per_cell_fractional() {
586        let stats = PresentStats::new(10, 3, 1, Duration::ZERO);
587        let bpc = stats.bytes_per_cell();
588        assert!((bpc - 3.333333333).abs() < 0.001);
589    }
590
591    #[test]
592    fn present_stats_bytes_per_run_fractional() {
593        let stats = PresentStats::new(10, 5, 3, Duration::ZERO);
594        let bpr = stats.bytes_per_run();
595        assert!((bpr - 3.333333333).abs() < 0.001);
596    }
597
598    #[test]
599    fn present_stats_within_budget_at_exact_boundary() {
600        // Budget for 10 cells, 2 runs: 2*10 + 10*40 + 20 = 440
601        let budget = expected_max_bytes(10, 2);
602        assert_eq!(budget, 440);
603
604        let at_boundary = PresentStats::new(440, 10, 2, Duration::ZERO);
605        assert!(at_boundary.within_budget());
606
607        let over_boundary = PresentStats::new(441, 10, 2, Duration::ZERO);
608        assert!(!over_boundary.within_budget());
609    }
610
611    // --- Constants ---
612
613    #[test]
614    fn constants_values() {
615        assert_eq!(BYTES_PER_CELL_MAX, 40);
616        assert_eq!(SYNC_OVERHEAD, 20);
617        assert_eq!(BYTES_PER_CURSOR_MOVE, 10);
618    }
619
620    // --- expected_max_bytes edge cases ---
621
622    #[test]
623    fn expected_max_bytes_many_runs_few_cells() {
624        // 1 cell, 100 runs (pathological case)
625        let budget = expected_max_bytes(1, 100);
626        // 100*10 + 1*40 + 20 = 1060
627        assert_eq!(budget, 1060);
628    }
629
630    #[test]
631    fn expected_max_bytes_many_cells_one_run() {
632        let budget = expected_max_bytes(1000, 1);
633        // 1*10 + 1000*40 + 20 = 40030
634        assert_eq!(budget, 40030);
635    }
636
637    // --- StatsCollector edge cases ---
638
639    #[test]
640    fn stats_collector_debug() {
641        let collector = StatsCollector::start(5, 2);
642        let dbg = format!("{:?}", collector);
643        assert!(dbg.contains("StatsCollector"), "Debug: {dbg}");
644    }
645
646    #[test]
647    fn stats_collector_zero_cells_runs() {
648        let collector = StatsCollector::start(0, 0);
649        let stats = collector.finish(0);
650        assert_eq!(stats.cells_changed, 0);
651        assert_eq!(stats.run_count, 0);
652        assert_eq!(stats.bytes_emitted, 0);
653        assert!(stats.within_budget()); // 0 <= SYNC_OVERHEAD
654    }
655
656    #[test]
657    fn stats_collector_immediate_finish() {
658        let collector = StatsCollector::start(1, 1);
659        let stats = collector.finish(50);
660        assert_eq!(stats.bytes_emitted, 50);
661        // Duration should be very small (near zero)
662        assert!(stats.duration < Duration::from_millis(100));
663    }
664}