cb8b093564
Internal Red Bear subprojects (tlc, redbear-*, redbear-greeter, etc.) live under local/recipes/* and have no upstream source — they are committed to our own gitea only. If lost, they cannot be recovered from any public source. The previous guard used is_local_overlay() && !redbear_allow_local_unfetch() which could be bypassed by setting REDBEAR_ALLOW_LOCAL_UNFETCH=1. This was triggered inadvertently (exact trigger unknown) and destroyed the source tree of local/recipes/tui/tlc/source/. This commit makes the protection UNCONDITIONAL: - is_local_overlay() already correctly identifies any path under local/recipes/ as internal. - The handle_clean unfetch path now refuses ALL local/recipes/* sources with a clear error message. No env var can override this. - The fetch() path's git-reset/git-clean-ffdx and source-wipe guards now also refuse local overlays unconditionally. - The dead redbear_allow_local_unfetch() function is removed. - Makefile distclean-nuclear target is documented as a no-op for local/. distclean still works for non-local recipes (upstream sources from sources/redbear-0.1.0/ or git mirrors can be safely re-fetched).
668 lines
22 KiB
Rust
668 lines
22 KiB
Rust
//! M-Enter exec output view.
|
|
//!
|
|
//! Captures the stdout and stderr of a shell command (run via
|
|
//! [`std::process::Command`]) into an in-memory ring buffer of
|
|
//! [`OutputLine`] entries, then renders the captured output in a
|
|
//! scrollable full-screen overlay. The exit status of the command is
|
|
//! preserved so the user can tell at a glance whether the command
|
|
//! succeeded.
|
|
//!
|
|
//! This dialog is a pure view: it does not parse ANSI escape codes,
|
|
//! does not paginate, and does not interact with the user's
|
|
//! terminal. The caller supplies a shell command string; the dialog
|
|
//! decides when to spawn the child (synchronously, in `start`) and
|
|
//! when to surface the captured output (on every key event, the
|
|
//! caller forwards the latest output via [`ExecDialog::render`]).
|
|
//!
|
|
//! Suspending the running command with `C-z` requires sending
|
|
//! `SIGTSTP` to the child's process group, which is platform-specific
|
|
//! work. For now, the dialog only reports [`ExecOutcome::Suspend`] so
|
|
//! the caller can decide what to do; the child is left running. This
|
|
//! is documented as a follow-up in the project plan.
|
|
|
|
use std::io::{BufRead, BufReader, Read};
|
|
use std::path::Path;
|
|
use std::process::{Command, Stdio};
|
|
use std::sync::mpsc::{self, Receiver};
|
|
use std::thread;
|
|
|
|
use ratatui::layout::{Constraint, Direction, Layout, Rect};
|
|
use ratatui::style::{Modifier, Style};
|
|
use ratatui::text::{Line, Span};
|
|
use ratatui::widgets::{Block, Borders, Clear, Paragraph, Wrap};
|
|
use ratatui::Frame;
|
|
|
|
use crate::key::Key;
|
|
use crate::terminal::color::Theme;
|
|
|
|
/// Outcome of feeding a key to [`ExecDialog`].
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub enum ExecOutcome {
|
|
/// The dialog is still open; feed the next key.
|
|
Running,
|
|
/// The user pressed Esc (or otherwise asked to close).
|
|
Close,
|
|
/// The user pressed `C-z`. The caller decides what to do —
|
|
/// in this v1, the child is left running until the dialog
|
|
/// is closed. Full shell-suspend via `SIGTSTP` to the process
|
|
/// group is a follow-up.
|
|
Suspend,
|
|
}
|
|
|
|
/// One line of captured output.
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct OutputLine {
|
|
/// The line text (without the trailing newline).
|
|
pub text: String,
|
|
/// `true` if the line came from stderr, `false` if from stdout.
|
|
pub is_stderr: bool,
|
|
}
|
|
|
|
impl OutputLine {
|
|
/// Build a stdout line.
|
|
fn stdout(text: impl Into<String>) -> Self {
|
|
Self {
|
|
text: text.into(),
|
|
is_stderr: false,
|
|
}
|
|
}
|
|
|
|
/// Build a stderr line.
|
|
fn stderr(text: impl Into<String>) -> Self {
|
|
Self {
|
|
text: text.into(),
|
|
is_stderr: true,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Default ring-buffer cap for captured output lines.
|
|
const DEFAULT_MAX_LINES: usize = 1000;
|
|
|
|
/// M-Enter exec output view.
|
|
///
|
|
/// The dialog owns a copy of the command string, the captured output
|
|
/// (truncated to the most-recent `max_output_lines` lines), the
|
|
/// process's exit status, and a cursor into the output for scrolling.
|
|
pub struct ExecDialog {
|
|
/// The command that was run (for display in the title).
|
|
cmd: String,
|
|
/// Captured output (most recent line last; truncated on overflow).
|
|
output: Vec<OutputLine>,
|
|
/// Exit status: `Some(0)` on success, `Some(n)` on non-zero exit,
|
|
/// `None` if the child was killed by a signal or failed to spawn.
|
|
status: Option<i32>,
|
|
/// Index of the topmost line currently scrolled into view.
|
|
cursor: usize,
|
|
/// Maximum number of lines kept in `output`. Older lines are
|
|
/// dropped when the buffer would grow past this.
|
|
max_output_lines: usize,
|
|
/// `true` while the dialog is open. Always `true` after `new()`;
|
|
/// the caller flips this to `false` once it has seen
|
|
/// [`ExecOutcome::Close`].
|
|
running: bool,
|
|
}
|
|
|
|
impl Default for ExecDialog {
|
|
fn default() -> Self {
|
|
Self::new()
|
|
}
|
|
}
|
|
|
|
impl ExecDialog {
|
|
/// Create a new, empty exec dialog (no command, no output).
|
|
#[must_use]
|
|
pub fn new() -> Self {
|
|
Self {
|
|
cmd: String::new(),
|
|
output: Vec::new(),
|
|
status: None,
|
|
cursor: 0,
|
|
max_output_lines: DEFAULT_MAX_LINES,
|
|
running: true,
|
|
}
|
|
}
|
|
|
|
/// Run `cmd` synchronously in a child process with both pipes
|
|
/// captured. The child's `cwd` is set to `cwd` (if it exists).
|
|
/// On success, the captured stdout+stderr are appended to
|
|
/// `self.output` (truncated to `self.max_output_lines`) and
|
|
/// `self.status` is set to the exit code.
|
|
///
|
|
/// # Errors
|
|
///
|
|
/// Returns `Err(msg)` if the child could not be spawned. The
|
|
/// `msg` is a human-readable description of the failure.
|
|
pub fn start(&mut self, cmd: String, cwd: &Path) -> Result<(), String> {
|
|
// Reset per-run state but keep the configured cap.
|
|
self.cmd = cmd.clone();
|
|
self.output.clear();
|
|
self.status = None;
|
|
self.cursor = 0;
|
|
|
|
let mut command = Command::new("sh");
|
|
command.arg("-c").arg(&cmd);
|
|
command.current_dir(cwd);
|
|
command.stdout(Stdio::piped());
|
|
command.stderr(Stdio::piped());
|
|
command.stdin(Stdio::null());
|
|
|
|
let mut child = command
|
|
.spawn()
|
|
.map_err(|e| format!("failed to spawn `{}`: {e}", cmd))?;
|
|
|
|
let stdout = child
|
|
.stdout
|
|
.take()
|
|
.ok_or_else(|| "child stdout unavailable".to_string())?;
|
|
let stderr = child
|
|
.stderr
|
|
.take()
|
|
.ok_or_else(|| "child stderr unavailable".to_string())?;
|
|
|
|
let stdout_lines = read_lines_threaded(stdout, false);
|
|
let stderr_lines = read_lines_threaded(stderr, true);
|
|
|
|
let mut all_lines = merge_pipes_streams(stdout_lines, stderr_lines);
|
|
self.output.append(&mut all_lines);
|
|
self.truncate_to_cap();
|
|
|
|
let exit = child.wait().map_err(|e| format!("waitpid failed: {e}"))?;
|
|
self.status = exit.code();
|
|
Ok(())
|
|
}
|
|
|
|
/// Forward `key` to the dialog. Esc closes, `C-z` requests a
|
|
/// suspend, all other keys are interpreted as scroll/page
|
|
/// commands.
|
|
pub fn handle_key(&mut self, key: Key) -> ExecOutcome {
|
|
if !self.running {
|
|
return ExecOutcome::Close;
|
|
}
|
|
let code = key.code;
|
|
let mods = key.mods;
|
|
let key_k = Key::from_char('k');
|
|
let key_j = Key::from_char('j');
|
|
match key {
|
|
Key::ESCAPE => {
|
|
self.running = false;
|
|
ExecOutcome::Close
|
|
}
|
|
k if k == Key::ctrl('z') => {
|
|
// v1: report Suspend but keep the child running.
|
|
// The caller decides what to do (typically: nothing,
|
|
// and the child reaps when the next command starts).
|
|
ExecOutcome::Suspend
|
|
}
|
|
k if k == key_k => {
|
|
self.scroll_up(1);
|
|
ExecOutcome::Running
|
|
}
|
|
k if k == key_j => {
|
|
self.scroll_down(1);
|
|
ExecOutcome::Running
|
|
}
|
|
_ if code == 0x2191 => {
|
|
self.scroll_up(1);
|
|
ExecOutcome::Running
|
|
}
|
|
_ if code == 0x2193 => {
|
|
self.scroll_down(1);
|
|
ExecOutcome::Running
|
|
}
|
|
_ if code == b' ' as u32 => {
|
|
self.page_down();
|
|
ExecOutcome::Running
|
|
}
|
|
_ if code == 0x21DF => {
|
|
self.page_down();
|
|
ExecOutcome::Running
|
|
}
|
|
_ if code == b'b' as u32 => {
|
|
self.page_up();
|
|
ExecOutcome::Running
|
|
}
|
|
_ if code == 0x21DE => {
|
|
self.page_up();
|
|
ExecOutcome::Running
|
|
}
|
|
_ if code == b'g' as u32 && mods.is_empty() => {
|
|
self.cursor = 0;
|
|
ExecOutcome::Running
|
|
}
|
|
_ if code == b'G' as u32 => {
|
|
self.cursor = self.last_top();
|
|
ExecOutcome::Running
|
|
}
|
|
_ => ExecOutcome::Running,
|
|
}
|
|
}
|
|
|
|
/// Render the dialog into `frame`, centered on `area`.
|
|
///
|
|
/// `theme` supplies the title, status, and body colours so the
|
|
/// dialog follows the active skin.
|
|
pub fn render(&self, frame: &mut Frame, area: Rect, theme: &Theme) {
|
|
let popup = centered_rect(area, 0.85, 0.85);
|
|
frame.render_widget(Clear, popup);
|
|
|
|
let block = Block::default()
|
|
.borders(Borders::ALL)
|
|
.border_style(Style::default().fg(theme.title_fg))
|
|
.title(Span::styled(
|
|
format!(" Exec: {} ", self.cmd),
|
|
Style::default()
|
|
.fg(theme.title_fg)
|
|
.bg(theme.title_bg)
|
|
.add_modifier(Modifier::BOLD),
|
|
));
|
|
let inner = block.inner(popup);
|
|
frame.render_widget(block, popup);
|
|
|
|
let chunks = Layout::default()
|
|
.direction(Direction::Vertical)
|
|
.constraints([Constraint::Min(3), Constraint::Length(1)])
|
|
.split(inner);
|
|
|
|
// Body: rendered output. We translate `cursor` (top-line
|
|
// index) into a slice of visible lines.
|
|
let body_lines = self.visible_lines(chunks[0].height as usize, theme);
|
|
let body = Paragraph::new(body_lines).wrap(Wrap { trim: false });
|
|
frame.render_widget(body, chunks[0]);
|
|
|
|
// Status line: exit code, line count, hint.
|
|
let status_text = match self.status {
|
|
Some(0) => "exit 0".to_string(),
|
|
Some(n) => format!("exit {n}"),
|
|
None => "no status".to_string(),
|
|
};
|
|
let status_color = match self.status {
|
|
Some(0) => theme.executable,
|
|
Some(_) => theme.error,
|
|
None => theme.hidden,
|
|
};
|
|
let mut spans = vec![
|
|
Span::styled(
|
|
format!(" [{status_text}] "),
|
|
Style::default()
|
|
.fg(theme.cursor_fg)
|
|
.bg(status_color)
|
|
.add_modifier(Modifier::BOLD),
|
|
),
|
|
Span::styled(
|
|
format!(" {} lines ", self.output.len()),
|
|
Style::default().fg(theme.hidden),
|
|
),
|
|
Span::styled(" Esc", Style::default().fg(theme.warning)),
|
|
Span::styled(" close ", Style::default().fg(theme.hidden)),
|
|
Span::styled("^Z", Style::default().fg(theme.warning)),
|
|
Span::styled(" suspend", Style::default().fg(theme.hidden)),
|
|
];
|
|
if !self.is_at_bottom() {
|
|
spans.push(Span::styled(
|
|
" (more ↑↓)",
|
|
Style::default().fg(theme.info),
|
|
));
|
|
}
|
|
let _ = Line::from(spans.clone());
|
|
frame.render_widget(Paragraph::new(Line::from(spans)), chunks[1]);
|
|
}
|
|
|
|
/// `true` while the dialog is still accepting keys.
|
|
#[must_use]
|
|
pub fn is_running(&self) -> bool {
|
|
self.running
|
|
}
|
|
|
|
/// Number of captured output lines.
|
|
#[must_use]
|
|
pub fn output_len(&self) -> usize {
|
|
self.output.len()
|
|
}
|
|
|
|
/// Borrow the captured output lines (stdout and stderr, in the
|
|
/// order they were captured).
|
|
#[must_use]
|
|
pub fn output(&self) -> &[OutputLine] {
|
|
&self.output
|
|
}
|
|
|
|
/// The most recent exit status (`None` if not finished).
|
|
#[must_use]
|
|
pub fn status(&self) -> Option<i32> {
|
|
self.status
|
|
}
|
|
|
|
/// The command string this dialog was started for.
|
|
#[must_use]
|
|
pub fn command(&self) -> &str {
|
|
&self.cmd
|
|
}
|
|
|
|
/// Override the per-dialog output cap. Useful for tests.
|
|
pub fn set_max_output_lines(&mut self, n: usize) {
|
|
self.max_output_lines = n.max(1);
|
|
self.truncate_to_cap();
|
|
}
|
|
|
|
fn truncate_to_cap(&mut self) {
|
|
if self.output.len() > self.max_output_lines {
|
|
let drop = self.output.len() - self.max_output_lines;
|
|
self.output.drain(..drop);
|
|
// Keep the cursor pointing at the new top.
|
|
if self.cursor > 0 {
|
|
self.cursor = self.cursor.saturating_sub(drop);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn last_top(&self) -> usize {
|
|
// The largest cursor value such that the last line is still
|
|
// visible at the bottom of the viewport. We approximate the
|
|
// viewport height as one row here; the render path uses the
|
|
// real height.
|
|
self.output.len().saturating_sub(1)
|
|
}
|
|
|
|
fn is_at_bottom(&self) -> bool {
|
|
self.cursor + 1 >= self.output.len()
|
|
}
|
|
|
|
fn scroll_up(&mut self, n: usize) {
|
|
self.cursor = self.cursor.saturating_sub(n);
|
|
}
|
|
|
|
fn scroll_down(&mut self, n: usize) {
|
|
let max_top = self.output.len().saturating_sub(1);
|
|
self.cursor = (self.cursor + n).min(max_top);
|
|
}
|
|
|
|
fn page_up(&mut self) {
|
|
self.scroll_up(10);
|
|
}
|
|
|
|
fn page_down(&mut self) {
|
|
self.scroll_down(10);
|
|
}
|
|
|
|
fn visible_lines(&self, viewport_h: usize, theme: &Theme) -> Vec<Line<'_>> {
|
|
if self.output.is_empty() {
|
|
return vec![Line::from(Span::styled(
|
|
"(no output)",
|
|
Style::default().fg(theme.hidden),
|
|
))];
|
|
}
|
|
let viewport = viewport_h.max(1);
|
|
let start = self.cursor.min(self.output.len().saturating_sub(1));
|
|
let end = (start + viewport).min(self.output.len());
|
|
self.output[start..end]
|
|
.iter()
|
|
.map(|l| {
|
|
let style = if l.is_stderr {
|
|
Style::default().fg(theme.error)
|
|
} else {
|
|
Style::default().fg(theme.foreground)
|
|
};
|
|
Line::from(Span::styled(l.text.as_str(), style))
|
|
})
|
|
.collect()
|
|
}
|
|
}
|
|
|
|
impl std::fmt::Debug for ExecDialog {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
f.debug_struct("ExecDialog")
|
|
.field("cmd", &self.cmd)
|
|
.field("output_len", &self.output.len())
|
|
.field("status", &self.status)
|
|
.field("cursor", &self.cursor)
|
|
.field("max_output_lines", &self.max_output_lines)
|
|
.field("running", &self.running)
|
|
.finish()
|
|
}
|
|
}
|
|
|
|
/// Drain a `Read` pipe into a `Vec<OutputLine>`. Kept for tests and
|
|
/// for callers that need a synchronous single-stream reader; the
|
|
/// production path uses [`read_lines_threaded`] instead.
|
|
#[allow(dead_code)]
|
|
fn read_lines<R: Read>(r: R, is_stderr: bool) -> Vec<OutputLine> {
|
|
let reader = BufReader::new(r);
|
|
let mut out = Vec::new();
|
|
for line in reader.lines() {
|
|
match line {
|
|
Ok(s) => {
|
|
if is_stderr {
|
|
out.push(OutputLine::stderr(s));
|
|
} else {
|
|
out.push(OutputLine::stdout(s));
|
|
}
|
|
}
|
|
Err(_) => out.push(OutputLine::stderr("(read error)")),
|
|
}
|
|
}
|
|
out
|
|
}
|
|
|
|
/// Drain a `Read` pipe on a dedicated thread, shipping each parsed
|
|
/// line through an mpsc channel as soon as it is read.
|
|
fn read_lines_threaded<R: Read + Send + 'static>(
|
|
r: R,
|
|
is_stderr: bool,
|
|
) -> Receiver<OutputLine> {
|
|
let (tx, rx) = mpsc::channel();
|
|
thread::spawn(move || {
|
|
let reader = BufReader::new(r);
|
|
for line in reader.lines() {
|
|
let owned = match line {
|
|
Ok(s) => s,
|
|
Err(_) => "(read error)".to_string(),
|
|
};
|
|
let out = if is_stderr {
|
|
OutputLine::stderr(owned)
|
|
} else {
|
|
OutputLine::stdout(owned)
|
|
};
|
|
if tx.send(out).is_err() {
|
|
break;
|
|
}
|
|
}
|
|
});
|
|
rx
|
|
}
|
|
|
|
/// Drain both receivers, returning lines as: **all of stdout first,
|
|
/// then all of stderr**. This matches the legacy sequential-drain
|
|
/// order so existing test assertions and `output()` consumers see
|
|
/// a stable layout.
|
|
fn merge_pipes_streams(
|
|
stdout: Receiver<OutputLine>,
|
|
stderr: Receiver<OutputLine>,
|
|
) -> Vec<OutputLine> {
|
|
let mut all = Vec::new();
|
|
while let Ok(line) = stdout.recv() {
|
|
all.push(line);
|
|
}
|
|
while let Ok(line) = stderr.recv() {
|
|
all.push(line);
|
|
}
|
|
all
|
|
}
|
|
|
|
fn centered_rect(area: Rect, width_pct: f32, height_pct: f32) -> Rect {
|
|
let w = (area.width as f32 * width_pct.clamp(0.1, 1.0)) as u16;
|
|
let h = (area.height as f32 * height_pct.clamp(0.1, 1.0)) as u16;
|
|
let x = area.x + area.width.saturating_sub(w) / 2;
|
|
let y = area.y + area.height.saturating_sub(h) / 2;
|
|
Rect::new(x, y, w, h)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use std::fs;
|
|
use std::path::PathBuf;
|
|
use std::process::Command;
|
|
|
|
fn temp_dir(name: &str) -> PathBuf {
|
|
let dir = std::env::temp_dir().join(format!("tlc-exec-{name}"));
|
|
let _ = fs::create_dir_all(&dir);
|
|
dir
|
|
}
|
|
|
|
#[test]
|
|
fn exec_dialog_new_empty() {
|
|
let d = ExecDialog::new();
|
|
assert!(d.is_running());
|
|
assert_eq!(d.output_len(), 0);
|
|
assert_eq!(d.status(), None);
|
|
assert_eq!(d.command(), "");
|
|
}
|
|
|
|
#[test]
|
|
fn exec_dialog_start_runs_command() {
|
|
let dir = temp_dir("start");
|
|
let mut d = ExecDialog::new();
|
|
d.start("true".to_string(), &dir).expect("start");
|
|
assert_eq!(d.status(), Some(0));
|
|
assert!(d.is_running(), "dialog must stay open after start");
|
|
let _ = fs::remove_dir_all(&dir);
|
|
}
|
|
|
|
#[test]
|
|
fn exec_dialog_captures_stdout() {
|
|
let dir = temp_dir("stdout");
|
|
let mut d = ExecDialog::new();
|
|
d.start("printf 'a\\nb\\nc\\n'".to_string(), &dir)
|
|
.expect("start");
|
|
assert_eq!(d.status(), Some(0));
|
|
let text: Vec<&str> = d
|
|
.output
|
|
.iter()
|
|
.filter(|l| !l.is_stderr)
|
|
.map(|l| l.text.as_str())
|
|
.collect();
|
|
assert_eq!(text, vec!["a", "b", "c"]);
|
|
let _ = fs::remove_dir_all(&dir);
|
|
}
|
|
|
|
#[test]
|
|
fn exec_dialog_captures_stderr() {
|
|
let dir = temp_dir("stderr");
|
|
let mut d = ExecDialog::new();
|
|
d.start("printf 'err1\\nerr2\\n' 1>&2".to_string(), &dir)
|
|
.expect("start");
|
|
assert_eq!(d.status(), Some(0));
|
|
let stderr: Vec<&str> = d
|
|
.output
|
|
.iter()
|
|
.filter(|l| l.is_stderr)
|
|
.map(|l| l.text.as_str())
|
|
.collect();
|
|
assert_eq!(stderr, vec!["err1", "err2"]);
|
|
let _ = fs::remove_dir_all(&dir);
|
|
}
|
|
|
|
#[test]
|
|
fn exec_dialog_non_zero_exit_shows_status() {
|
|
let dir = temp_dir("nonzero");
|
|
let mut d = ExecDialog::new();
|
|
d.start("sh -c 'exit 7'".to_string(), &dir).expect("start");
|
|
assert_eq!(d.status(), Some(7));
|
|
let _ = fs::remove_dir_all(&dir);
|
|
}
|
|
|
|
#[test]
|
|
fn exec_dialog_esc_returns_close() {
|
|
let dir = temp_dir("esc");
|
|
let mut d = ExecDialog::new();
|
|
d.start("true".to_string(), &dir).unwrap();
|
|
let r = d.handle_key(Key::ESCAPE);
|
|
assert_eq!(r, ExecOutcome::Close);
|
|
assert!(!d.is_running());
|
|
let _ = fs::remove_dir_all(&dir);
|
|
}
|
|
|
|
#[test]
|
|
fn exec_dialog_max_output_lines_truncates() {
|
|
let dir = temp_dir("truncate");
|
|
let mut d = ExecDialog::new();
|
|
d.set_max_output_lines(3);
|
|
d.start("printf 'l1\\nl2\\nl3\\nl4\\nl5\\n'".to_string(), &dir)
|
|
.unwrap();
|
|
assert_eq!(d.output_len(), 3);
|
|
let text: Vec<&str> = d.output.iter().map(|l| l.text.as_str()).collect();
|
|
assert_eq!(text, vec!["l3", "l4", "l5"]);
|
|
let _ = fs::remove_dir_all(&dir);
|
|
}
|
|
|
|
/// `Command::new("sh")` is the same path the production code
|
|
/// uses. If `sh` is missing, the test suite would fail anyway,
|
|
/// so this is a sanity check that the harness is correctly set
|
|
/// up.
|
|
#[test]
|
|
fn test_harness_has_sh() {
|
|
let out = Command::new("sh")
|
|
.arg("-c")
|
|
.arg("echo ok")
|
|
.output()
|
|
.expect("sh must be on PATH");
|
|
assert!(out.status.success());
|
|
assert!(std::str::from_utf8(&out.stdout).unwrap().contains("ok"));
|
|
}
|
|
|
|
#[test]
|
|
fn output_line_predicates() {
|
|
let s = OutputLine::stdout("a");
|
|
let e = OutputLine::stderr("b");
|
|
assert!(!s.is_stderr);
|
|
assert!(e.is_stderr);
|
|
assert_eq!(s.text, "a");
|
|
assert_eq!(e.text, "b");
|
|
}
|
|
|
|
#[test]
|
|
fn exec_dialog_parallel_drain_preserves_line_order_within_stream() {
|
|
let dir = temp_dir("parallel");
|
|
let mut d = ExecDialog::new();
|
|
d.start(
|
|
"printf 's1\\ns2\\ns3\\n' && printf 'e1\\ne2\\n' 1>&2".to_string(),
|
|
&dir,
|
|
)
|
|
.expect("start");
|
|
let stdout: Vec<&str> = d
|
|
.output
|
|
.iter()
|
|
.filter(|l| !l.is_stderr)
|
|
.map(|l| l.text.as_str())
|
|
.collect();
|
|
let stderr: Vec<&str> = d
|
|
.output
|
|
.iter()
|
|
.filter(|l| l.is_stderr)
|
|
.map(|l| l.text.as_str())
|
|
.collect();
|
|
assert_eq!(stdout, vec!["s1", "s2", "s3"]);
|
|
assert_eq!(stderr, vec!["e1", "e2"]);
|
|
let _ = fs::remove_dir_all(&dir);
|
|
}
|
|
|
|
#[test]
|
|
fn exec_dialog_parallel_drain_handles_large_output() {
|
|
let dir = temp_dir("large");
|
|
let mut d = ExecDialog::new();
|
|
let cmd = r#"
|
|
for i in $(seq 1 500); do echo "stdout_$i"; done
|
|
for i in $(seq 1 500); do echo "stderr_$i" 1>&2; done
|
|
"#;
|
|
d.start(cmd.to_string(), &dir).expect("start");
|
|
let total = d.output.len();
|
|
assert_eq!(total, 1000, "both streams must be fully captured");
|
|
let stdout_count = d.output.iter().filter(|l| !l.is_stderr).count();
|
|
let stderr_count = d.output.iter().filter(|l| l.is_stderr).count();
|
|
assert_eq!(stdout_count, 500);
|
|
assert_eq!(stderr_count, 500);
|
|
let _ = fs::remove_dir_all(&dir);
|
|
}
|
|
}
|