⎇ 0f2f1033eb3498e3d2635cd89fb4ba6899165d95
crates/modules/shuma/shuma-session/src/lib.rs
Historia
Quién tocó qué
crudo
feat(shuma): shuma-session + shuma-exec — sesiones de trabajo y ejecución
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}