gioser

tawasuyu/brahman

Rust 476TOML 190Markdown 50JSON 176602 Definiciones

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

feat(shuma): shuma-session + shuma-exec — sesiones de trabajo y ejecución 0f2f1033e · 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/// Un comando ejecutado: la línea, el directorio, el estado y la salida.
35#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
36pub struct CommandRun {
37    pub id: RunId,
38    /// La línea de comandos tal como se escribió.
39    pub line: String,
40    /// El directorio en que se ejecutó.
41    pub cwd: String,
42    pub status: RunStatus,
43    /// Código de salida, una vez terminado.
44    pub exit_code: Option<i32>,
45    /// Salida combinada (stdout + stderr), una línea por elemento.
46    pub output: Vec<String>,
47    /// Segundo Unix en que arrancó.
48    pub started_at: u64,
49    /// Segundo Unix en que terminó.
50    pub finished_at: Option<u64>,
51}
52
53impl CommandRun {
54    /// `true` si el comando sigue corriendo.
55    pub fn is_running(&self) -> bool {
56        self.status == RunStatus::Running
57    }
58
59    /// Cantidad de líneas de salida.
60    pub fn line_count(&self) -> usize {
61        self.output.len()
62    }
63}
64
65/// Un grupo de comandos guardado para reutilizar — una receta.
66#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
67pub struct CommandGroup {
68    pub name: String,
69    /// Las líneas de comando, en orden de ejecución.
70    pub lines: Vec<String>,
71}
72
73/// La sesión de trabajo: directorio actual + historial + grupos.
74#[derive(Debug, Clone, Serialize, Deserialize)]
75pub struct WorkSession {
76    pub name: String,
77    cwd: String,
78    history: Vec<CommandRun>,
79    groups: Vec<CommandGroup>,
80    next_id: RunId,
81}
82
83/// FNV-1a de 64 bits — base del identificador de aislamiento.
84fn fnv1a(bytes: &[u8]) -> u64 {
85    let mut h: u64 = 0xcbf2_9ce4_8422_2325;
86    for &b in bytes {
87        h ^= b as u64;
88        h = h.wrapping_mul(0x100_0000_01b3);
89    }
90    h
91}
92
93impl WorkSession {
94    /// Abre una sesión con un nombre y un directorio inicial.
95    pub fn new(name: impl Into<String>, cwd: impl Into<String>) -> Self {
96        Self {
97            name: name.into(),
98            cwd: cwd.into(),
99            history: Vec::new(),
100            groups: Vec::new(),
101            next_id: 1,
102        }
103    }
104
105    /// Directorio actual de la sesión.
106    pub fn cwd(&self) -> &str {
107        &self.cwd
108    }
109
110    /// Cambia el directorio actual — y con él, el contexto de aislamiento.
111    pub fn set_cwd(&mut self, cwd: impl Into<String>) {
112        self.cwd = cwd.into();
113    }
114
115    /// Identificador de aislamiento del directorio actual: un hash corto
116    /// y estable del `cwd`. Cada directorio es un contexto distinto, así
117    /// que el id cambia al hacer `cd`.
118    pub fn isolation_id(&self) -> String {
119        format!("{:012x}", fnv1a(self.cwd.as_bytes()) & 0xffff_ffff_ffff)
120    }
121
122    // --- Historial de comandos ---
123
124    /// Registra el inicio de un comando (estado `Running`) en el `cwd`
125    /// actual. Devuelve su id.
126    pub fn begin_run(&mut self, line: impl Into<String>, now: u64) -> RunId {
127        let id = self.next_id;
128        self.next_id += 1;
129        self.history.push(CommandRun {
130            id,
131            line: line.into(),
132            cwd: self.cwd.clone(),
133            status: RunStatus::Running,
134            exit_code: None,
135            output: Vec::new(),
136            started_at: now,
137            finished_at: None,
138        });
139        id
140    }
141
142    pub fn run(&self, id: RunId) -> Option<&CommandRun> {
143        self.history.iter().find(|r| r.id == id)
144    }
145
146    fn run_mut(&mut self, id: RunId) -> Option<&mut CommandRun> {
147        self.history.iter_mut().find(|r| r.id == id)
148    }
149
150    /// Añade una línea de salida a un comando en curso.
151    pub fn append_output(&mut self, id: RunId, line: impl Into<String>) {
152        if let Some(r) = self.run_mut(id) {
153            r.output.push(line.into());
154        }
155    }
156
157    /// Marca un comando como terminado con su código de salida.
158    pub fn finish_run(&mut self, id: RunId, exit_code: i32, now: u64) {
159        if let Some(r) = self.run_mut(id) {
160            r.exit_code = Some(exit_code);
161            r.status = if exit_code == 0 { RunStatus::Ok } else { RunStatus::Failed };
162            r.finished_at = Some(now);
163        }
164    }
165
166    /// Historial completo, del más antiguo al más reciente.
167    pub fn history(&self) -> &[CommandRun] {
168        &self.history
169    }
170
171    /// Comandos que siguen corriendo.
172    pub fn running(&self) -> Vec<RunId> {
173        self.history
174            .iter()
175            .filter(|r| r.is_running())
176            .map(|r| r.id)
177            .collect()
178    }
179
180    /// Vacía el historial (no toca los grupos ni el `cwd`).
181    pub fn clear_history(&mut self) {
182        self.history.clear();
183    }
184
185    // --- Grupos reutilizables ---
186
187    /// Guarda un grupo de comandos. Si ya existe uno con ese nombre, lo
188    /// reemplaza.
189    pub fn save_group(&mut self, name: impl Into<String>, lines: Vec<String>) {
190        let name = name.into();
191        self.groups.retain(|g| g.name != name);
192        self.groups.push(CommandGroup { name, lines });
193    }
194
195    /// Guarda como grupo las líneas de los últimos `n` comandos del
196    /// historial — la forma natural de "convertir lo que acabo de hacer
197    /// en una receta".
198    pub fn save_recent_as_group(&mut self, name: impl Into<String>, n: usize) {
199        let lines: Vec<String> = self
200            .history
201            .iter()
202            .rev()
203            .take(n)
204            .rev()
205            .map(|r| r.line.clone())
206            .collect();
207        self.save_group(name, lines);
208    }
209
210    pub fn groups(&self) -> &[CommandGroup] {
211        &self.groups
212    }
213
214    pub fn group(&self, name: &str) -> Option<&CommandGroup> {
215        self.groups.iter().find(|g| g.name == name)
216    }
217
218    /// Quita un grupo. `true` si existía.
219    pub fn remove_group(&mut self, name: &str) -> bool {
220        let before = self.groups.len();
221        self.groups.retain(|g| g.name != name);
222        self.groups.len() != before
223    }
224}
225
226#[cfg(test)]
227mod tests {
228    use super::*;
229
230    #[test]
231    fn isolation_id_follows_the_directory() {
232        let mut s = WorkSession::new("trabajo", "/home/sergio/brahman");
233        let id_a = s.isolation_id();
234        s.set_cwd("/tmp");
235        let id_b = s.isolation_id();
236        assert_ne!(id_a, id_b, "cd cambia el contexto de aislamiento");
237        // Estable: el mismo directorio da el mismo id.
238        s.set_cwd("/home/sergio/brahman");
239        assert_eq!(s.isolation_id(), id_a);
240    }
241
242    #[test]
243    fn a_run_records_its_directory() {
244        let mut s = WorkSession::new("t", "/home");
245        let id = s.begin_run("ls -la", 1000);
246        assert_eq!(s.run(id).unwrap().cwd, "/home");
247        assert_eq!(s.run(id).unwrap().status, RunStatus::Running);
248    }
249
250    #[test]
251    fn output_accumulates_and_run_finishes() {
252        let mut s = WorkSession::new("t", "/home");
253        let id = s.begin_run("echo hola", 1000);
254        s.append_output(id, "hola");
255        s.finish_run(id, 0, 1001);
256        let r = s.run(id).unwrap();
257        assert_eq!(r.output, vec!["hola"]);
258        assert_eq!(r.status, RunStatus::Ok);
259        assert_eq!(r.exit_code, Some(0));
260        assert_eq!(r.finished_at, Some(1001));
261    }
262
263    #[test]
264    fn nonzero_exit_marks_failed() {
265        let mut s = WorkSession::new("t", "/home");
266        let id = s.begin_run("false", 0);
267        s.finish_run(id, 1, 1);
268        assert_eq!(s.run(id).unwrap().status, RunStatus::Failed);
269    }
270
271    #[test]
272    fn running_lists_unfinished_commands() {
273        let mut s = WorkSession::new("t", "/home");
274        let a = s.begin_run("sleep 1", 0);
275        let b = s.begin_run("sleep 2", 0);
276        s.finish_run(a, 0, 1);
277        assert_eq!(s.running(), vec![b]);
278    }
279
280    #[test]
281    fn save_and_recall_a_group() {
282        let mut s = WorkSession::new("t", "/home");
283        s.save_group("deploy", vec!["cargo build".into(), "scp target host:/srv".into()]);
284        assert_eq!(s.group("deploy").unwrap().lines.len(), 2);
285        // Guardar con el mismo nombre reemplaza.
286        s.save_group("deploy", vec!["echo nuevo".into()]);
287        assert_eq!(s.group("deploy").unwrap().lines, vec!["echo nuevo"]);
288    }
289
290    #[test]
291    fn save_recent_history_as_a_group() {
292        let mut s = WorkSession::new("t", "/home");
293        for line in ["git add .", "git commit", "git push"] {
294            s.begin_run(line, 0);
295        }
296        s.save_recent_as_group("publicar", 2);
297        // Los 2 últimos, en orden cronológico.
298        assert_eq!(s.group("publicar").unwrap().lines, vec!["git commit", "git push"]);
299    }
300
301    #[test]
302    fn remove_group() {
303        let mut s = WorkSession::new("t", "/home");
304        s.save_group("x", vec!["echo x".into()]);
305        assert!(s.remove_group("x"));
306        assert!(!s.remove_group("x"));
307    }
308}