tawasuyu/shuma
Interactive shell with zsh/fish parity, native multiplexing + remote sessions, on Llimphi.
⎇ 1e5d3ed2397d6efbb71aa34380f170471508cfd7
02_ruway/shuma/sandbox/shuma-history/src/lib.rs
Historia
Quién tocó qué
crudo
feat: shuma standalone — shell interactiva sobre Llimphi (front-door, git-dep al monorepo)
1//! `shuma-history` — historial **durable** de comandos.
2//!
3//! Independiente del historial vivo de [`shuma_session::WorkSession`]
4//! (que guarda salida completa para la vista en curso): aquí sólo se
5//! persisten *líneas* con su contexto mínimo, en un fichero JSONL
6//! append‑only fácil de leer, rotar y compartir entre sesiones.
7//!
8//! Diseño:
9//!
10//! - **JSONL** (`{"line":...,"cwd":...,"exit":...,"started":...,"duration_ms":...}`).
11//! Una entrada por línea, append‑only — robusto frente a kills/crashes.
12//! - **Sin lock global**: las escrituras usan `OpenOptions::append`, que
13//! en Linux son atómicas hasta `PIPE_BUF` (4096 B). Las líneas largas
14//! no se entrelazan en la práctica con los tamaños típicos de un
15//! comando.
16//! - **Búsqueda fuzzy** con [`nucleo_matcher`] — mismo matcher que
17//! helix‑editor: rápido, Unicode‑correct, ranking estable.
18//! - **Dedup**: política configurable; por defecto se ignora el
19//! duplicado *consecutivo* (estilo bash `HISTCONTROL=ignoredups`).
20
21#![forbid(unsafe_code)]
22
23use std::fs::{File, OpenOptions};
24use std::io::{self, BufRead, BufReader, Write};
25use std::path::{Path, PathBuf};
26
27use serde::{Deserialize, Serialize};
28
29/// Una entrada del historial durable — la línea y su contexto mínimo.
30#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
31pub struct Entry {
32 /// La línea de comandos tal como se ejecutó.
33 pub line: String,
34 /// Directorio en que se lanzó.
35 pub cwd: String,
36 /// Código de salida (`None` si nunca terminó —p. ej. crash del shell).
37 #[serde(default, skip_serializing_if = "Option::is_none")]
38 pub exit: Option<i32>,
39 /// Segundo Unix en que arrancó.
40 pub started: u64,
41 /// Duración en milisegundos (`None` si no terminó).
42 #[serde(default, skip_serializing_if = "Option::is_none")]
43 pub duration_ms: Option<u64>,
44}
45
46impl Entry {
47 /// Construye una entrada nueva con la línea y el cwd; resto a vacío.
48 pub fn new(line: impl Into<String>, cwd: impl Into<String>, started: u64) -> Self {
49 Self {
50 line: line.into(),
51 cwd: cwd.into(),
52 exit: None,
53 started,
54 duration_ms: None,
55 }
56 }
57}
58
59/// Política de deduplicación al añadir entradas nuevas.
60#[derive(Debug, Clone, Copy, PartialEq, Eq)]
61pub enum DedupPolicy {
62 /// Guardar todas las entradas sin deduplicar.
63 None,
64 /// Saltar el comando si es idéntico al último guardado (`ignoredups`).
65 IgnoreConsecutive,
66 /// Borrar duplicados previos cuando se vuelve a ver el mismo comando.
67 EraseDups,
68}
69
70impl Default for DedupPolicy {
71 fn default() -> Self {
72 Self::IgnoreConsecutive
73 }
74}
75
76/// Dirección de navegación por el historial.
77#[derive(Debug, Clone, Copy, PartialEq, Eq)]
78pub enum Nav {
79 /// Hacia atrás en el tiempo (flecha arriba).
80 Older,
81 /// Hacia adelante en el tiempo (flecha abajo).
82 Newer,
83}
84
85/// Historial durable cargado en memoria, con su fichero de respaldo.
86pub struct History {
87 path: PathBuf,
88 entries: Vec<Entry>,
89 dedup: DedupPolicy,
90 /// Cuántas líneas inválidas se descartaron al cargar.
91 skipped: usize,
92}
93
94impl History {
95 /// Ruta por defecto: `$XDG_DATA_HOME/shuma/history.jsonl` (o el
96 /// equivalente Linux/macOS/Windows según [`directories`]). `None` si
97 /// el SO no expone un directorio de datos para el usuario.
98 pub fn default_path() -> Option<PathBuf> {
99 directories::ProjectDirs::from("", "", "shuma")
100 .map(|d| d.data_dir().join("history.jsonl"))
101 }
102
103 /// Abre (o crea) el historial en `path`. Carga todas las entradas
104 /// existentes. Las líneas inválidas se cuentan en `skipped` pero no
105 /// abortan la apertura — el shell debe poder arrancar incluso con
106 /// historial parcialmente corrupto.
107 pub fn open(path: impl Into<PathBuf>) -> io::Result<Self> {
108 let path = path.into();
109 if let Some(parent) = path.parent() {
110 std::fs::create_dir_all(parent)?;
111 }
112 let mut entries = Vec::new();
113 let mut skipped = 0usize;
114 if path.exists() {
115 let f = File::open(&path)?;
116 for line in BufReader::new(f).lines() {
117 let line = line?;
118 if line.trim().is_empty() {
119 continue;
120 }
121 match serde_json::from_str::<Entry>(&line) {
122 Ok(e) => entries.push(e),
123 Err(_) => skipped += 1,
124 }
125 }
126 }
127 Ok(Self { path, entries, dedup: DedupPolicy::default(), skipped })
128 }
129
130 /// Política de deduplicación activa.
131 pub fn dedup(&self) -> DedupPolicy {
132 self.dedup
133 }
134
135 /// Cambia la política de deduplicación.
136 pub fn set_dedup(&mut self, policy: DedupPolicy) {
137 self.dedup = policy;
138 }
139
140 /// Líneas inválidas descartadas en la última apertura.
141 pub fn skipped_on_load(&self) -> usize {
142 self.skipped
143 }
144
145 /// Cantidad de entradas en memoria.
146 pub fn len(&self) -> usize {
147 self.entries.len()
148 }
149
150 /// `true` si no hay entradas.
151 pub fn is_empty(&self) -> bool {
152 self.entries.is_empty()
153 }
154
155 /// Ruta del fichero de respaldo.
156 pub fn path(&self) -> &Path {
157 &self.path
158 }
159
160 /// Entradas en orden cronológico (más antigua primero).
161 pub fn entries(&self) -> &[Entry] {
162 &self.entries
163 }
164
165 /// Última entrada.
166 pub fn last(&self) -> Option<&Entry> {
167 self.entries.last()
168 }
169
170 /// Añade una entrada — aplica la política de dedup y persiste a
171 /// disco. Devuelve `true` si efectivamente se añadió (no era un
172 /// duplicado descartable). Las entradas con `line` vacía se ignoran.
173 pub fn append(&mut self, entry: Entry) -> io::Result<bool> {
174 if entry.line.trim().is_empty() {
175 return Ok(false);
176 }
177 match self.dedup {
178 DedupPolicy::None => {}
179 DedupPolicy::IgnoreConsecutive => {
180 if self.entries.last().is_some_and(|e| e.line == entry.line) {
181 return Ok(false);
182 }
183 }
184 DedupPolicy::EraseDups => {
185 self.entries.retain(|e| e.line != entry.line);
186 self.rewrite_file()?;
187 }
188 }
189 self.write_one(&entry)?;
190 self.entries.push(entry);
191 Ok(true)
192 }
193
194 /// Actualiza la última entrada con el código de salida y la duración
195 /// cuando el comando termina. Persiste reescribiendo el fichero.
196 pub fn finalize_last(&mut self, exit: i32, duration_ms: u64) -> io::Result<()> {
197 if let Some(last) = self.entries.last_mut() {
198 last.exit = Some(exit);
199 last.duration_ms = Some(duration_ms);
200 self.rewrite_file()?;
201 }
202 Ok(())
203 }
204
205 /// Navegación por el historial — devuelve el `(index, entry)`
206 /// correspondiente a moverse `dir` desde el cursor actual. El cursor
207 /// `None` parte "del final" (por debajo de la última entrada).
208 /// Convención: el índice 0 es la **entrada más reciente**, y avanza
209 /// hacia el pasado al subir el cursor.
210 pub fn navigate(&self, cursor: Option<usize>, dir: Nav) -> Option<(usize, &Entry)> {
211 if self.entries.is_empty() {
212 return None;
213 }
214 let next = match (cursor, dir) {
215 (None, Nav::Older) => 0,
216 (None, Nav::Newer) => return None,
217 (Some(i), Nav::Older) => i + 1,
218 (Some(0), Nav::Newer) => return None,
219 (Some(i), Nav::Newer) => i - 1,
220 };
221 if next >= self.entries.len() {
222 return None;
223 }
224 let entry = &self.entries[self.entries.len() - 1 - next];
225 Some((next, entry))
226 }
227
228 /// Búsqueda fuzzy sobre el campo `line`. Devuelve hasta `limit`
229 /// resultados ordenados por score descendente. Una `query` vacía
230 /// devuelve las entradas más recientes.
231 pub fn fuzzy_search(&self, query: &str, limit: usize) -> Vec<&Entry> {
232 if limit == 0 || self.entries.is_empty() {
233 return Vec::new();
234 }
235 if query.trim().is_empty() {
236 return self.entries.iter().rev().take(limit).collect();
237 }
238 use nucleo_matcher::{
239 pattern::{CaseMatching, Normalization, Pattern},
240 Config, Matcher,
241 };
242 let mut matcher = Matcher::new(Config::DEFAULT);
243 let pat = Pattern::parse(query, CaseMatching::Smart, Normalization::Smart);
244 let mut scored: Vec<(u32, usize)> = Vec::new();
245 let mut buf = Vec::new();
246 for (idx, e) in self.entries.iter().enumerate() {
247 buf.clear();
248 let hay = nucleo_matcher::Utf32Str::new(&e.line, &mut buf);
249 if let Some(score) = pat.score(hay, &mut matcher) {
250 scored.push((score, idx));
251 }
252 }
253 // Score desc, y a igualdad de score, el más reciente primero.
254 scored.sort_by(|a, b| b.0.cmp(&a.0).then(b.1.cmp(&a.1)));
255 scored
256 .into_iter()
257 .take(limit)
258 .map(|(_, i)| &self.entries[i])
259 .collect()
260 }
261
262 // --- I/O ---
263
264 fn write_one(&self, entry: &Entry) -> io::Result<()> {
265 let mut f = OpenOptions::new().create(true).append(true).open(&self.path)?;
266 let mut s = serde_json::to_string(entry).map_err(io::Error::other)?;
267 s.push('\n');
268 f.write_all(s.as_bytes())?;
269 f.flush()
270 }
271
272 fn rewrite_file(&self) -> io::Result<()> {
273 // Escritura atómica vía rename — nunca dejamos un historial a medias.
274 let tmp = self.path.with_extension("jsonl.tmp");
275 {
276 let mut f = OpenOptions::new()
277 .create(true)
278 .truncate(true)
279 .write(true)
280 .open(&tmp)?;
281 for e in &self.entries {
282 let mut s = serde_json::to_string(e).map_err(io::Error::other)?;
283 s.push('\n');
284 f.write_all(s.as_bytes())?;
285 }
286 f.flush()?;
287 }
288 std::fs::rename(tmp, &self.path)
289 }
290}
291
292#[cfg(test)]
293mod tests {
294 use super::*;
295 use tempfile::tempdir;
296
297 fn h(dir: &Path) -> History {
298 History::open(dir.join("history.jsonl")).unwrap()
299 }
300
301 #[test]
302 fn empty_history_round_trip() {
303 let d = tempdir().unwrap();
304 let h1 = h(d.path());
305 assert!(h1.is_empty());
306 let h2 = h(d.path());
307 assert!(h2.is_empty());
308 }
309
310 #[test]
311 fn append_persists_across_reopen() {
312 let d = tempdir().unwrap();
313 {
314 let mut h = h(d.path());
315 h.append(Entry::new("ls", "/tmp", 1000)).unwrap();
316 h.append(Entry::new("pwd", "/tmp", 1001)).unwrap();
317 }
318 let h = h(d.path());
319 assert_eq!(h.len(), 2);
320 assert_eq!(h.entries()[0].line, "ls");
321 assert_eq!(h.entries()[1].line, "pwd");
322 }
323
324 #[test]
325 fn ignore_consecutive_dedup_skips_repeats() {
326 let d = tempdir().unwrap();
327 let mut h = h(d.path());
328 assert!(h.append(Entry::new("ls", "/tmp", 1)).unwrap());
329 assert!(!h.append(Entry::new("ls", "/tmp", 2)).unwrap());
330 assert!(h.append(Entry::new("pwd", "/tmp", 3)).unwrap());
331 assert!(h.append(Entry::new("ls", "/tmp", 4)).unwrap());
332 assert_eq!(h.len(), 3);
333 }
334
335 #[test]
336 fn erase_dups_purges_prior_copies() {
337 let d = tempdir().unwrap();
338 let mut h = h(d.path());
339 h.set_dedup(DedupPolicy::EraseDups);
340 h.append(Entry::new("ls", "/tmp", 1)).unwrap();
341 h.append(Entry::new("pwd", "/tmp", 2)).unwrap();
342 h.append(Entry::new("ls", "/tmp", 3)).unwrap();
343 assert_eq!(h.len(), 2);
344 assert_eq!(h.entries()[0].line, "pwd");
345 assert_eq!(h.entries()[1].line, "ls");
346 }
347
348 #[test]
349 fn empty_line_is_ignored() {
350 let d = tempdir().unwrap();
351 let mut h = h(d.path());
352 assert!(!h.append(Entry::new("", "/tmp", 1)).unwrap());
353 assert!(!h.append(Entry::new(" ", "/tmp", 2)).unwrap());
354 assert!(h.is_empty());
355 }
356
357 #[test]
358 fn finalize_writes_exit_and_duration() {
359 let d = tempdir().unwrap();
360 {
361 let mut h = h(d.path());
362 h.append(Entry::new("sleep 1", "/tmp", 0)).unwrap();
363 h.finalize_last(0, 1000).unwrap();
364 }
365 let h = h(d.path());
366 assert_eq!(h.last().unwrap().exit, Some(0));
367 assert_eq!(h.last().unwrap().duration_ms, Some(1000));
368 }
369
370 #[test]
371 fn navigate_walks_from_newest_to_oldest() {
372 let d = tempdir().unwrap();
373 let mut h = h(d.path());
374 for (i, l) in ["a", "b", "c"].iter().enumerate() {
375 h.append(Entry::new(*l, "/tmp", i as u64)).unwrap();
376 }
377 // Empezando sin cursor, Older da la más reciente.
378 let (i0, e0) = h.navigate(None, Nav::Older).unwrap();
379 assert_eq!((i0, e0.line.as_str()), (0, "c"));
380 let (i1, e1) = h.navigate(Some(i0), Nav::Older).unwrap();
381 assert_eq!((i1, e1.line.as_str()), (1, "b"));
382 let (i2, e2) = h.navigate(Some(i1), Nav::Older).unwrap();
383 assert_eq!((i2, e2.line.as_str()), (2, "a"));
384 // En el extremo no hay más viejas.
385 assert!(h.navigate(Some(i2), Nav::Older).is_none());
386 // Volvemos hacia las nuevas.
387 let (i3, e3) = h.navigate(Some(i2), Nav::Newer).unwrap();
388 assert_eq!((i3, e3.line.as_str()), (1, "b"));
389 }
390
391 #[test]
392 fn fuzzy_search_ranks_matches() {
393 let d = tempdir().unwrap();
394 let mut h = h(d.path());
395 for l in ["cargo build --release", "cargo test", "git status", "cargo run"] {
396 h.append(Entry::new(l, "/tmp", 0)).unwrap();
397 }
398 let hits = h.fuzzy_search("cgo", 10);
399 // Las 3 entradas con "cargo" matchean; "git status" no.
400 assert_eq!(hits.len(), 3);
401 assert!(hits.iter().all(|e| e.line.contains("cargo")));
402 }
403
404 #[test]
405 fn empty_query_returns_most_recent_first() {
406 let d = tempdir().unwrap();
407 let mut h = h(d.path());
408 for l in ["a", "b", "c", "d"] {
409 h.append(Entry::new(l, "/tmp", 0)).unwrap();
410 }
411 let hits = h.fuzzy_search("", 2);
412 assert_eq!(hits.len(), 2);
413 assert_eq!(hits[0].line, "d");
414 assert_eq!(hits[1].line, "c");
415 }
416
417 #[test]
418 fn corrupt_lines_are_skipped_not_fatal() {
419 let d = tempdir().unwrap();
420 let path = d.path().join("history.jsonl");
421 std::fs::write(
422 &path,
423 "{\"line\":\"ok\",\"cwd\":\"/tmp\",\"started\":1}\ngarbage\n{\"line\":\"ok2\",\"cwd\":\"/tmp\",\"started\":2}\n",
424 )
425 .unwrap();
426 let h = History::open(&path).unwrap();
427 assert_eq!(h.len(), 2);
428 assert_eq!(h.skipped_on_load(), 1);
429 }
430}