use std::io::Write;
use std::time::{Duration, Instant};
use anyhow::{Context, anyhow};
use crate::cli::SpinArgs;
use crate::color::ColorProfile;
use crate::core::duration::parse_interval;
use crate::core::retain::{Keep, Retention, compact_count, read_all};
use crate::exit::{AppError, AppResult};
use crate::term::inline::InlineRenderer;
use crate::term::tty::UiStream;
use crate::theme::Palette;
const RETENTION: Retention = Retention {
max_lines: 10_000,
keep: Keep::Bottom,
};
pub fn run(args: SpinArgs, profile: ColorProfile, _palette: Palette) -> AppResult {
let timeout = args.timeout.as_deref().map(parse_interval).transpose()?;
let mut command = std::process::Command::new(&args.command[0]);
command
.args(&args.command[1..])
.stdin(std::process::Stdio::inherit())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped());
if profile != ColorProfile::Ascii {
command.env("CLICOLOR_FORCE", "1");
}
let mut child = command
.spawn()
.map_err(|err| anyhow!("running {:?}: {err}", args.command[0]))?;
let child_stdout = child.stdout.take().expect("stdout piped");
let child_stderr = child.stderr.take().expect("stderr piped");
let stdout_thread = std::thread::spawn(move || read_all(Some(child_stdout), RETENTION));
let stderr_thread = std::thread::spawn(move || read_all(Some(child_stderr), RETENTION));
let ui = UiStream::open();
let animate = ui.is_tty();
let mut renderer = InlineRenderer::new(ui)
.with_cursor_hidden(animate)
.with_sync_output(animate);
let started = Instant::now();
let mut tick: u64 = 0;
let mut timed_out = false;
let status = loop {
if let Some(status) = child.try_wait().context("waiting for child")? {
break status;
}
if let Some(limit) = timeout
&& started.elapsed() >= limit
{
let _ = child.kill();
let _ = child.wait();
timed_out = true;
break std::process::ExitStatus::default();
}
if animate {
let frame = format!("{} {}", args.spinner.frame(tick), args.title);
let (cols, _) = crossterm::terminal::size().unwrap_or((80, 24));
renderer.draw(&[frame], cols).context("painting spinner")?;
tick += 1;
}
std::thread::sleep(Duration::from_millis(80));
};
if animate {
renderer.clear().context("clearing spinner")?;
}
renderer.finish().context("restoring terminal")?;
let (stdout_lines, stdout_dropped) = stdout_thread.join().unwrap_or_default();
let (stderr_lines, stderr_dropped) = stderr_thread.join().unwrap_or_default();
if timed_out {
return Err(AppError::Timeout(None));
}
let code = status.code().unwrap_or(1); let failed = code != 0;
let show_stdout = args.show_output || args.show_stdout || (args.show_error && failed);
let show_stderr = args.show_output || args.show_stderr || (args.show_error && failed);
if show_stdout {
report_dropped(stdout_dropped, stdout_lines.len(), "stdout");
std::io::stdout()
.write_all(&stdout_lines.concat())
.context("writing child stdout")?;
}
if show_stderr {
report_dropped(stderr_dropped, stderr_lines.len(), "stderr");
std::io::stderr()
.write_all(&stderr_lines.concat())
.context("writing child stderr")?;
}
if failed {
return Err(AppError::Child(code));
}
Ok(())
}
fn report_dropped(dropped: usize, kept: usize, stream: &str) {
if let Some(notice) = dropped_notice(dropped, kept, stream) {
eprintln!("{notice}");
}
}
fn dropped_notice(dropped: usize, kept: usize, stream: &str) -> Option<String> {
(dropped > 0).then(|| {
format!(
"rat: {} {} dropped from {stream} — kept the newest {kept}",
compact_count(dropped),
if dropped == 1 { "line" } else { "lines" },
)
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_stream_that_fit_is_not_reported() {
assert_eq!(dropped_notice(0, 12, "stdout"), None);
}
#[test]
fn the_notice_names_the_stream_the_loss_and_what_survived() {
assert_eq!(
dropped_notice(2_000, 10_000, "stdout").as_deref(),
Some("rat: 2.0k lines dropped from stdout — kept the newest 10000")
);
assert_eq!(
dropped_notice(2_000, 10_000, "stderr").as_deref(),
Some("rat: 2.0k lines dropped from stderr — kept the newest 10000")
);
}
#[test]
fn one_dropped_line_is_a_line() {
assert_eq!(
dropped_notice(1, 3, "stdout").as_deref(),
Some("rat: 1 line dropped from stdout — kept the newest 3")
);
}
#[test]
fn what_survived_is_counted_rather_than_assumed_to_be_the_bound() {
assert_eq!(
dropped_notice(1, 0, "stdout").as_deref(),
Some("rat: 1 line dropped from stdout — kept the newest 0")
);
}
}