Skip to main content

ftui_widgets/
diagnostics.rs

1#![forbid(unsafe_code)]
2
3//! Reusable diagnostic logging and telemetry substrate.
4//!
5//! This module provides the shared infrastructure for JSONL diagnostic
6//! logging and telemetry hooks, extracted from the common patterns in
7//! [`crate::inspector`] and the demo showcase's `mouse_playground`.
8//!
9//! # Design
10//!
11//! The core types are generic over the entry type, so each consumer
12//! defines its own `DiagnosticEntry` / `DiagnosticEventKind` while
13//! reusing the log, dispatch, and checksum infrastructure.
14//!
15//! # Key Types
16//!
17//! - [`DiagnosticRecord`] — trait for entries that can be serialized to JSONL
18//! - [`DiagnosticLog`] — bounded in-memory log with optional stderr mirroring
19//! - [`TelemetryCallback`] — type alias for observer callbacks
20//! - [`fnv1a_hash`] — FNV-1a checksum utility for determinism verification
21//!
22//! # Example
23//!
24//! ```ignore
25//! use ftui_widgets::diagnostics::{DiagnosticLog, DiagnosticRecord};
26//!
27//! #[derive(Debug, Clone)]
28//! struct MyEntry { kind: &'static str, data: u64 }
29//!
30//! impl DiagnosticRecord for MyEntry {
31//!     fn to_jsonl(&self) -> String {
32//!         format!("{{\"kind\":\"{}\",\"data\":{}}}", self.kind, self.data)
33//!     }
34//! }
35//!
36//! let mut log = DiagnosticLog::<MyEntry>::new();
37//! log.record(MyEntry { kind: "test", data: 42 });
38//! assert_eq!(log.entries().len(), 1);
39//! ```
40
41use std::fmt;
42use std::io::Write;
43
44// =============================================================================
45// DiagnosticRecord trait
46// =============================================================================
47
48/// Trait for diagnostic entries that can be serialized to JSONL.
49///
50/// Consumers define their own entry structs with domain-specific fields
51/// and implement this trait to plug into [`DiagnosticLog`].
52pub trait DiagnosticRecord: fmt::Debug + Clone {
53    /// Format this entry as a single JSONL line (no trailing newline).
54    fn to_jsonl(&self) -> String;
55}
56
57/// Trait implemented by telemetry hook collections that can observe
58/// diagnostic entries of type `E`.
59pub trait DiagnosticHookDispatch<E>: fmt::Debug {
60    /// Dispatch a single diagnostic entry to any registered hooks.
61    fn dispatch(&self, entry: &E);
62}
63
64/// Encode a string as a JSON string literal.
65///
66/// The returned value includes the surrounding quotes and correctly escapes
67/// control characters so the result can be embedded directly into JSONL output.
68#[must_use]
69pub fn json_string_literal(value: &str) -> String {
70    use std::fmt::Write as _;
71
72    let mut out = String::with_capacity(value.len() + 2);
73    out.push('"');
74    for ch in value.chars() {
75        match ch {
76            '"' => out.push_str("\\\""),
77            '\\' => out.push_str("\\\\"),
78            '\n' => out.push_str("\\n"),
79            '\r' => out.push_str("\\r"),
80            '\t' => out.push_str("\\t"),
81            '\u{08}' => out.push_str("\\b"),
82            '\u{0C}' => out.push_str("\\f"),
83            c if c < '\u{20}' => {
84                let _ = write!(&mut out, "\\u{:04x}", c as u32);
85            }
86            c => out.push(c),
87        }
88    }
89    out.push('"');
90    out
91}
92
93// =============================================================================
94// DiagnosticLog<E>
95// =============================================================================
96
97/// Bounded in-memory diagnostic log with optional stderr mirroring.
98///
99/// Generic over the entry type `E` so different subsystems can use
100/// their own entry structs while sharing the log infrastructure.
101#[derive(Debug)]
102pub struct DiagnosticLog<E: DiagnosticRecord> {
103    /// Collected entries.
104    entries: Vec<E>,
105    /// Logical start index after bounded evictions.
106    head: usize,
107    /// Maximum entries to keep (0 = unlimited).
108    max_entries: usize,
109    /// Whether to also write to stderr.
110    write_stderr: bool,
111}
112
113impl<E: DiagnosticRecord> Default for DiagnosticLog<E> {
114    fn default() -> Self {
115        Self::new()
116    }
117}
118
119impl<E: DiagnosticRecord> DiagnosticLog<E> {
120    /// Create a new diagnostic log with a default capacity of 10 000 entries.
121    pub fn new() -> Self {
122        Self {
123            entries: Vec::new(),
124            head: 0,
125            max_entries: 10_000,
126            write_stderr: false,
127        }
128    }
129
130    /// Enable stderr mirroring — each recorded entry is also written
131    /// to stderr as a JSONL line.
132    #[must_use]
133    pub fn with_stderr(mut self) -> Self {
134        self.write_stderr = true;
135        self
136    }
137
138    /// Set the maximum number of entries to keep. When the log is full,
139    /// the oldest entry is evicted. Pass `0` for unlimited.
140    #[must_use]
141    pub fn with_max_entries(mut self, max: usize) -> Self {
142        self.max_entries = max;
143        self
144    }
145
146    /// Record a diagnostic entry.
147    pub fn record(&mut self, entry: E) {
148        if self.write_stderr {
149            let _ = writeln!(std::io::stderr(), "{}", entry.to_jsonl());
150        }
151
152        self.entries.push(entry);
153        if self.max_entries > 0 && self.entries.len().saturating_sub(self.head) > self.max_entries {
154            self.head += 1;
155            if self.head >= self.entries.len() / 2 {
156                self.entries = self.entries.split_off(self.head);
157                self.head = 0;
158            }
159        }
160    }
161
162    /// Get all entries.
163    pub fn entries(&self) -> &[E] {
164        &self.entries[self.head..]
165    }
166
167    /// Get entries matching a predicate.
168    pub fn entries_matching(&self, predicate: impl Fn(&E) -> bool) -> Vec<&E> {
169        self.entries().iter().filter(|e| predicate(e)).collect()
170    }
171
172    /// Clear all entries.
173    pub fn clear(&mut self) {
174        self.entries.clear();
175        self.head = 0;
176    }
177
178    /// Export all entries as a JSONL string (newline-separated).
179    pub fn to_jsonl(&self) -> String {
180        self.entries()
181            .iter()
182            .map(DiagnosticRecord::to_jsonl)
183            .collect::<Vec<_>>()
184            .join("\n")
185    }
186
187    /// Number of recorded entries.
188    pub fn len(&self) -> usize {
189        self.entries.len().saturating_sub(self.head)
190    }
191
192    /// Whether the log is empty.
193    pub fn is_empty(&self) -> bool {
194        self.len() == 0
195    }
196}
197
198// =============================================================================
199// DiagnosticSupport<E, H>
200// =============================================================================
201
202/// Shared state for optional diagnostic logging plus optional telemetry hooks.
203///
204/// This is the reusable control-flow skeleton shared by diagnostic-enabled
205/// widgets and screens:
206///
207/// - optional bounded log
208/// - optional hook collection
209/// - shared `record()` ordering: hooks first, then log
210pub struct DiagnosticSupport<E: DiagnosticRecord, H: DiagnosticHookDispatch<E>> {
211    log: Option<DiagnosticLog<E>>,
212    hooks: Option<H>,
213}
214
215impl<E: DiagnosticRecord, H: DiagnosticHookDispatch<E>> Default for DiagnosticSupport<E, H> {
216    fn default() -> Self {
217        Self::new()
218    }
219}
220
221impl<E: DiagnosticRecord, H: DiagnosticHookDispatch<E>> fmt::Debug for DiagnosticSupport<E, H> {
222    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
223        f.debug_struct("DiagnosticSupport")
224            .field("log", &self.log)
225            .field("hooks", &self.hooks)
226            .finish()
227    }
228}
229
230impl<E: DiagnosticRecord, H: DiagnosticHookDispatch<E>> DiagnosticSupport<E, H> {
231    /// Create an empty diagnostic support bundle with no log and no hooks.
232    pub fn new() -> Self {
233        Self {
234            log: None,
235            hooks: None,
236        }
237    }
238
239    /// Enable logging with the provided diagnostic log.
240    #[must_use]
241    pub fn with_log(mut self, log: DiagnosticLog<E>) -> Self {
242        self.log = Some(log);
243        self
244    }
245
246    /// Enable telemetry hooks with the provided hook set.
247    #[must_use]
248    pub fn with_hooks(mut self, hooks: H) -> Self {
249        self.hooks = Some(hooks);
250        self
251    }
252
253    /// Replace the diagnostic log.
254    pub fn set_log(&mut self, log: DiagnosticLog<E>) {
255        self.log = Some(log);
256    }
257
258    /// Replace the telemetry hooks.
259    pub fn set_hooks(&mut self, hooks: H) {
260        self.hooks = Some(hooks);
261    }
262
263    /// Borrow the diagnostic log, if enabled.
264    pub fn log(&self) -> Option<&DiagnosticLog<E>> {
265        self.log.as_ref()
266    }
267
268    /// Mutably borrow the diagnostic log, if enabled.
269    pub fn log_mut(&mut self) -> Option<&mut DiagnosticLog<E>> {
270        self.log.as_mut()
271    }
272
273    /// Borrow the telemetry hooks, if enabled.
274    pub fn hooks(&self) -> Option<&H> {
275        self.hooks.as_ref()
276    }
277
278    /// Returns true when either logging or hooks are enabled.
279    pub fn is_active(&self) -> bool {
280        self.log.is_some() || self.hooks.is_some()
281    }
282
283    /// Dispatch an entry to hooks first, then record it to the log.
284    pub fn record(&mut self, entry: E) {
285        if let Some(ref hooks) = self.hooks {
286            hooks.dispatch(&entry);
287        }
288        if let Some(ref mut log) = self.log {
289            log.record(entry);
290        }
291    }
292}
293
294// =============================================================================
295// TelemetryCallback type alias
296// =============================================================================
297
298/// Callback type for telemetry hooks.
299///
300/// Generic over the entry type so each subsystem can observe its own
301/// domain-specific entries.
302pub type TelemetryCallback<E> = Box<dyn Fn(&E) + Send + Sync>;
303
304// =============================================================================
305// FNV-1a checksum utility
306// =============================================================================
307
308/// Compute an FNV-1a 64-bit hash of the given byte slice.
309///
310/// This is the same algorithm used by both `inspector` and
311/// `mouse_playground` for determinism verification checksums.
312pub fn fnv1a_hash(data: &[u8]) -> u64 {
313    let mut hash: u64 = 0xcbf29ce484222325;
314    for &b in data {
315        hash ^= b as u64;
316        hash = hash.wrapping_mul(0x100000001b3);
317    }
318    hash
319}
320
321// =============================================================================
322// Helpers for environment-flag-based diagnostics
323// =============================================================================
324
325/// Check an environment variable as a boolean diagnostic flag.
326///
327/// Returns `true` if the variable is set to `"1"` or `"true"` (case-insensitive).
328pub fn env_flag_enabled(var_name: &str) -> bool {
329    std::env::var(var_name)
330        .map(|v| env_flag_value_enabled(&v))
331        .unwrap_or(false)
332}
333
334fn env_flag_value_enabled(value: &str) -> bool {
335    value == "1" || value.eq_ignore_ascii_case("true")
336}
337
338// =============================================================================
339// Tests
340// =============================================================================
341
342#[cfg(test)]
343mod tests {
344    use super::*;
345
346    #[derive(Debug, Clone)]
347    struct TestEntry {
348        kind: &'static str,
349        value: u64,
350    }
351
352    impl DiagnosticRecord for TestEntry {
353        fn to_jsonl(&self) -> String {
354            format!("{{\"kind\":\"{}\",\"value\":{}}}", self.kind, self.value)
355        }
356    }
357
358    #[test]
359    fn log_records_and_retrieves() {
360        let mut log = DiagnosticLog::<TestEntry>::new();
361        log.record(TestEntry {
362            kind: "a",
363            value: 1,
364        });
365        log.record(TestEntry {
366            kind: "b",
367            value: 2,
368        });
369        assert_eq!(log.len(), 2);
370        assert_eq!(log.entries()[0].value, 1);
371        assert_eq!(log.entries()[1].value, 2);
372    }
373
374    #[test]
375    fn log_evicts_oldest_when_full() {
376        let mut log = DiagnosticLog::<TestEntry>::new().with_max_entries(2);
377        log.record(TestEntry {
378            kind: "a",
379            value: 1,
380        });
381        log.record(TestEntry {
382            kind: "b",
383            value: 2,
384        });
385        log.record(TestEntry {
386            kind: "c",
387            value: 3,
388        });
389        assert_eq!(log.len(), 2);
390        assert_eq!(log.entries()[0].value, 2);
391        assert_eq!(log.entries()[1].value, 3);
392    }
393
394    #[test]
395    fn log_preserves_order_after_many_evictions() {
396        let mut log = DiagnosticLog::<TestEntry>::new().with_max_entries(3);
397        for value in 0..16 {
398            log.record(TestEntry { kind: "x", value });
399        }
400        let values: Vec<u64> = log.entries().iter().map(|entry| entry.value).collect();
401        assert_eq!(values, vec![13, 14, 15]);
402    }
403
404    #[test]
405    fn log_clear() {
406        let mut log = DiagnosticLog::<TestEntry>::new();
407        log.record(TestEntry {
408            kind: "a",
409            value: 1,
410        });
411        assert!(!log.is_empty());
412        log.clear();
413        assert!(log.is_empty());
414        assert_eq!(log.len(), 0);
415    }
416
417    #[test]
418    fn log_to_jsonl() {
419        let mut log = DiagnosticLog::<TestEntry>::new();
420        log.record(TestEntry {
421            kind: "x",
422            value: 10,
423        });
424        log.record(TestEntry {
425            kind: "y",
426            value: 20,
427        });
428        let output = log.to_jsonl();
429        assert!(output.contains("\"kind\":\"x\""));
430        assert!(output.contains("\"kind\":\"y\""));
431        assert!(output.contains('\n'));
432    }
433
434    #[test]
435    fn log_entries_matching() {
436        let mut log = DiagnosticLog::<TestEntry>::new();
437        log.record(TestEntry {
438            kind: "a",
439            value: 1,
440        });
441        log.record(TestEntry {
442            kind: "b",
443            value: 2,
444        });
445        log.record(TestEntry {
446            kind: "a",
447            value: 3,
448        });
449        let matches = log.entries_matching(|e| e.kind == "a");
450        assert_eq!(matches.len(), 2);
451    }
452
453    #[test]
454    fn json_string_literal_escapes_control_characters() {
455        let escaped = json_string_literal("line 1\nline\t2");
456        assert_eq!(escaped, "\"line 1\\nline\\t2\"");
457    }
458
459    #[test]
460    fn fnv1a_hash_deterministic() {
461        let h1 = fnv1a_hash(b"hello world");
462        let h2 = fnv1a_hash(b"hello world");
463        assert_eq!(h1, h2);
464        assert_ne!(h1, fnv1a_hash(b"hello worlD"));
465    }
466
467    #[test]
468    fn fnv1a_hash_empty() {
469        let h = fnv1a_hash(b"");
470        assert_eq!(h, 0xcbf29ce484222325); // FNV offset basis
471    }
472
473    #[test]
474    fn env_flag_enabled_false_when_unset() {
475        // Use a unique variable name unlikely to be set
476        assert!(!env_flag_enabled("FTUI_TEST_DIAGNOSTICS_NEVER_SET_12345"));
477    }
478
479    #[test]
480    fn env_flag_enabled_accepts_true_case_insensitively() {
481        assert!(env_flag_value_enabled("TrUe"));
482    }
483
484    #[test]
485    fn env_flag_enabled_accepts_one() {
486        assert!(env_flag_value_enabled("1"));
487    }
488
489    #[test]
490    fn default_log_has_correct_capacity() {
491        let log = DiagnosticLog::<TestEntry>::new();
492        assert_eq!(log.max_entries, 10_000);
493        assert!(!log.write_stderr);
494    }
495}