⎇ 0f2f1033eb3498e3d2635cd89fb4ba6899165d95
crates/modules/shuma/shuma-exec/src/lib.rs
Ñawpa willay
Pi imata llamkarqan
ch'uya
feat(shuma): shuma-session + shuma-exec — sesiones de trabajo y ejecución
1//! `shuma-exec` — ejecución de comandos del shell con salida en streaming.
2//!
3//! Lanza una línea de comandos en un shell (`bash -c …`) dentro de un
4//! directorio, y entrega su salida **a medida que ocurre**: cada línea
5//! de stdout o stderr llega como un [`RunEvent`] por un canal, sin
6//! esperar a que el proceso termine.
7//!
8//! Esto es lo que `sandokan` no hace: el orquestador es poll-based y
9//! orquesta *Cards* de brahman (entidades aisladas y supervisadas). El
10//! shell, en cambio, corre líneas de shell ad-hoc y necesita ver la
11//! salida fluir. Dos capas distintas, a propósito.
12//!
13//! El crate es agnóstico de frontend: el proceso y sus lectores corren
14//! en hilos; el consumidor (shell GPUI o TUI) drena el canal cuando
15//! quiere — sin `async`, sin acoplarse a ningún runtime.
16
17#![forbid(unsafe_code)]
18
19use std::io::{BufRead, BufReader};
20use std::process::{Command, Stdio};
21use std::sync::mpsc::{self, Receiver, TryRecvError};
22
23/// Qué ejecutar: una línea de comandos, en un directorio, con un shell.
24#[derive(Debug, Clone)]
25pub struct CommandSpec {
26 /// La línea completa — se pasa como `shell -c "<line>"`.
27 pub line: String,
28 /// Directorio de trabajo del proceso.
29 pub cwd: String,
30 /// Programa de shell — `"bash"`, `"sh"`, `"fish"`…
31 pub shell: String,
32}
33
34impl CommandSpec {
35 /// Spec con `bash` como shell.
36 pub fn bash(line: impl Into<String>, cwd: impl Into<String>) -> Self {
37 Self { line: line.into(), cwd: cwd.into(), shell: "bash".into() }
38 }
39}
40
41/// Un evento de la ejecución de un comando.
42#[derive(Debug, Clone, PartialEq, Eq)]
43pub enum RunEvent {
44 /// Una línea de salida estándar.
45 Stdout(String),
46 /// Una línea de salida de error.
47 Stderr(String),
48 /// El proceso terminó con este código de salida.
49 Exited(i32),
50 /// El proceso no pudo siquiera lanzarse.
51 Failed(String),
52}
53
54impl RunEvent {
55 /// `true` si el evento cierra la ejecución (`Exited` o `Failed`).
56 pub fn is_terminal(&self) -> bool {
57 matches!(self, RunEvent::Exited(_) | RunEvent::Failed(_))
58 }
59}
60
61/// Asa de un comando en ejecución. El consumidor la conserva y drena sus
62/// eventos cuando le conviene.
63pub struct RunHandle {
64 rx: Receiver<RunEvent>,
65 finished: bool,
66}
67
68impl RunHandle {
69 /// Drena todos los eventos disponibles ahora mismo, sin bloquear.
70 /// Marca el asa como terminada al ver un evento terminal.
71 pub fn try_events(&mut self) -> Vec<RunEvent> {
72 let mut out = Vec::new();
73 loop {
74 match self.rx.try_recv() {
75 Ok(ev) => {
76 if ev.is_terminal() {
77 self.finished = true;
78 }
79 out.push(ev);
80 }
81 Err(TryRecvError::Empty) => break,
82 Err(TryRecvError::Disconnected) => {
83 self.finished = true;
84 break;
85 }
86 }
87 }
88 out
89 }
90
91 /// Bloquea hasta que el proceso termine y devuelve todos sus eventos
92 /// en orden. Pensado para tests y para usos sincrónicos.
93 pub fn wait_all(&mut self) -> Vec<RunEvent> {
94 let mut out = Vec::new();
95 while let Ok(ev) = self.rx.recv() {
96 let terminal = ev.is_terminal();
97 out.push(ev);
98 if terminal {
99 self.finished = true;
100 }
101 }
102 self.finished = true;
103 out
104 }
105
106 /// `true` si ya se observó el evento terminal.
107 pub fn is_finished(&self) -> bool {
108 self.finished
109 }
110}
111
112/// Lanza `spec` y devuelve un [`RunHandle`] desde el que drenar la
113/// salida. La función vuelve de inmediato: el proceso corre en hilos.
114pub fn run(spec: &CommandSpec) -> RunHandle {
115 let (tx, rx) = mpsc::channel();
116 let spec = spec.clone();
117
118 std::thread::spawn(move || {
119 let spawned = Command::new(&spec.shell)
120 .arg("-c")
121 .arg(&spec.line)
122 .current_dir(&spec.cwd)
123 .stdin(Stdio::null())
124 .stdout(Stdio::piped())
125 .stderr(Stdio::piped())
126 .spawn();
127
128 let mut child = match spawned {
129 Ok(c) => c,
130 Err(e) => {
131 let _ = tx.send(RunEvent::Failed(e.to_string()));
132 return;
133 }
134 };
135
136 // Un hilo lector por flujo: stdout y stderr fluyen en paralelo.
137 let stdout = child.stdout.take();
138 let stderr = child.stderr.take();
139 let out_reader = stdout.map(|s| {
140 let tx = tx.clone();
141 std::thread::spawn(move || {
142 for line in BufReader::new(s).lines().map_while(Result::ok) {
143 if tx.send(RunEvent::Stdout(line)).is_err() {
144 break;
145 }
146 }
147 })
148 });
149 let err_reader = stderr.map(|s| {
150 let tx = tx.clone();
151 std::thread::spawn(move || {
152 for line in BufReader::new(s).lines().map_while(Result::ok) {
153 if tx.send(RunEvent::Stderr(line)).is_err() {
154 break;
155 }
156 }
157 })
158 });
159
160 if let Some(h) = out_reader {
161 let _ = h.join();
162 }
163 if let Some(h) = err_reader {
164 let _ = h.join();
165 }
166 let code = child
167 .wait()
168 .ok()
169 .and_then(|s| s.code())
170 .unwrap_or(-1);
171 let _ = tx.send(RunEvent::Exited(code));
172 });
173
174 RunHandle { rx, finished: false }
175}
176
177#[cfg(test)]
178mod tests {
179 use super::*;
180
181 /// `sh` está en cualquier entorno POSIX — más portable que bash
182 /// para los tests.
183 fn sh(line: &str) -> CommandSpec {
184 CommandSpec { line: line.into(), cwd: ".".into(), shell: "sh".into() }
185 }
186
187 #[test]
188 fn captures_stdout_and_exit_code() {
189 let mut h = run(&sh("echo hola-mundo"));
190 let events = h.wait_all();
191 assert!(events.contains(&RunEvent::Stdout("hola-mundo".into())));
192 assert!(events.contains(&RunEvent::Exited(0)));
193 assert!(h.is_finished());
194 }
195
196 #[test]
197 fn captures_stderr() {
198 let mut h = run(&sh("echo problema 1>&2"));
199 let events = h.wait_all();
200 assert!(events.contains(&RunEvent::Stderr("problema".into())));
201 }
202
203 #[test]
204 fn nonzero_exit_is_reported() {
205 let mut h = run(&sh("exit 3"));
206 let events = h.wait_all();
207 assert!(events.contains(&RunEvent::Exited(3)));
208 }
209
210 #[test]
211 fn multiple_output_lines_arrive_in_order() {
212 let mut h = run(&sh("echo uno; echo dos; echo tres"));
213 let lines: Vec<String> = h
214 .wait_all()
215 .into_iter()
216 .filter_map(|e| match e {
217 RunEvent::Stdout(l) => Some(l),
218 _ => None,
219 })
220 .collect();
221 assert_eq!(lines, vec!["uno", "dos", "tres"]);
222 }
223
224 #[test]
225 fn pipes_run_through_the_shell() {
226 let mut h = run(&sh("printf 'b\\na\\nc\\n' | sort"));
227 let lines: Vec<String> = h
228 .wait_all()
229 .into_iter()
230 .filter_map(|e| match e {
231 RunEvent::Stdout(l) => Some(l),
232 _ => None,
233 })
234 .collect();
235 assert_eq!(lines, vec!["a", "b", "c"]);
236 }
237
238 #[test]
239 fn missing_shell_fails_gracefully() {
240 let spec = CommandSpec {
241 line: "echo x".into(),
242 cwd: ".".into(),
243 shell: "/no/existe/shell-xyz".into(),
244 };
245 let mut h = run(&spec);
246 let events = h.wait_all();
247 assert!(matches!(events.first(), Some(RunEvent::Failed(_))));
248 }
249
250 #[test]
251 fn terminal_event_detection() {
252 assert!(RunEvent::Exited(0).is_terminal());
253 assert!(RunEvent::Failed("x".into()).is_terminal());
254 assert!(!RunEvent::Stdout("x".into()).is_terminal());
255 }
256}