gioser

tawasuyu/brahman

Rust 320TOML 122JSON 14Markdown 114164 Definitions

⎇ 0fde2aa2732815edaf12c2715bb154ec944d92fe crates/modules/shuma/shuma-session/src/lib.rs Rust · 360 lines · 11.3 KB History Who touched what raw

feat(shuma-shell): tarjetas acordeón con filtro stdout/stderr 0fde2aa27 · sergio ·

1//! `shuma-session` — la sesión de trabajo del shell.
2//!
3//! El shell no es una sucesión suelta de comandos: trabaja *dentro de
4//! una sesión*. Una [`WorkSession`] contiene:
5//!
6//! - el **directorio actual** — que es además el identificador de
7//!   aislamiento (cada directorio es un contexto separado);
8//! - el **historial** de comandos ejecutados, cada uno con su salida y
9//!   su estado ([`CommandRun`]);
10//! - los **grupos** de comandos guardados y reutilizables
11//!   ([`CommandGroup`]).
12//!
13//! Modelo puro y agnóstico: la ejecución real la hace `shuma-exec`, el
14//! tiempo lo inyecta el caller. Determinista y testeable.
15
16#![forbid(unsafe_code)]
17
18use serde::{Deserialize, Serialize};
19
20/// Identificador de un comando dentro de su sesión.
21pub type RunId = u64;
22
23/// Estado de un comando ejecutado.
24#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
25pub enum RunStatus {
26    /// Ejecutándose; su salida sigue llegando.
27    Running,
28    /// Terminó con código 0.
29    Ok,
30    /// Terminó con código distinto de 0, o no pudo lanzarse.
31    Failed,
32}
33
34/// De qué flujo viene una línea de salida.
35#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
36pub enum Stream {
37    /// Salida estándar.
38    Stdout,
39    /// Salida de error.
40    Stderr,
41}
42
43/// Una línea de salida con el flujo del que proviene.
44#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
45pub struct OutputLine {
46    pub stream: Stream,
47    pub text: String,
48}
49
50/// Un comando ejecutado: la línea, el directorio, el estado y la salida.
51#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
52pub struct CommandRun {
53    pub id: RunId,
54    /// La línea de comandos tal como se escribió.
55    pub line: String,
56    /// El directorio en que se ejecutó.
57    pub cwd: String,
58    pub status: RunStatus,
59    /// Código de salida, una vez terminado.
60    pub exit_code: Option<i32>,
61    /// Salida — cada línea sabe si es de stdout o de stderr.
62    pub output: Vec<OutputLine>,
63    /// Segundo Unix en que arrancó.
64    pub started_at: u64,
65    /// Segundo Unix en que terminó.
66    pub finished_at: Option<u64>,
67}
68
69impl CommandRun {
70    /// `true` si el comando sigue corriendo.
71    pub fn is_running(&self) -> bool {
72        self.status == RunStatus::Running
73    }
74
75    /// Cantidad total de líneas de salida.
76    pub fn line_count(&self) -> usize {
77        self.output.len()
78    }
79
80    /// Líneas de un flujo concreto.
81    pub fn lines_of(&self, stream: Stream) -> impl Iterator<Item = &str> {
82        self.output
83            .iter()
84            .filter(move |l| l.stream == stream)
85            .map(|l| l.text.as_str())
86    }
87
88    /// Cuántas líneas tiene un flujo.
89    pub fn count_of(&self, stream: Stream) -> usize {
90        self.output.iter().filter(|l| l.stream == stream).count()
91    }
92
93    /// `true` si el comando emitió algo por stderr.
94    pub fn has_stderr(&self) -> bool {
95        self.output.iter().any(|l| l.stream == Stream::Stderr)
96    }
97}
98
99/// Un grupo de comandos guardado para reutilizar — una receta.
100#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
101pub struct CommandGroup {
102    pub name: String,
103    /// Las líneas de comando, en orden de ejecución.
104    pub lines: Vec<String>,
105}
106
107/// La sesión de trabajo: directorio actual + historial + grupos.
108#[derive(Debug, Clone, Serialize, Deserialize)]
109pub struct WorkSession {
110    pub name: String,
111    cwd: String,
112    history: Vec<CommandRun>,
113    groups: Vec<CommandGroup>,
114    next_id: RunId,
115}
116
117/// FNV-1a de 64 bits — base del identificador de aislamiento.
118fn fnv1a(bytes: &[u8]) -> u64 {
119    let mut h: u64 = 0xcbf2_9ce4_8422_2325;
120    for &b in bytes {
121        h ^= b as u64;
122        h = h.wrapping_mul(0x100_0000_01b3);
123    }
124    h
125}
126
127impl WorkSession {
128    /// Abre una sesión con un nombre y un directorio inicial.
129    pub fn new(name: impl Into<String>, cwd: impl Into<String>) -> Self {
130        Self {
131            name: name.into(),
132            cwd: cwd.into(),
133            history: Vec::new(),
134            groups: Vec::new(),
135            next_id: 1,
136        }
137    }
138
139    /// Directorio actual de la sesión.
140    pub fn cwd(&self) -> &str {
141        &self.cwd
142    }
143
144    /// Cambia el directorio actual — y con él, el contexto de aislamiento.
145    pub fn set_cwd(&mut self, cwd: impl Into<String>) {
146        self.cwd = cwd.into();
147    }
148
149    /// Identificador de aislamiento del directorio actual: un hash corto
150    /// y estable del `cwd`. Cada directorio es un contexto distinto, así
151    /// que el id cambia al hacer `cd`.
152    pub fn isolation_id(&self) -> String {
153        format!("{:012x}", fnv1a(self.cwd.as_bytes()) & 0xffff_ffff_ffff)
154    }
155
156    // --- Historial de comandos ---
157
158    /// Registra el inicio de un comando (estado `Running`) en el `cwd`
159    /// actual. Devuelve su id.
160    pub fn begin_run(&mut self, line: impl Into<String>, now: u64) -> RunId {
161        let id = self.next_id;
162        self.next_id += 1;
163        self.history.push(CommandRun {
164            id,
165            line: line.into(),
166            cwd: self.cwd.clone(),
167            status: RunStatus::Running,
168            exit_code: None,
169            output: Vec::new(),
170            started_at: now,
171            finished_at: None,
172        });
173        id
174    }
175
176    pub fn run(&self, id: RunId) -> Option<&CommandRun> {
177        self.history.iter().find(|r| r.id == id)
178    }
179
180    fn run_mut(&mut self, id: RunId) -> Option<&mut CommandRun> {
181        self.history.iter_mut().find(|r| r.id == id)
182    }
183
184    /// Añade una línea de salida a un comando en curso, marcando su flujo.
185    pub fn append_output(&mut self, id: RunId, stream: Stream, text: impl Into<String>) {
186        if let Some(r) = self.run_mut(id) {
187            r.output.push(OutputLine { stream, text: text.into() });
188        }
189    }
190
191    /// Marca un comando como terminado con su código de salida.
192    pub fn finish_run(&mut self, id: RunId, exit_code: i32, now: u64) {
193        if let Some(r) = self.run_mut(id) {
194            r.exit_code = Some(exit_code);
195            r.status = if exit_code == 0 { RunStatus::Ok } else { RunStatus::Failed };
196            r.finished_at = Some(now);
197        }
198    }
199
200    /// Historial completo, del más antiguo al más reciente.
201    pub fn history(&self) -> &[CommandRun] {
202        &self.history
203    }
204
205    /// Comandos que siguen corriendo.
206    pub fn running(&self) -> Vec<RunId> {
207        self.history
208            .iter()
209            .filter(|r| r.is_running())
210            .map(|r| r.id)
211            .collect()
212    }
213
214    /// Vacía el historial (no toca los grupos ni el `cwd`).
215    pub fn clear_history(&mut self) {
216        self.history.clear();
217    }
218
219    // --- Grupos reutilizables ---
220
221    /// Guarda un grupo de comandos. Si ya existe uno con ese nombre, lo
222    /// reemplaza.
223    pub fn save_group(&mut self, name: impl Into<String>, lines: Vec<String>) {
224        let name = name.into();
225        self.groups.retain(|g| g.name != name);
226        self.groups.push(CommandGroup { name, lines });
227    }
228
229    /// Guarda como grupo las líneas de los últimos `n` comandos del
230    /// historial — la forma natural de "convertir lo que acabo de hacer
231    /// en una receta".
232    pub fn save_recent_as_group(&mut self, name: impl Into<String>, n: usize) {
233        let lines: Vec<String> = self
234            .history
235            .iter()
236            .rev()
237            .take(n)
238            .rev()
239            .map(|r| r.line.clone())
240            .collect();
241        self.save_group(name, lines);
242    }
243
244    pub fn groups(&self) -> &[CommandGroup] {
245        &self.groups
246    }
247
248    pub fn group(&self, name: &str) -> Option<&CommandGroup> {
249        self.groups.iter().find(|g| g.name == name)
250    }
251
252    /// Quita un grupo. `true` si existía.
253    pub fn remove_group(&mut self, name: &str) -> bool {
254        let before = self.groups.len();
255        self.groups.retain(|g| g.name != name);
256        self.groups.len() != before
257    }
258}
259
260#[cfg(test)]
261mod tests {
262    use super::*;
263
264    #[test]
265    fn isolation_id_follows_the_directory() {
266        let mut s = WorkSession::new("trabajo", "/home/sergio/brahman");
267        let id_a = s.isolation_id();
268        s.set_cwd("/tmp");
269        let id_b = s.isolation_id();
270        assert_ne!(id_a, id_b, "cd cambia el contexto de aislamiento");
271        // Estable: el mismo directorio da el mismo id.
272        s.set_cwd("/home/sergio/brahman");
273        assert_eq!(s.isolation_id(), id_a);
274    }
275
276    #[test]
277    fn a_run_records_its_directory() {
278        let mut s = WorkSession::new("t", "/home");
279        let id = s.begin_run("ls -la", 1000);
280        assert_eq!(s.run(id).unwrap().cwd, "/home");
281        assert_eq!(s.run(id).unwrap().status, RunStatus::Running);
282    }
283
284    #[test]
285    fn output_accumulates_and_run_finishes() {
286        let mut s = WorkSession::new("t", "/home");
287        let id = s.begin_run("echo hola", 1000);
288        s.append_output(id, Stream::Stdout, "hola");
289        s.finish_run(id, 0, 1001);
290        let r = s.run(id).unwrap();
291        assert_eq!(r.line_count(), 1);
292        assert_eq!(r.lines_of(Stream::Stdout).collect::<Vec<_>>(), vec!["hola"]);
293        assert_eq!(r.status, RunStatus::Ok);
294        assert_eq!(r.exit_code, Some(0));
295        assert_eq!(r.finished_at, Some(1001));
296    }
297
298    #[test]
299    fn output_separates_stdout_from_stderr() {
300        let mut s = WorkSession::new("t", "/home");
301        let id = s.begin_run("build", 0);
302        s.append_output(id, Stream::Stdout, "compilando…");
303        s.append_output(id, Stream::Stderr, "warning: variable sin usar");
304        s.append_output(id, Stream::Stdout, "listo");
305        let r = s.run(id).unwrap();
306        assert!(r.has_stderr());
307        assert_eq!(r.count_of(Stream::Stdout), 2);
308        assert_eq!(r.count_of(Stream::Stderr), 1);
309        assert_eq!(
310            r.lines_of(Stream::Stderr).collect::<Vec<_>>(),
311            vec!["warning: variable sin usar"]
312        );
313    }
314
315    #[test]
316    fn nonzero_exit_marks_failed() {
317        let mut s = WorkSession::new("t", "/home");
318        let id = s.begin_run("false", 0);
319        s.finish_run(id, 1, 1);
320        assert_eq!(s.run(id).unwrap().status, RunStatus::Failed);
321    }
322
323    #[test]
324    fn running_lists_unfinished_commands() {
325        let mut s = WorkSession::new("t", "/home");
326        let a = s.begin_run("sleep 1", 0);
327        let b = s.begin_run("sleep 2", 0);
328        s.finish_run(a, 0, 1);
329        assert_eq!(s.running(), vec![b]);
330    }
331
332    #[test]
333    fn save_and_recall_a_group() {
334        let mut s = WorkSession::new("t", "/home");
335        s.save_group("deploy", vec!["cargo build".into(), "scp target host:/srv".into()]);
336        assert_eq!(s.group("deploy").unwrap().lines.len(), 2);
337        // Guardar con el mismo nombre reemplaza.
338        s.save_group("deploy", vec!["echo nuevo".into()]);
339        assert_eq!(s.group("deploy").unwrap().lines, vec!["echo nuevo"]);
340    }
341
342    #[test]
343    fn save_recent_history_as_a_group() {
344        let mut s = WorkSession::new("t", "/home");
345        for line in ["git add .", "git commit", "git push"] {
346            s.begin_run(line, 0);
347        }
348        s.save_recent_as_group("publicar", 2);
349        // Los 2 últimos, en orden cronológico.
350        assert_eq!(s.group("publicar").unwrap().lines, vec!["git commit", "git push"]);
351    }
352
353    #[test]
354    fn remove_group() {
355        let mut s = WorkSession::new("t", "/home");
356        s.save_group("x", vec!["echo x".into()]);
357        assert!(s.remove_group("x"));
358        assert!(!s.remove_group("x"));
359    }
360}