use std::ffi::OsStr;
use std::io::{BufRead, BufReader, Read};
use std::path::PathBuf;
use std::process::{Command, ExitStatus, Stdio};
use std::sync::mpsc;
use std::time::{Duration, Instant};
use rich::{AnsiDecoder, Console, ConsoleOptions, Renderable, Segment, Text};
use super::{render_texts, span, style, Look, State};
use crate::a11y::{AccessibilityPolicy, SymbolSet};
use crate::cancel::CancelToken;
use crate::diagnostic::Diagnostic;
use crate::event::EventView;
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum Stream {
Stdout,
Stderr,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct OutputLine {
pub stream: Stream,
pub text: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum CommandStatus {
Running,
Exited(i32),
Signalled(i32),
Cancelled,
FailedToStart(String),
}
impl CommandStatus {
pub fn state(&self) -> State {
match self {
CommandStatus::Running => State::Running,
CommandStatus::Exited(0) => State::Succeeded,
CommandStatus::Cancelled => State::Cancelled,
CommandStatus::Exited(_)
| CommandStatus::Signalled(_)
| CommandStatus::FailedToStart(_) => State::Failed,
}
}
pub fn success(&self) -> bool {
*self == CommandStatus::Exited(0)
}
pub fn detail(&self) -> String {
match self {
CommandStatus::Exited(0) | CommandStatus::Running | CommandStatus::Cancelled => {
String::new()
}
CommandStatus::Exited(code) => format!("exit {code}"),
CommandStatus::Signalled(signal) => format!("signal {signal}"),
CommandStatus::FailedToStart(_) => "failed to start".into(),
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct CommandRecord {
pub program: String,
pub args: Vec<String>,
pub cwd: Option<PathBuf>,
pub lines: Vec<OutputLine>,
pub status: CommandStatus,
pub duration: Duration,
}
impl CommandRecord {
pub fn new<I, S>(program: impl Into<String>, args: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
CommandRecord {
program: program.into(),
args: args.into_iter().map(Into::into).collect(),
cwd: None,
lines: Vec::new(),
status: CommandStatus::Running,
duration: Duration::ZERO,
}
}
pub fn from_command(command: &Command) -> Self {
let lossy = |s: &OsStr| s.to_string_lossy().into_owned();
let mut record =
CommandRecord::new(lossy(command.get_program()), command.get_args().map(lossy));
record.cwd = command.get_current_dir().map(PathBuf::from);
record
}
pub fn cwd(mut self, cwd: impl Into<PathBuf>) -> Self {
self.cwd = Some(cwd.into());
self
}
pub fn stdout(mut self, text: &str) -> Self {
self.push(Stream::Stdout, text);
self
}
pub fn stderr(mut self, text: &str) -> Self {
self.push(Stream::Stderr, text);
self
}
pub fn status(mut self, status: CommandStatus) -> Self {
self.status = status;
self
}
pub fn duration(mut self, duration: Duration) -> Self {
self.duration = duration;
self
}
pub fn push(&mut self, stream: Stream, text: &str) {
for line in text.lines() {
self.lines.push(OutputLine {
stream,
text: line.to_string(),
});
}
}
pub fn command_line(&self) -> String {
std::iter::once(&self.program)
.chain(&self.args)
.map(|word| quote(word))
.collect::<Vec<_>>()
.join(" ")
}
pub fn output(&self, stream: Stream) -> String {
self.lines
.iter()
.filter(|line| line.stream == stream)
.map(|line| line.text.as_str())
.collect::<Vec<_>>()
.join("\n")
}
pub fn state(&self) -> State {
self.status.state()
}
pub fn diagnostic(&self) -> Option<Diagnostic> {
let command = format!("`{}`", self.command_line());
let message = match &self.status {
CommandStatus::Exited(0) | CommandStatus::Running | CommandStatus::Cancelled => {
return None
}
CommandStatus::Exited(code) => format!("{command} exited with code {code}"),
CommandStatus::Signalled(signal) => {
format!("{command} was terminated by signal {signal}")
}
CommandStatus::FailedToStart(reason) => format!("{command} could not start: {reason}"),
};
let mut diagnostic = Diagnostic::error(message);
let last = self
.lines
.iter()
.rev()
.filter(|line| line.stream == Stream::Stderr)
.map(|line| AnsiDecoder::new().decode_line(&line.text))
.find(|text| !text.plain().trim().is_empty());
if let Some(text) = last {
diagnostic = diagnostic.cause(text.plain().trim());
}
Some(diagnostic)
}
pub fn view(&self) -> CommandView<'_> {
CommandView::new(self)
}
}
impl Renderable for CommandRecord {
fn rich_render(&self, console: &Console, options: &ConsoleOptions) -> Vec<Segment> {
self.view().rich_render(console, options)
}
}
fn quote(word: &str) -> String {
let plain = !word.is_empty()
&& word
.chars()
.all(|c| c.is_alphanumeric() || "_-./=:,+@%^".contains(c));
if plain {
word.to_string()
} else {
format!("'{}'", word.replace('\'', r"'\''"))
}
}
#[derive(Clone, Debug)]
pub struct CommandView<'a> {
record: &'a CommandRecord,
tail: Option<usize>,
full_on_failure: bool,
show_cwd: bool,
show_diagnostic: bool,
help: Vec<String>,
look: Look,
}
impl<'a> CommandView<'a> {
pub fn new(record: &'a CommandRecord) -> Self {
CommandView {
record,
tail: Some(10),
full_on_failure: true,
show_cwd: true,
show_diagnostic: true,
help: Vec::new(),
look: Look::default(),
}
}
pub fn tail(mut self, lines: usize) -> Self {
self.tail = Some(lines);
self
}
pub fn show_all(mut self) -> Self {
self.tail = None;
self
}
pub fn full_on_failure(mut self, full: bool) -> Self {
self.full_on_failure = full;
self
}
pub fn show_cwd(mut self, show: bool) -> Self {
self.show_cwd = show;
self
}
pub fn show_diagnostic(mut self, show: bool) -> Self {
self.show_diagnostic = show;
self
}
pub fn help(mut self, message: impl Into<String>) -> Self {
self.help.push(message.into());
self
}
pub fn symbols(mut self, set: SymbolSet) -> Self {
self.look.symbols = set;
self
}
pub fn animate(mut self, animate: bool) -> Self {
self.look.animate = animate;
self
}
pub fn policy(mut self, policy: &AccessibilityPolicy) -> Self {
self.look = Look::from_policy(policy);
self
}
fn header(&self, console: &Console) -> Text {
let record = self.record;
let state = record.state();
let mut text = Text::new("");
text.append(
&self.look.marker(state, record.duration),
span(console, state.style_key()),
);
text.append(" ", None);
text.append("$ ", span(console, "workflow.prompt"));
text.append(&record.command_line(), span(console, "workflow.command"));
let detail = record.status.detail();
if !detail.is_empty() {
text.append(" ", None);
text.append(&detail, span(console, state.style_key()));
}
if !matches!(record.status, CommandStatus::FailedToStart(_)) {
text.append(" ", None);
text.append(
&self.look.duration(record.duration),
span(console, "workflow.duration"),
);
}
text
}
fn output(&self, console: &Console) -> Vec<Text> {
let lines = &self.record.lines;
let full = self.full_on_failure && self.record.state() == State::Failed;
let shown = match self.tail {
Some(tail) if !full => tail.min(lines.len()),
_ => lines.len(),
};
let hidden = lines.len() - shown;
let mut out = Vec::new();
if hidden > 0 {
let noun = if hidden == 1 { "line" } else { "lines" };
out.push(Text::styled(
format!(" {} {hidden} {noun} hidden", self.look.ellipsis()),
style(console, "workflow.hidden"),
));
}
let (mut stdout, mut stderr) = (AnsiDecoder::new(), AnsiDecoder::new());
let bar = if self.look.ascii() { "|" } else { "│" };
for (index, line) in lines.iter().enumerate() {
let (decoder, gutter, gutter_key, key) = match line.stream {
Stream::Stdout => (&mut stdout, bar, "workflow.gutter", "workflow.stdout"),
Stream::Stderr => (&mut stderr, "!", "workflow.stderr", "workflow.stderr"),
};
let decoded = decoder.decode_line(&line.text);
if index < hidden {
continue;
}
let mut body = decoded;
let base = style(console, key);
if !base.is_null() {
body.set_base_style(base);
}
let mut text = Text::new(" ");
text.append(gutter, span(console, gutter_key));
text.append(" ", None);
out.push(text.append_text(&body));
}
out
}
}
impl Renderable for CommandView<'_> {
fn rich_render(&self, console: &Console, options: &ConsoleOptions) -> Vec<Segment> {
let mut lines = vec![self.header(console)];
if self.show_cwd {
if let Some(cwd) = &self.record.cwd {
lines.push(Text::styled(
format!(" in {}", cwd.display()),
style(console, "workflow.cwd"),
));
}
}
lines.extend(self.output(console));
let mut segments = render_texts(console, options, &lines);
let diagnostic = self
.record
.diagnostic()
.filter(|_| self.show_diagnostic)
.map(|diagnostic| {
let expanded = !self.help.is_empty();
self.help
.iter()
.fold(diagnostic, |d, help| d.help(help.clone()))
.view(if expanded {
EventView::Expanded
} else {
EventView::Compact
})
});
if let Some(diagnostic) = diagnostic {
if !segments.is_empty() {
segments.push(Segment::line());
}
segments.extend(diagnostic.rich_render(console, options));
}
segments
}
}
pub struct CommandRunner<'a> {
cancel: Option<CancelToken>,
tick: Duration,
on_update: Option<UpdateFn<'a>>,
}
type UpdateFn<'a> = Box<dyn FnMut(&CommandRecord) + 'a>;
impl Default for CommandRunner<'_> {
fn default() -> Self {
Self::new()
}
}
impl std::fmt::Debug for CommandRunner<'_> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("CommandRunner")
.field("cancel", &self.cancel)
.field("tick", &self.tick)
.finish_non_exhaustive()
}
}
const GRACE: Duration = Duration::from_secs(1);
impl<'a> CommandRunner<'a> {
pub fn new() -> Self {
CommandRunner {
cancel: None,
tick: Duration::from_millis(100),
on_update: None,
}
}
pub fn cancel(mut self, token: CancelToken) -> Self {
self.cancel = Some(token);
self
}
pub fn tick(mut self, every: Duration) -> Self {
self.tick = every.max(Duration::from_millis(1));
self
}
pub fn on_update(mut self, update: impl FnMut(&CommandRecord) + 'a) -> Self {
self.on_update = Some(Box::new(update));
self
}
pub fn run(mut self, command: &mut Command) -> CommandRecord {
let mut record = CommandRecord::from_command(command);
let start = Instant::now();
let mut child = match command
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
{
Ok(child) => child,
Err(error) => {
record.status = CommandStatus::FailedToStart(error.to_string());
return record;
}
};
let (tx, rx) = mpsc::channel();
if let Some(out) = child.stdout.take() {
pump(out, Stream::Stdout, tx.clone());
}
if let Some(err) = child.stderr.take() {
pump(err, Stream::Stderr, tx);
} else {
drop(tx);
}
let mut exited: Option<(ExitStatus, Instant)> = None;
let mut killed = false;
let mut pipes_open = true;
let status = loop {
if pipes_open {
match rx.recv_timeout(self.tick) {
Ok((stream, bytes)) => {
let text = String::from_utf8_lossy(&bytes);
let text = text.strip_suffix('\n').unwrap_or(&text);
let text = text.strip_suffix('\r').unwrap_or(text);
record.lines.push(OutputLine {
stream,
text: text.to_string(),
});
if exited.is_none() {
record.duration = start.elapsed();
}
self.notify(&record);
}
Err(mpsc::RecvTimeoutError::Timeout) => {
if exited.is_none() {
record.duration = start.elapsed();
self.notify(&record);
}
}
Err(mpsc::RecvTimeoutError::Disconnected) => pipes_open = false,
}
} else if exited.is_none() {
std::thread::sleep(self.tick);
record.duration = start.elapsed();
self.notify(&record);
}
if !killed
&& exited.is_none()
&& self.cancel.as_ref().is_some_and(CancelToken::is_cancelled)
{
let _ = child.kill();
killed = true;
}
if exited.is_none() {
match child.try_wait() {
Ok(Some(status)) => {
record.duration = start.elapsed();
exited = Some((status, Instant::now()));
}
Ok(None) => {}
Err(error) => {
record.duration = start.elapsed();
break Err(error);
}
}
}
match exited {
Some((status, _)) if !pipes_open => break Ok(status),
Some((status, at)) if at.elapsed() > GRACE => break Ok(status),
_ => {}
}
};
record.status = match status {
_ if killed => CommandStatus::Cancelled,
Ok(status) => exit_status(status),
Err(error) => CommandStatus::FailedToStart(error.to_string()),
};
record
}
fn notify(&mut self, record: &CommandRecord) {
if let Some(update) = &mut self.on_update {
update(record);
}
}
}
fn pump(reader: impl Read + Send + 'static, stream: Stream, tx: mpsc::Sender<(Stream, Vec<u8>)>) {
std::thread::spawn(move || {
let mut reader = BufReader::new(reader);
loop {
let mut line = Vec::new();
match reader.read_until(b'\n', &mut line) {
Ok(0) | Err(_) => break,
Ok(_) => {
if tx.send((stream, line)).is_err() {
break;
}
}
}
}
});
}
fn exit_status(status: ExitStatus) -> CommandStatus {
if let Some(code) = status.code() {
return CommandStatus::Exited(code);
}
#[cfg(unix)]
{
use std::os::unix::process::ExitStatusExt;
if let Some(signal) = status.signal() {
return CommandStatus::Signalled(signal);
}
}
CommandStatus::Exited(-1)
}