feat(shuma): ejecución directa + captura configurable por sesión

bash deja de ser el ejecutor. shuma-exec ahora tiene dos modos:
- Exec::Direct — brahman lanza y conecta cada etapa del pipe con
  descriptores reales; control total del árbol de procesos.
- Exec::Shell — fallback a `bash -c` para sintaxis que el modo directo
  aún no absorbe (globs, $VAR, redirecciones, &&). bash = un parser
  de sintaxis, no el ejecutor por defecto.

El shell elige: pipe simple (sólo comandos/args/`|`) → directo; algo
más → shell. La WorkSession lleva su CapturePolicy (límite + spill),
configurable con `:limit <MB>` y `:spill on|off`; la barra de estado
la muestra. Si spill está activo, la salida excedente se vuelca a un
archivo en vez de descartarse (RunEvent::Spilled).

shuma-exec: 11 tests (directo, pipes, spill, kill de pipeline).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
sergio
2026-05-20 20:06:54 +00:00
parent b4be5e1c72
commit b08d5cbe0a
4 changed files with 805 additions and 217 deletions
+327 -203
View File
@@ -1,63 +1,86 @@
//! `shuma-exec` — ejecución de comandos del shell con salida en streaming.
//!
//! Lanza una línea de comandos en un shell (`bash -c …`) dentro de un
//! directorio, y entrega su salida **a medida que ocurre**: cada línea
//! de stdout o stderr llega como un [`RunEvent`] por un canal, sin
//! esperar a que el proceso termine.
//! Dos modos de ejecución, un mismo contrato de eventos:
//!
//! Esto es lo que `sandokan` no hace: el orquestador es poll-based y
//! orquesta *Cards* de brahman (entidades aisladas y supervisadas). El
//! shell, en cambio, corre líneas de shell ad-hoc y necesita ver la
//! salida fluir. Dos capas distintas, a propósito.
//! - [`Exec::Direct`] — brahman lanza y **conecta los procesos él mismo**:
//! un `Command` por etapa del pipe, los pipes cableados con descriptores
//! reales. Control total del árbol de procesos (matar todo el pipe de
//! un golpe). Es el modo preferido.
//! - [`Exec::Shell`] — delega a un shell externo (`bash -c "<line>"`).
//! Reservado para sintaxis que el modo directo aún no absorbe (globs,
//! `$VAR`, redirecciones, `&&`). bash es **sólo un parser de sintaxis**,
//! no el ejecutor por defecto.
//!
//! **Captura acotada.** Para no cargar en RAM un stream de gigabytes, la
//! captura tiene un límite de bytes ([`CommandSpec::capture_limit`]):
//! pasado el límite se emite [`RunEvent::Truncated`] una vez y el resto
//! se **descarta** — pero el pipe se sigue drenando, así el proceso no
//! se bloquea y termina normal.
//! **Captura acotada.** [`CommandSpec::capture_limit`] topa los bytes en
//! RAM; pasado el tope, o se **descarta** ([`RunEvent::Truncated`]) o se
//! **vuelca a un archivo** si hay [`CommandSpec::spill_path`]
//! ([`RunEvent::Spilled`]). En ambos casos el pipe se sigue drenando, así
//! el proceso no se bloquea.
//!
//! **Reproceso.** [`CommandSpec::stdin_data`] alimenta un texto por la
//! entrada estándar del proceso: permite reprocesar la salida capturada
//! de un comando previo con otra herramienta, sin volver a correr el
//! comando original.
//!
//! El crate es agnóstico de frontend: el proceso y sus lectores corren
//! en hilos; el consumidor (shell GPUI o TUI) drena el canal cuando
//! quiere — sin `async`, sin acoplarse a ningún runtime.
//! entrada estándar: reprocesa la salida capturada de un comando previo
//! sin volver a correr el original.
#![forbid(unsafe_code)]
use std::fs::File;
use std::io::{BufRead, BufReader, Read, Write};
use std::path::PathBuf;
use std::process::{Child, Command, Stdio};
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use std::sync::mpsc::{self, Receiver, Sender, TryRecvError};
use std::sync::{Arc, Mutex};
use std::thread::JoinHandle;
/// Qué ejecutar: una línea de comandos, en un directorio, con un shell.
/// Una etapa del pipe en ejecución directa: un binario y sus argumentos
/// ya resueltos (sin comillas, sin metacaracteres).
#[derive(Debug, Clone)]
pub struct StageSpec {
pub program: String,
pub args: Vec<String>,
}
/// Cómo ejecutar.
#[derive(Debug, Clone)]
pub enum Exec {
/// Vía un shell externo — `program -c "<line>"`.
Shell { line: String, program: String },
/// Directo — brahman lanza y conecta cada etapa.
Direct { stages: Vec<StageSpec> },
}
/// Qué ejecutar y con qué política de captura.
#[derive(Debug, Clone)]
pub struct CommandSpec {
/// La línea completa — se pasa como `shell -c "<line>"`.
pub line: String,
/// Directorio de trabajo del proceso.
pub exec: Exec,
pub cwd: String,
/// Programa de shell — `"bash"`, `"sh"`, `"fish"`…
pub shell: String,
/// Tope de bytes a capturar; `0` = sin límite. Pasado el tope, la
/// salida se descarta (se emite [`RunEvent::Truncated`]).
/// Tope de captura en bytes; `0` = sin límite.
pub capture_limit: usize,
/// Si está, la salida que excede el tope se vuelca a este archivo.
pub spill_path: Option<PathBuf>,
/// Texto a alimentar por stdin — para reprocesar una salida previa.
pub stdin_data: Option<String>,
}
impl CommandSpec {
/// Spec con `bash` como shell, sin límite ni stdin.
pub fn bash(line: impl Into<String>, cwd: impl Into<String>) -> Self {
/// Ejecución vía `bash -c "<line>"`.
pub fn shell(line: impl Into<String>, cwd: impl Into<String>) -> Self {
Self {
line: line.into(),
exec: Exec::Shell { line: line.into(), program: "bash".into() },
cwd: cwd.into(),
shell: "bash".into(),
capture_limit: 0,
spill_path: None,
stdin_data: None,
}
}
/// Ejecución directa de un pipe de etapas.
pub fn direct(stages: Vec<StageSpec>, cwd: impl Into<String>) -> Self {
Self {
exec: Exec::Direct { stages },
cwd: cwd.into(),
capture_limit: 0,
spill_path: None,
stdin_data: None,
}
}
@@ -68,6 +91,12 @@ impl CommandSpec {
self
}
/// Vuelca la salida excedente a `path` en vez de descartarla.
pub fn with_spill(mut self, path: PathBuf) -> Self {
self.spill_path = Some(path);
self
}
/// Alimenta `data` por la entrada estándar del proceso (encadenable).
pub fn with_stdin(mut self, data: impl Into<String>) -> Self {
self.stdin_data = Some(data.into());
@@ -84,6 +113,8 @@ pub enum RunEvent {
Stderr(String),
/// La captura alcanzó su tope; lo que sigue se descarta.
Truncated,
/// La captura alcanzó su tope; el resto se vuelca al archivo dado.
Spilled(String),
/// El proceso terminó con este código de salida.
Exited(i32),
/// El proceso no pudo siquiera lanzarse.
@@ -97,28 +128,46 @@ impl RunEvent {
}
}
/// Destino de volcado de la salida excedente — compartido entre lectores.
struct SpillSink {
path: PathBuf,
file: Mutex<Option<File>>,
}
impl SpillSink {
/// Escribe una línea excedente al archivo (lo abre perezosamente).
fn write_line(&self, line: &str) {
let mut g = self.file.lock().expect("spill lock");
if g.is_none() {
*g = File::create(&self.path).ok();
}
if let Some(f) = g.as_mut() {
let _ = writeln!(f, "{line}");
}
}
}
/// Asa de un comando en ejecución. El consumidor la conserva y drena sus
/// eventos cuando le conviene.
pub struct RunHandle {
rx: Receiver<RunEvent>,
finished: bool,
/// El proceso, compartido con su hilo coordinador para poder matarlo.
child: Arc<Mutex<Option<Child>>>,
/// Los procesos, compartidos con el hilo coordinador para poder
/// matarlos — todas las etapas de un pipe directo.
children: Arc<Mutex<Vec<Child>>>,
}
impl RunHandle {
/// Mata el proceso (envía la señal de terminación). No hace nada si
/// el proceso ya terminó o nunca llegó a lanzarse.
/// Mata todos los procesos del comando. No hace nada si ya terminaron.
pub fn kill(&self) {
if let Ok(mut guard) = self.child.lock() {
if let Some(c) = guard.as_mut() {
if let Ok(mut guard) = self.children.lock() {
for c in guard.iter_mut() {
let _ = c.kill();
}
}
}
/// Drena todos los eventos disponibles ahora mismo, sin bloquear.
/// Marca el asa como terminada al ver un evento terminal.
pub fn try_events(&mut self) -> Vec<RunEvent> {
let mut out = Vec::new();
loop {
@@ -139,8 +188,7 @@ impl RunHandle {
out
}
/// Bloquea hasta que el proceso termine y devuelve todos sus eventos
/// en orden. Pensado para tests y para usos sincrónicos.
/// Bloquea hasta que el proceso termine y devuelve todos sus eventos.
pub fn wait_all(&mut self) -> Vec<RunEvent> {
let mut out = Vec::new();
while let Ok(ev) = self.rx.recv() {
@@ -160,9 +208,10 @@ impl RunHandle {
}
}
/// Lanza un hilo lector de un flujo. Cuenta los bytes contra `counter`;
/// pasado `limit` emite `Truncated` una vez (vía `announced`) y descarta
/// el resto, pero **sigue drenando** el pipe para no bloquear al proceso.
/// Lanza un hilo lector de un flujo, con captura acotada. Pasado el tope
/// emite (una vez) `Truncated` o `Spilled` y deriva el resto al sumidero
/// de volcado o a la basura — pero **sigue drenando** el pipe.
#[allow(clippy::too_many_arguments)]
fn spawn_reader<R: Read + Send + 'static>(
stream: R,
tx: Sender<RunEvent>,
@@ -170,15 +219,29 @@ fn spawn_reader<R: Read + Send + 'static>(
limit: usize,
counter: Arc<AtomicUsize>,
announced: Arc<AtomicBool>,
spill: Option<Arc<SpillSink>>,
) -> JoinHandle<()> {
std::thread::spawn(move || {
for line in BufReader::new(stream).lines().map_while(Result::ok) {
let total = counter.fetch_add(line.len() + 1, Ordering::Relaxed) + line.len() + 1;
let total =
counter.fetch_add(line.len() + 1, Ordering::Relaxed) + line.len() + 1;
if limit != 0 && total > limit {
if !announced.swap(true, Ordering::Relaxed) {
let _ = tx.send(RunEvent::Truncated);
let first = !announced.swap(true, Ordering::Relaxed);
match &spill {
Some(sink) => {
if first {
let _ = tx
.send(RunEvent::Spilled(sink.path.display().to_string()));
}
sink.write_line(&line);
}
None => {
if first {
let _ = tx.send(RunEvent::Truncated);
}
}
}
continue; // descarta, pero sigue leyendo el pipe
continue; // descarta/vuelca, pero sigue leyendo el pipe
}
if tx.send(make(line)).is_err() {
break;
@@ -187,241 +250,302 @@ fn spawn_reader<R: Read + Send + 'static>(
})
}
/// Resultado de lanzar los procesos: lo que el coordinador necesita.
struct Spawned {
children: Vec<Child>,
stdin: Option<std::process::ChildStdin>,
stdout: Option<std::process::ChildStdout>,
stderrs: Vec<std::process::ChildStderr>,
}
/// Lanza un único proceso shell (`program -c "<line>"`).
fn spawn_shell(line: &str, program: &str, cwd: &str, want_stdin: bool) -> std::io::Result<Spawned> {
let mut child = Command::new(program)
.arg("-c")
.arg(line)
.current_dir(cwd)
.stdin(if want_stdin { Stdio::piped() } else { Stdio::null() })
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()?;
let stdin = child.stdin.take();
let stdout = child.stdout.take();
let stderrs = child.stderr.take().into_iter().collect();
Ok(Spawned { children: vec![child], stdin, stdout, stderrs })
}
/// Lanza un pipe de etapas conectándolas con descriptores reales.
fn spawn_direct(stages: &[StageSpec], cwd: &str, want_stdin: bool) -> std::io::Result<Spawned> {
if stages.is_empty() {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"pipe vacío",
));
}
let n = stages.len();
let mut children: Vec<Child> = Vec::with_capacity(n);
let mut prev_stdout: Option<std::process::ChildStdout> = None;
for (i, st) in stages.iter().enumerate() {
let mut cmd = Command::new(&st.program);
cmd.args(&st.args)
.current_dir(cwd)
.stdout(Stdio::piped())
.stderr(Stdio::piped());
if i == 0 {
cmd.stdin(if want_stdin { Stdio::piped() } else { Stdio::null() });
} else {
// La etapa anterior alimenta a ésta por su stdout.
cmd.stdin(Stdio::from(prev_stdout.take().expect("stdout previo")));
}
match cmd.spawn() {
Ok(mut child) => {
if i + 1 < n {
prev_stdout = child.stdout.take();
}
children.push(child);
}
Err(e) => {
// Si una etapa no arranca, se matan las ya lanzadas.
for mut c in children {
let _ = c.kill();
}
return Err(std::io::Error::new(
e.kind(),
format!("{}: {e}", st.program),
));
}
}
}
let stdin = children.first_mut().and_then(|c| c.stdin.take());
let stdout = children.last_mut().and_then(|c| c.stdout.take());
let stderrs = children.iter_mut().filter_map(|c| c.stderr.take()).collect();
Ok(Spawned { children, stdin, stdout, stderrs })
}
/// Lanza `spec` y devuelve un [`RunHandle`] desde el que drenar la
/// salida. La función vuelve de inmediato: el proceso corre en hilos.
pub fn run(spec: &CommandSpec) -> RunHandle {
let (tx, rx) = mpsc::channel();
let spec = spec.clone();
let child_cell: Arc<Mutex<Option<Child>>> = Arc::new(Mutex::new(None));
let cell = Arc::clone(&child_cell);
let cell: Arc<Mutex<Vec<Child>>> = Arc::new(Mutex::new(Vec::new()));
let cell_thread = Arc::clone(&cell);
std::thread::spawn(move || {
let stdin_mode = if spec.stdin_data.is_some() {
Stdio::piped()
} else {
Stdio::null()
let want_stdin = spec.stdin_data.is_some();
let spawned = match &spec.exec {
Exec::Shell { line, program } => {
spawn_shell(line, program, &spec.cwd, want_stdin)
}
Exec::Direct { stages } => spawn_direct(stages, &spec.cwd, want_stdin),
};
let spawned = Command::new(&spec.shell)
.arg("-c")
.arg(&spec.line)
.current_dir(&spec.cwd)
.stdin(stdin_mode)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn();
let mut child = match spawned {
Ok(c) => c,
let Spawned { children, stdin, stdout, stderrs } = match spawned {
Ok(s) => s,
Err(e) => {
let _ = tx.send(RunEvent::Failed(e.to_string()));
return;
}
};
// Si hay datos para reprocesar, se escriben por stdin en su
// propio hilo (la escritura puede bloquear hasta que el proceso
// consuma); al terminar, `stdin` se cierra → EOF.
if let Some(data) = spec.stdin_data.clone() {
if let Some(mut stdin) = child.stdin.take() {
std::thread::spawn(move || {
let _ = stdin.write_all(data.as_bytes());
});
}
// Alimenta stdin (reproceso) en su propio hilo.
if let (Some(data), Some(mut sink)) = (spec.stdin_data.clone(), stdin) {
std::thread::spawn(move || {
let _ = sink.write_all(data.as_bytes());
});
}
let stdout = child.stdout.take();
let stderr = child.stderr.take();
// Comparte el proceso para que `RunHandle::kill` pueda alcanzarlo.
if let Ok(mut g) = cell.lock() {
*g = Some(child);
// Comparte los procesos para que `kill` los alcance.
if let Ok(mut g) = cell_thread.lock() {
*g = children;
}
// Contador de bytes compartido: el tope vale para stdout+stderr.
// Captura acotada: contador y aviso compartidos por todos los
// lectores; un sumidero de volcado opcional.
let counter = Arc::new(AtomicUsize::new(0));
let announced = Arc::new(AtomicBool::new(false));
let spill = spec
.spill_path
.clone()
.map(|path| Arc::new(SpillSink { path, file: Mutex::new(None) }));
let limit = spec.capture_limit;
let out_reader = stdout.map(|s| {
spawn_reader(
let mut readers: Vec<JoinHandle<()>> = Vec::new();
if let Some(s) = stdout {
readers.push(spawn_reader(
s,
tx.clone(),
RunEvent::Stdout,
limit,
Arc::clone(&counter),
Arc::clone(&announced),
)
});
let err_reader = stderr.map(|s| {
spawn_reader(
spill.clone(),
));
}
for s in stderrs {
readers.push(spawn_reader(
s,
tx.clone(),
RunEvent::Stderr,
limit,
Arc::clone(&counter),
Arc::clone(&announced),
)
});
spill.clone(),
));
}
for h in readers {
let _ = h.join();
}
// Los lectores terminan cuando el proceso cierra sus pipes —sea
// por fin natural o por `kill`—; recién entonces se cosecha.
if let Some(h) = out_reader {
let _ = h.join();
}
if let Some(h) = err_reader {
let _ = h.join();
}
let code = cell
.lock()
.ok()
.and_then(|mut g| g.as_mut().and_then(|c| c.wait().ok()))
.and_then(|s| s.code())
.unwrap_or(-1);
// Cosecha todas las etapas; el código de salida es el de la última.
let code = {
let mut g = cell_thread.lock().expect("children lock");
let mut last = -1;
for c in g.iter_mut() {
last = c.wait().ok().and_then(|s| s.code()).unwrap_or(-1);
}
last
};
let _ = tx.send(RunEvent::Exited(code));
});
RunHandle { rx, finished: false, child: child_cell }
RunHandle { rx, finished: false, children: cell }
}
#[cfg(test)]
mod tests {
use super::*;
/// `sh` está en cualquier entorno POSIX — más portable que bash
/// para los tests.
fn sh(line: &str) -> CommandSpec {
CommandSpec { shell: "sh".into(), ..CommandSpec::bash(line, ".") }
/// Ejecución directa de un único programa.
fn direct(program: &str, args: &[&str]) -> CommandSpec {
CommandSpec::direct(
vec![StageSpec {
program: program.into(),
args: args.iter().map(|s| s.to_string()).collect(),
}],
".",
)
}
/// Pipe directo de varias etapas.
fn pipe(stages: &[(&str, &[&str])]) -> CommandSpec {
CommandSpec::direct(
stages
.iter()
.map(|(p, a)| StageSpec {
program: p.to_string(),
args: a.iter().map(|s| s.to_string()).collect(),
})
.collect(),
".",
)
}
fn stdout_of(events: Vec<RunEvent>) -> Vec<String> {
events
.into_iter()
.filter_map(|e| match e {
RunEvent::Stdout(l) => Some(l),
_ => None,
})
.collect()
}
#[test]
fn captures_stdout_and_exit_code() {
let mut h = run(&sh("echo hola-mundo"));
fn direct_runs_a_single_program() {
let mut h = run(&direct("echo", &["hola", "mundo"]));
let events = h.wait_all();
assert!(events.contains(&RunEvent::Stdout("hola-mundo".into())));
assert!(events.contains(&RunEvent::Stdout("hola mundo".into())));
assert!(events.contains(&RunEvent::Exited(0)));
assert!(h.is_finished());
}
#[test]
fn captures_stderr() {
let mut h = run(&sh("echo problema 1>&2"));
let events = h.wait_all();
assert!(events.contains(&RunEvent::Stderr("problema".into())));
fn direct_wires_a_pipeline_itself() {
// printf … | sort — brahman conecta los procesos, sin shell.
let mut h = run(&pipe(&[
("printf", &["b\\na\\nc\\n"]),
("sort", &[]),
]));
assert_eq!(stdout_of(h.wait_all()), vec!["a", "b", "c"]);
}
#[test]
fn nonzero_exit_is_reported() {
let mut h = run(&sh("exit 3"));
let events = h.wait_all();
assert!(events.contains(&RunEvent::Exited(3)));
fn direct_three_stage_pipeline() {
let mut h = run(&pipe(&[
("printf", &["3\\n1\\n2\\n1\\n"]),
("sort", &[]),
("uniq", &[]),
]));
assert_eq!(stdout_of(h.wait_all()), vec!["1", "2", "3"]);
}
#[test]
fn multiple_output_lines_arrive_in_order() {
let mut h = run(&sh("echo uno; echo dos; echo tres"));
let lines: Vec<String> = h
.wait_all()
.into_iter()
.filter_map(|e| match e {
RunEvent::Stdout(l) => Some(l),
_ => None,
})
.collect();
assert_eq!(lines, vec!["uno", "dos", "tres"]);
fn direct_nonzero_exit_is_the_last_stage() {
let mut h = run(&direct("false", &[]));
assert!(h.wait_all().contains(&RunEvent::Exited(1)));
}
#[test]
fn pipes_run_through_the_shell() {
let mut h = run(&sh("printf 'b\\na\\nc\\n' | sort"));
let lines: Vec<String> = h
.wait_all()
.into_iter()
.filter_map(|e| match e {
RunEvent::Stdout(l) => Some(l),
_ => None,
})
.collect();
assert_eq!(lines, vec!["a", "b", "c"]);
}
#[test]
fn missing_shell_fails_gracefully() {
let spec = CommandSpec { shell: "/no/existe/shell-xyz".into(), ..sh("echo x") };
let mut h = run(&spec);
fn direct_missing_program_fails_gracefully() {
let mut h = run(&direct("no-existe-binario-xyz", &[]));
let events = h.wait_all();
assert!(matches!(events.first(), Some(RunEvent::Failed(_))));
}
#[test]
fn terminal_event_detection() {
assert!(RunEvent::Exited(0).is_terminal());
assert!(RunEvent::Failed("x".into()).is_terminal());
assert!(!RunEvent::Stdout("x".into()).is_terminal());
assert!(!RunEvent::Truncated.is_terminal());
}
#[test]
fn kill_stops_a_long_running_process() {
let mut h = run(&sh("sleep 30"));
std::thread::sleep(std::time::Duration::from_millis(250));
h.kill();
let events = h.wait_all();
assert!(events.last().map(|e| e.is_terminal()).unwrap_or(false));
assert!(h.is_finished());
fn shell_mode_still_works_for_complex_syntax() {
let mut h = run(&CommandSpec {
exec: Exec::Shell { line: "echo $((2 + 3))".into(), program: "sh".into() },
..CommandSpec::shell("", ".")
});
assert!(h.wait_all().contains(&RunEvent::Stdout("5".into())));
}
#[test]
fn capture_limit_truncates_but_process_finishes() {
// 20.000 líneas, pero la captura se corta a ~400 bytes.
let mut h = run(&sh("seq 1 20000").with_limit(400));
let mut h = run(&direct("seq", &["1", "20000"]).with_limit(400));
let events = h.wait_all();
// Se anunció el truncado…
assert!(events.contains(&RunEvent::Truncated));
// …pero el proceso terminó normal (no se bloqueó).
assert!(events.contains(&RunEvent::Exited(0)));
// Y lo capturado quedó acotado.
let captured = events
.iter()
.filter(|e| matches!(e, RunEvent::Stdout(_)))
.count();
assert!(captured < 20000, "la salida quedó acotada");
assert!(stdout_of(events).len() < 20000);
}
#[test]
fn no_limit_captures_everything() {
let mut h = run(&sh("seq 1 500")); // capture_limit = 0
fn spill_writes_overflow_to_a_file() {
let path = std::env::temp_dir()
.join(format!("shuma-exec-spill-{}.log", std::process::id()));
let _ = std::fs::remove_file(&path);
let mut h = run(&direct("seq", &["1", "5000"])
.with_limit(200)
.with_spill(path.clone()));
let events = h.wait_all();
assert!(!events.contains(&RunEvent::Truncated));
let n = events.iter().filter(|e| matches!(e, RunEvent::Stdout(_))).count();
assert_eq!(n, 500);
}
#[test]
fn stdin_data_is_fed_to_the_process() {
// `cat` devuelve por stdout lo que recibe por stdin — es el
// reproceso más simple: tomar una salida y pasarla a otro filtro.
let mut h = run(&sh("cat").with_stdin("alfa\nbeta\ngamma"));
let lines: Vec<String> = h
.wait_all()
.into_iter()
.filter_map(|e| match e {
RunEvent::Stdout(l) => Some(l),
_ => None,
})
.collect();
assert_eq!(lines, vec!["alfa", "beta", "gamma"]);
assert!(events.iter().any(|e| matches!(e, RunEvent::Spilled(_))));
assert!(events.contains(&RunEvent::Exited(0)));
// El archivo de volcado existe y tiene contenido.
let spilled = std::fs::read_to_string(&path).unwrap_or_default();
assert!(spilled.contains("5000"), "la cola se volcó al archivo");
let _ = std::fs::remove_file(&path);
}
#[test]
fn stdin_data_reprocessed_by_a_filter() {
let mut h = run(&sh("grep beta").with_stdin("alfa\nbeta\nbetabel\ngamma"));
let lines: Vec<String> = h
.wait_all()
.into_iter()
.filter_map(|e| match e {
RunEvent::Stdout(l) => Some(l),
_ => None,
})
.collect();
assert_eq!(lines, vec!["beta", "betabel"]);
let mut h = run(&direct("grep", &["beta"]).with_stdin("alfa\nbeta\nbetabel\ngamma"));
assert_eq!(stdout_of(h.wait_all()), vec!["beta", "betabel"]);
}
#[test]
fn kill_stops_a_long_running_pipeline() {
let mut h = run(&pipe(&[("sleep", &["30"]), ("cat", &[])]));
std::thread::sleep(std::time::Duration::from_millis(250));
h.kill();
let events = h.wait_all();
assert!(events.last().map(|e| e.is_terminal()).unwrap_or(false));
}
#[test]
fn terminal_event_detection() {
assert!(RunEvent::Exited(0).is_terminal());
assert!(!RunEvent::Truncated.is_terminal());
assert!(!RunEvent::Spilled("x".into()).is_terminal());
}
}
@@ -20,6 +20,23 @@ use serde::{Deserialize, Serialize};
/// Identificador de un comando dentro de su sesión.
pub type RunId = u64;
/// Política de captura de salida — configurable por sesión de trabajo.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct CapturePolicy {
/// Tope de captura en bytes; `0` = sin límite.
pub limit_bytes: usize,
/// Si la salida que excede el tope se vuelca a un archivo (en vez de
/// descartarse).
pub spill: bool,
}
impl Default for CapturePolicy {
/// Por defecto: 8 MiB, sin volcado a disco.
fn default() -> Self {
Self { limit_bytes: 8 * 1024 * 1024, spill: false }
}
}
/// Estado de un comando ejecutado.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum RunStatus {
@@ -113,6 +130,8 @@ pub struct WorkSession {
cwd: String,
history: Vec<CommandRun>,
groups: Vec<CommandGroup>,
/// Política de captura — propia de esta sesión.
capture: CapturePolicy,
next_id: RunId,
}
@@ -134,10 +153,31 @@ impl WorkSession {
cwd: cwd.into(),
history: Vec::new(),
groups: Vec::new(),
capture: CapturePolicy::default(),
next_id: 1,
}
}
/// Política de captura vigente de la sesión.
pub fn capture(&self) -> CapturePolicy {
self.capture
}
/// Reemplaza la política de captura.
pub fn set_capture(&mut self, policy: CapturePolicy) {
self.capture = policy;
}
/// Ajusta sólo el tope de captura en bytes.
pub fn set_capture_limit(&mut self, bytes: usize) {
self.capture.limit_bytes = bytes;
}
/// Activa o desactiva el volcado a disco de la salida excedente.
pub fn set_spill(&mut self, spill: bool) {
self.capture.spill = spill;
}
/// Directorio actual de la sesión.
pub fn cwd(&self) -> &str {
&self.cwd
@@ -367,4 +407,14 @@ mod tests {
assert!(s.remove_group("x"));
assert!(!s.remove_group("x"));
}
#[test]
fn capture_policy_is_per_session() {
let mut s = WorkSession::new("t", "/home");
assert_eq!(s.capture(), CapturePolicy::default());
s.set_capture_limit(1_000_000);
s.set_spill(true);
assert_eq!(s.capture().limit_bytes, 1_000_000);
assert!(s.capture().spill);
}
}