use colored::Colorize;
use crossterm::{cursor, execute, terminal};
use std::collections::VecDeque;
use std::io::{self, Write};
use std::time::{Duration, Instant};
const DEFAULT_MAX_LINES: usize = 5;
pub struct StreamingShellOutput {
lines: VecDeque<String>,
max_lines: usize,
command: String,
start_time: Instant,
lines_rendered: usize,
timeout_secs: u64,
}
impl StreamingShellOutput {
pub fn new(command: &str, timeout_secs: u64) -> Self {
Self {
lines: VecDeque::with_capacity(DEFAULT_MAX_LINES + 1),
max_lines: DEFAULT_MAX_LINES,
command: command.to_string(),
start_time: Instant::now(),
lines_rendered: 0,
timeout_secs,
}
}
pub fn with_max_lines(command: &str, timeout_secs: u64, max_lines: usize) -> Self {
Self {
lines: VecDeque::with_capacity(max_lines + 1),
max_lines,
command: command.to_string(),
start_time: Instant::now(),
lines_rendered: 0,
timeout_secs,
}
}
fn format_elapsed(&self) -> String {
let elapsed = self.start_time.elapsed();
let secs = elapsed.as_secs();
if secs >= 60 {
let mins = secs / 60;
let remaining_secs = secs % 60;
format!("{}m {}s", mins, remaining_secs)
} else {
format!("{}s", secs)
}
}
fn format_timeout(&self) -> String {
let mins = self.timeout_secs / 60;
let secs = self.timeout_secs % 60;
if mins > 0 {
format!("timeout: {}m {}s", mins, secs)
} else {
format!("timeout: {}s", secs)
}
}
fn render_header(&self) {
let elapsed = self.format_elapsed();
let timeout = self.format_timeout();
let term_width = term_size::dimensions().map(|(w, _)| w).unwrap_or(80);
let prefix_len = 2 + timeout.len() + elapsed.len() + 10; let max_cmd_len = term_width.saturating_sub(prefix_len);
let cmd_display = truncate_safe(&self.command, max_cmd_len);
print!(
"{} {}({}) {} ({})",
"●".cyan().bold(),
"Bash".cyan(),
cmd_display.cyan(),
timeout.dimmed(),
elapsed.yellow()
);
}
fn render_output(&self) {
let term_width = term_size::dimensions().map(|(w, _)| w).unwrap_or(80);
let content_width = term_width.saturating_sub(5);
for (i, line) in self.lines.iter().enumerate() {
let is_last = i == self.lines.len() - 1;
let prefix = if is_last { "└" } else { "│" };
let display = truncate_safe(line, content_width);
println!(" {} {}", prefix.dimmed(), display);
}
}
fn clear_previous(&mut self) {
if self.lines_rendered > 0 {
let mut stdout = io::stdout();
for _ in 0..self.lines_rendered {
let _ = execute!(
stdout,
cursor::MoveUp(1),
terminal::Clear(terminal::ClearType::CurrentLine)
);
}
}
}
pub fn push_line(&mut self, line: &str) {
if self.lines.is_empty() && line.trim().is_empty() {
return;
}
let cleaned = strip_ansi_codes(line);
self.lines.push_back(cleaned);
while self.lines.len() > self.max_lines {
self.lines.pop_front();
}
self.render();
}
pub fn push_lines(&mut self, text: &str) {
for line in text.lines() {
self.push_line(line);
}
}
pub fn render(&mut self) {
self.clear_previous();
let mut stdout = io::stdout();
self.render_header();
println!();
let lines_count = self.lines.len();
self.render_output();
self.lines_rendered = 1 + lines_count;
let _ = stdout.flush();
}
pub fn finish(&mut self, success: bool, exit_code: Option<i32>) {
self.clear_previous();
let elapsed = self.format_elapsed();
let status_icon = if success { "✓" } else { "✗" };
let term_width = term_size::dimensions().map(|(w, _)| w).unwrap_or(80);
let max_cmd_len = term_width.saturating_sub(30);
let cmd_display = truncate_safe(&self.command, max_cmd_len);
let exit_info = match exit_code {
Some(code) if code != 0 => format!(" (exit {})", code),
_ => String::new(),
};
if success {
println!(
"{} {}({}) {} {}{}",
status_icon.green().bold(),
"Bash".green(),
cmd_display.dimmed(),
"completed".green(),
elapsed.dimmed(),
exit_info.red()
);
} else {
println!(
"{} {}({}) {} {}{}",
status_icon.red().bold(),
"Bash".red(),
cmd_display.dimmed(),
"failed".red(),
elapsed.dimmed(),
exit_info.red()
);
}
if !success && !self.lines.is_empty() {
for line in self.lines.iter().take(3) {
println!(" {} {}", "│".dimmed(), line.dimmed());
}
}
let _ = io::stdout().flush();
self.lines_rendered = 0;
}
pub fn elapsed(&self) -> Duration {
self.start_time.elapsed()
}
}
fn strip_ansi_codes(s: &str) -> String {
let mut result = String::with_capacity(s.len());
let mut chars = s.chars().peekable();
while let Some(c) = chars.next() {
if c == '\x1b' {
if chars.peek() == Some(&'[') {
chars.next(); while let Some(&c) = chars.peek() {
chars.next();
if c.is_ascii_alphabetic() {
break;
}
}
}
} else {
result.push(c);
}
}
result
}
fn truncate_safe(s: &str, max_width: usize) -> String {
let stripped = strip_ansi_codes(s);
let visual_len: usize = stripped.chars().count();
if visual_len <= max_width {
return s.to_string();
}
let truncate_to = max_width.saturating_sub(3);
let mut result = String::new();
for (char_count, ch) in stripped.chars().enumerate() {
if char_count >= truncate_to {
result.push_str("...");
break;
}
result.push(ch);
}
result
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_strip_ansi_codes() {
let input = "\x1b[32mgreen\x1b[0m text";
assert_eq!(strip_ansi_codes(input), "green text");
}
#[test]
fn test_truncate_safe_ascii() {
assert_eq!(truncate_safe("hello world", 8), "hello...");
assert_eq!(truncate_safe("short", 10), "short");
assert_eq!(truncate_safe("exactly10!", 10), "exactly10!");
}
#[test]
fn test_truncate_safe_utf8_box_drawing() {
let box_line = "╭ Warning ──────────────────────────────────╮";
let result = truncate_safe(box_line, 20);
assert!(result.ends_with("..."));
assert!(result.chars().count() <= 20);
}
#[test]
fn test_truncate_safe_utf8_emoji() {
let emoji_str = "🚀 Building project 📦 with dependencies 🔧";
let result = truncate_safe(emoji_str, 15);
assert!(result.ends_with("..."));
}
#[test]
fn test_truncate_safe_mixed_content() {
let mixed = "#9 3.304 ╭ Warning ───";
let result = truncate_safe(mixed, 15);
assert!(result.ends_with("..."));
assert!(result.chars().count() <= 15);
}
#[test]
fn test_truncate_safe_no_truncation_needed() {
let short = "hello";
assert_eq!(truncate_safe(short, 100), "hello");
let exact = "12345";
assert_eq!(truncate_safe(exact, 5), "12345");
}
#[test]
fn test_streaming_output_buffer() {
let mut stream = StreamingShellOutput::new("test", 60);
stream.push_line("line 1");
stream.push_line("line 2");
assert_eq!(stream.lines.len(), 2);
for i in 0..10 {
stream.push_line(&format!("line {}", i));
}
assert_eq!(stream.lines.len(), DEFAULT_MAX_LINES);
}
#[test]
fn test_streaming_output_with_utf8_content() {
let mut stream = StreamingShellOutput::new("docker build", 60);
stream.push_line("╭ Warning ────────────────╮");
stream.push_line("│ This is a warning message │");
stream.push_line("╰────────────────────────────╯");
assert_eq!(stream.lines.len(), 3);
}
}