1#![forbid(unsafe_code)]
2
3use std::fmt;
42use std::io::Write;
43
44pub trait DiagnosticRecord: fmt::Debug + Clone {
53 fn to_jsonl(&self) -> String;
55}
56
57pub trait DiagnosticHookDispatch<E>: fmt::Debug {
60 fn dispatch(&self, entry: &E);
62}
63
64#[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#[derive(Debug)]
102pub struct DiagnosticLog<E: DiagnosticRecord> {
103 entries: Vec<E>,
105 head: usize,
107 max_entries: usize,
109 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 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 #[must_use]
133 pub fn with_stderr(mut self) -> Self {
134 self.write_stderr = true;
135 self
136 }
137
138 #[must_use]
141 pub fn with_max_entries(mut self, max: usize) -> Self {
142 self.max_entries = max;
143 self
144 }
145
146 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 pub fn entries(&self) -> &[E] {
164 &self.entries[self.head..]
165 }
166
167 pub fn entries_matching(&self, predicate: impl Fn(&E) -> bool) -> Vec<&E> {
169 self.entries().iter().filter(|e| predicate(e)).collect()
170 }
171
172 pub fn clear(&mut self) {
174 self.entries.clear();
175 self.head = 0;
176 }
177
178 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 pub fn len(&self) -> usize {
189 self.entries.len().saturating_sub(self.head)
190 }
191
192 pub fn is_empty(&self) -> bool {
194 self.len() == 0
195 }
196}
197
198pub 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 pub fn new() -> Self {
233 Self {
234 log: None,
235 hooks: None,
236 }
237 }
238
239 #[must_use]
241 pub fn with_log(mut self, log: DiagnosticLog<E>) -> Self {
242 self.log = Some(log);
243 self
244 }
245
246 #[must_use]
248 pub fn with_hooks(mut self, hooks: H) -> Self {
249 self.hooks = Some(hooks);
250 self
251 }
252
253 pub fn set_log(&mut self, log: DiagnosticLog<E>) {
255 self.log = Some(log);
256 }
257
258 pub fn set_hooks(&mut self, hooks: H) {
260 self.hooks = Some(hooks);
261 }
262
263 pub fn log(&self) -> Option<&DiagnosticLog<E>> {
265 self.log.as_ref()
266 }
267
268 pub fn log_mut(&mut self) -> Option<&mut DiagnosticLog<E>> {
270 self.log.as_mut()
271 }
272
273 pub fn hooks(&self) -> Option<&H> {
275 self.hooks.as_ref()
276 }
277
278 pub fn is_active(&self) -> bool {
280 self.log.is_some() || self.hooks.is_some()
281 }
282
283 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
294pub type TelemetryCallback<E> = Box<dyn Fn(&E) + Send + Sync>;
303
304pub 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
321pub 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#[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); }
472
473 #[test]
474 fn env_flag_enabled_false_when_unset() {
475 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}