ferridriver_script/
console.rs1use std::sync::Mutex;
15use std::time::Instant;
16
17use crate::result::{ConsoleEntry, ConsoleLevel};
18
19pub struct ConsoleCapture {
25 max_entries: usize,
26 max_total_bytes: usize,
27 max_entry_bytes: usize,
28 started: Instant,
29 inner: Mutex<ConsoleInner>,
30}
31
32struct ConsoleInner {
33 entries: Vec<ConsoleEntry>,
34 total_bytes: usize,
35 truncated: bool,
36}
37
38impl ConsoleCapture {
39 #[must_use]
40 pub fn new(max_entries: usize, max_total_bytes: usize, max_entry_bytes: usize) -> Self {
41 Self {
42 max_entries,
43 max_total_bytes,
44 max_entry_bytes,
45 started: Instant::now(),
46 inner: Mutex::new(ConsoleInner {
47 entries: Vec::new(),
48 total_bytes: 0,
49 truncated: false,
50 }),
51 }
52 }
53
54 pub fn push(&self, level: ConsoleLevel, message: impl Into<String>) {
61 let mut message = message.into();
62 if message.len() > self.max_entry_bytes {
63 message.truncate(self.max_entry_bytes);
64 message.push('…');
65 }
66
67 let Ok(mut inner) = self.inner.lock() else {
68 return;
69 };
70
71 if inner.truncated {
72 return;
73 }
74
75 let would_exceed_count = inner.entries.len() >= self.max_entries;
76 let would_exceed_bytes = inner.total_bytes.saturating_add(message.len()) > self.max_total_bytes;
77
78 if would_exceed_count || would_exceed_bytes {
79 inner.entries.push(ConsoleEntry {
80 level: ConsoleLevel::System,
81 message: "console capture truncated: limits exceeded".to_string(),
82 ts_ms: self.started.elapsed().as_millis() as u64,
83 });
84 inner.truncated = true;
85 return;
86 }
87
88 inner.total_bytes = inner.total_bytes.saturating_add(message.len());
89 inner.entries.push(ConsoleEntry {
90 level,
91 message,
92 ts_ms: self.started.elapsed().as_millis() as u64,
93 });
94 }
95
96 #[must_use]
101 pub fn drain(&self) -> Vec<ConsoleEntry> {
102 self
103 .inner
104 .lock()
105 .map(|mut inner| std::mem::take(&mut inner.entries))
106 .unwrap_or_default()
107 }
108
109 #[must_use]
111 pub fn elapsed_ms(&self) -> u64 {
112 self.started.elapsed().as_millis() as u64
113 }
114}
115
116#[must_use]
120pub fn strip_ansi(input: &str) -> String {
121 let mut out = String::with_capacity(input.len());
122 let mut chars = input.chars().peekable();
123 while let Some(c) = chars.next() {
124 if c == '\x1b' && chars.peek() == Some(&'[') {
125 chars.next();
126 for nc in chars.by_ref() {
127 if ('@'..='~').contains(&nc) {
128 break;
129 }
130 }
131 } else {
132 out.push(c);
133 }
134 }
135 out
136}
137
138#[cfg(test)]
139mod tests {
140 use super::*;
141
142 #[test]
143 fn strip_ansi_removes_color_codes() {
144 assert_eq!(strip_ansi("\x1b[31mred\x1b[0m"), "red");
145 assert_eq!(strip_ansi("\x1b[1;34mbold blue\x1b[0m"), "bold blue");
146 assert_eq!(strip_ansi("plain"), "plain");
147 }
148
149 #[test]
150 fn capture_respects_entry_limit() {
151 let cap = ConsoleCapture::new(3, 10_000, 1000);
152 for i in 0..5 {
153 cap.push(ConsoleLevel::Log, format!("line {i}"));
154 }
155 let entries = cap.drain();
156 assert_eq!(entries.len(), 4);
158 assert_eq!(entries[3].level, ConsoleLevel::System);
159 }
160
161 #[test]
162 fn capture_respects_byte_limit() {
163 let cap = ConsoleCapture::new(100, 20, 100);
164 cap.push(ConsoleLevel::Log, "a".repeat(15));
165 cap.push(ConsoleLevel::Log, "b".repeat(15));
166 let entries = cap.drain();
167 assert_eq!(entries.len(), 2);
169 assert_eq!(entries[1].level, ConsoleLevel::System);
170 }
171
172 #[test]
173 fn capture_truncates_long_entry() {
174 let cap = ConsoleCapture::new(10, 10_000, 5);
175 cap.push(ConsoleLevel::Log, "abcdefgh");
176 let entries = cap.drain();
177 assert_eq!(entries.len(), 1);
178 assert!(entries[0].message.starts_with("abcde"));
179 assert!(entries[0].message.ends_with('…'));
180 }
181}