tawasuyu

tawasuyu/shuma

Interactive shell with zsh/fish parity, native multiplexing + remote sessions, on Llimphi.

⎇ 1e5d3ed2397d6efbb71aa34380f170471508cfd7 02_ruway/shuma/sandbox/shuma-history/src/lib.rs Rust · 430 líneas · 14.6 KB Historia Quién tocó qué crudo

feat: shuma standalone — shell interactiva sobre Llimphi (front-door, git-dep al monorepo) 1e5d3ed23 · Sergio ·

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}