1use colored::Colorize;
7use crossterm::{cursor, execute, terminal};
8use std::collections::VecDeque;
9use std::io::{self, Write};
10use std::time::{Duration, Instant};
11
12const DEFAULT_MAX_LINES: usize = 5;
14
15pub struct StreamingShellOutput {
17 lines: VecDeque<String>,
18 max_lines: usize,
19 command: String,
20 start_time: Instant,
21 lines_rendered: usize,
22 timeout_secs: u64,
23}
24
25impl StreamingShellOutput {
26 pub fn new(command: &str, timeout_secs: u64) -> Self {
28 Self {
29 lines: VecDeque::with_capacity(DEFAULT_MAX_LINES + 1),
30 max_lines: DEFAULT_MAX_LINES,
31 command: command.to_string(),
32 start_time: Instant::now(),
33 lines_rendered: 0,
34 timeout_secs,
35 }
36 }
37
38 pub fn with_max_lines(command: &str, timeout_secs: u64, max_lines: usize) -> Self {
40 Self {
41 lines: VecDeque::with_capacity(max_lines + 1),
42 max_lines,
43 command: command.to_string(),
44 start_time: Instant::now(),
45 lines_rendered: 0,
46 timeout_secs,
47 }
48 }
49
50 fn format_elapsed(&self) -> String {
52 let elapsed = self.start_time.elapsed();
53 let secs = elapsed.as_secs();
54 if secs >= 60 {
55 let mins = secs / 60;
56 let remaining_secs = secs % 60;
57 format!("{}m {}s", mins, remaining_secs)
58 } else {
59 format!("{}s", secs)
60 }
61 }
62
63 fn format_timeout(&self) -> String {
65 let mins = self.timeout_secs / 60;
66 let secs = self.timeout_secs % 60;
67 if mins > 0 {
68 format!("timeout: {}m {}s", mins, secs)
69 } else {
70 format!("timeout: {}s", secs)
71 }
72 }
73
74 fn render_header(&self) {
76 let elapsed = self.format_elapsed();
77 let timeout = self.format_timeout();
78
79 let term_width = term_size::dimensions().map(|(w, _)| w).unwrap_or(80);
81 let prefix_len = 2 + timeout.len() + elapsed.len() + 10; let max_cmd_len = term_width.saturating_sub(prefix_len);
83 let cmd_display = truncate_safe(&self.command, max_cmd_len);
84
85 print!(
86 "{} {}({}) {} ({})",
87 "●".cyan().bold(),
88 "Bash".cyan(),
89 cmd_display.cyan(),
90 timeout.dimmed(),
91 elapsed.yellow()
92 );
93 }
94
95 fn render_output(&self) {
97 let term_width = term_size::dimensions().map(|(w, _)| w).unwrap_or(80);
98 let content_width = term_width.saturating_sub(5); for (i, line) in self.lines.iter().enumerate() {
101 let is_last = i == self.lines.len() - 1;
102 let prefix = if is_last { "└" } else { "│" };
103
104 let display = truncate_safe(line, content_width);
106
107 println!(" {} {}", prefix.dimmed(), display);
108 }
109 }
111
112 fn clear_previous(&mut self) {
114 if self.lines_rendered > 0 {
115 let mut stdout = io::stdout();
116 for _ in 0..self.lines_rendered {
118 let _ = execute!(
119 stdout,
120 cursor::MoveUp(1),
121 terminal::Clear(terminal::ClearType::CurrentLine)
122 );
123 }
124 }
125 }
126
127 pub fn push_line(&mut self, line: &str) {
129 if self.lines.is_empty() && line.trim().is_empty() {
131 return;
132 }
133
134 let cleaned = strip_ansi_codes(line);
136
137 self.lines.push_back(cleaned);
139
140 while self.lines.len() > self.max_lines {
142 self.lines.pop_front();
143 }
144
145 self.render();
147 }
148
149 pub fn push_lines(&mut self, text: &str) {
151 for line in text.lines() {
152 self.push_line(line);
153 }
154 }
155
156 pub fn render(&mut self) {
158 self.clear_previous();
159
160 let mut stdout = io::stdout();
161
162 self.render_header();
164 println!();
165
166 let lines_count = self.lines.len();
168 self.render_output();
169
170 self.lines_rendered = 1 + lines_count;
172
173 let _ = stdout.flush();
174 }
175
176 pub fn finish(&mut self, success: bool, exit_code: Option<i32>) {
178 self.clear_previous();
179
180 let elapsed = self.format_elapsed();
181 let status_icon = if success { "✓" } else { "✗" };
182
183 let term_width = term_size::dimensions().map(|(w, _)| w).unwrap_or(80);
185 let max_cmd_len = term_width.saturating_sub(30);
186 let cmd_display = truncate_safe(&self.command, max_cmd_len);
187
188 let exit_info = match exit_code {
189 Some(code) if code != 0 => format!(" (exit {})", code),
190 _ => String::new(),
191 };
192
193 if success {
194 println!(
195 "{} {}({}) {} {}{}",
196 status_icon.green().bold(),
197 "Bash".green(),
198 cmd_display.dimmed(),
199 "completed".green(),
200 elapsed.dimmed(),
201 exit_info.red()
202 );
203 } else {
204 println!(
205 "{} {}({}) {} {}{}",
206 status_icon.red().bold(),
207 "Bash".red(),
208 cmd_display.dimmed(),
209 "failed".red(),
210 elapsed.dimmed(),
211 exit_info.red()
212 );
213 }
214
215 if !success && !self.lines.is_empty() {
217 for line in self.lines.iter().take(3) {
218 println!(" {} {}", "│".dimmed(), line.dimmed());
219 }
220 }
221
222 let _ = io::stdout().flush();
223 self.lines_rendered = 0;
224 }
225
226 pub fn elapsed(&self) -> Duration {
228 self.start_time.elapsed()
229 }
230}
231
232fn strip_ansi_codes(s: &str) -> String {
234 let mut result = String::with_capacity(s.len());
235 let mut chars = s.chars().peekable();
236
237 while let Some(c) = chars.next() {
238 if c == '\x1b' {
239 if chars.peek() == Some(&'[') {
241 chars.next(); while let Some(&c) = chars.peek() {
244 chars.next();
245 if c.is_ascii_alphabetic() {
246 break;
247 }
248 }
249 }
250 } else {
251 result.push(c);
252 }
253 }
254
255 result
256}
257
258fn truncate_safe(s: &str, max_width: usize) -> String {
262 let stripped = strip_ansi_codes(s);
264
265 let visual_len: usize = stripped.chars().count();
267
268 if visual_len <= max_width {
269 return s.to_string();
270 }
271
272 let truncate_to = max_width.saturating_sub(3);
275
276 let mut result = String::new();
277
278 for (char_count, ch) in stripped.chars().enumerate() {
279 if char_count >= truncate_to {
280 result.push_str("...");
281 break;
282 }
283 result.push(ch);
284 }
285
286 result
287}
288
289#[cfg(test)]
290mod tests {
291 use super::*;
292
293 #[test]
294 fn test_strip_ansi_codes() {
295 let input = "\x1b[32mgreen\x1b[0m text";
296 assert_eq!(strip_ansi_codes(input), "green text");
297 }
298
299 #[test]
300 fn test_truncate_safe_ascii() {
301 assert_eq!(truncate_safe("hello world", 8), "hello...");
303 assert_eq!(truncate_safe("short", 10), "short");
304 assert_eq!(truncate_safe("exactly10!", 10), "exactly10!");
305 }
306
307 #[test]
308 fn test_truncate_safe_utf8_box_drawing() {
309 let box_line = "╭ Warning ──────────────────────────────────╮";
311 let result = truncate_safe(box_line, 20);
313 assert!(result.ends_with("..."));
314 assert!(result.chars().count() <= 20);
315 }
316
317 #[test]
318 fn test_truncate_safe_utf8_emoji() {
319 let emoji_str = "🚀 Building project 📦 with dependencies 🔧";
321 let result = truncate_safe(emoji_str, 15);
322 assert!(result.ends_with("..."));
323 }
325
326 #[test]
327 fn test_truncate_safe_mixed_content() {
328 let mixed = "#9 3.304 ╭ Warning ───";
330 let result = truncate_safe(mixed, 15);
331 assert!(result.ends_with("..."));
332 assert!(result.chars().count() <= 15);
333 }
334
335 #[test]
336 fn test_truncate_safe_no_truncation_needed() {
337 let short = "hello";
338 assert_eq!(truncate_safe(short, 100), "hello");
339
340 let exact = "12345";
341 assert_eq!(truncate_safe(exact, 5), "12345");
342 }
343
344 #[test]
345 fn test_streaming_output_buffer() {
346 let mut stream = StreamingShellOutput::new("test", 60);
347 stream.push_line("line 1");
348 stream.push_line("line 2");
349 assert_eq!(stream.lines.len(), 2);
350
351 for i in 0..10 {
353 stream.push_line(&format!("line {}", i));
354 }
355 assert_eq!(stream.lines.len(), DEFAULT_MAX_LINES);
356 }
357
358 #[test]
359 fn test_streaming_output_with_utf8_content() {
360 let mut stream = StreamingShellOutput::new("docker build", 60);
362 stream.push_line("╭ Warning ────────────────╮");
363 stream.push_line("│ This is a warning message │");
364 stream.push_line("╰────────────────────────────╯");
365 assert_eq!(stream.lines.len(), 3);
366 }
367}