⎇ 0fde2aa2732815edaf12c2715bb154ec944d92fe
crates/modules/shuma/shuma-session/src/lib.rs
History
Who touched what
raw
feat(shuma-shell): tarjetas acordeón con filtro stdout/stderr
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}