gioser

tawasuyu/brahman

Rust 492TOML 194Markdown 53JSON 176939 Definiciones

⎇ 0f2f1033eb3498e3d2635cd89fb4ba6899165d95 crates/modules/shuma/shuma-exec/src/lib.rs Rust · 256 líneas · 7.8 KB Historia Quién tocó qué crudo

feat(shuma): shuma-session + shuma-exec — sesiones de trabajo y ejecución 0f2f1033e · sergio ·

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}