//! `shuma-infer` — el motor de inferencia de intenciones secuenciales. //! //! El shell observa cómo trabajas. Cuando una *coreografía* de comandos //! se repite —`cd` a un proyecto, `git pull`, `cargo build`— este motor //! la detecta, la abstrae (los argumentos que cambian se vuelven //! variables) y la ofrece como un patrón reutilizable. Automatización //! que nace de la repetición orgánica, no de escribir scripts. //! //! Es agnóstico y determinista: recibe el historial reducido a //! [`CommandRecord`]s y devuelve [`EmergingPattern`]s. No toca disco, ni //! la red, ni ningún frontend — el shell se encarga de eso. //! //! ```text //! historial ──► detect_patterns ──► [EmergingPattern] //! · firma de binarios (ventana deslizante) //! · sólo ventanas 100% exitosas //! · abstracción: args que varían → Varies //! · se quedan los patrones maximales //! ``` #![forbid(unsafe_code)] use std::collections::BTreeMap; use serde::{Deserialize, Serialize}; /// Un comando ejecutado, reducido a lo que importa para inferir. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct CommandRecord { /// El binario invocado — la primera palabra de la línea. pub binary: String, /// Los argumentos, en orden. pub args: Vec, /// Directorio en que se ejecutó. pub cwd: String, /// Si terminó con éxito (código 0). pub success: bool, } impl CommandRecord { /// Reduce una línea de comando a un registro. La división es simple /// (`split_whitespace`) — suficiente para comparar firmas. pub fn parse(line: &str, cwd: impl Into, success: bool) -> Self { let mut words = line.split_whitespace().map(str::to_string); let binary = words.next().unwrap_or_default(); Self { binary, args: words.collect(), cwd: cwd.into(), success } } } /// Ajustes del detector. #[derive(Debug, Clone, Copy, Serialize, Deserialize)] pub struct InferConfig { /// Largo mínimo de una secuencia para considerarla patrón. pub min_len: usize, /// Largo máximo de ventana a buscar. pub max_len: usize, /// Cuántas veces debe repetirse una firma para emerger. pub min_occurrences: usize, } impl Default for InferConfig { fn default() -> Self { Self { min_len: 2, max_len: 5, min_occurrences: 2 } } } /// Los argumentos de un paso del patrón, tras la abstracción. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub enum StepArgs { /// Los argumentos son idénticos en todas las ocurrencias. Fixed(Vec), /// Los argumentos cambian entre ocurrencias — son una variable. Varies, } /// Un paso abstracto del patrón: el binario + sus argumentos. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct PatternStep { pub binary: String, pub args: StepArgs, } impl PatternStep { /// Renderiza el paso para mostrarlo — `"git pull"`, `"cd <…>"`. pub fn render(&self) -> String { match &self.args { StepArgs::Fixed(a) if a.is_empty() => self.binary.clone(), StepArgs::Fixed(a) => format!("{} {}", self.binary, a.join(" ")), StepArgs::Varies => format!("{} <…>", self.binary), } } } /// Un patrón de comandos que emergió del historial. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct EmergingPattern { /// Firma: la secuencia de binarios. pub signature: Vec, /// Pasos abstractos — para mostrar al usuario. pub steps: Vec, /// Las líneas reales de la ocurrencia más reciente — ejecutables. pub example: Vec, /// Cuántas veces apareció el patrón. pub occurrences: usize, /// Directorios donde arrancó el patrón, sin repetir. pub directories: Vec, } impl EmergingPattern { /// Puntaje de interés: más largo y más frecuente, más arriba. pub fn score(&self) -> usize { self.occurrences * self.signature.len() } /// Nombre sugerido para el patrón — los binarios significativos /// (sin el `cd` inicial) unidos por `+`. pub fn suggested_name(&self) -> String { let significant: Vec<&str> = self .signature .iter() .filter(|b| b.as_str() != "cd") .map(String::as_str) .collect(); if significant.is_empty() { self.signature.join("+") } else { significant.join("+") } } } /// `true` si `needle` aparece como sub-secuencia contigua de `haystack`. fn contains_subslice(haystack: &[String], needle: &[String]) -> bool { needle.len() <= haystack.len() && haystack.windows(needle.len()).any(|w| w == needle) } /// Construye el patrón abstracto a partir de su firma y las posiciones /// donde ocurrió. fn build_pattern( history: &[CommandRecord], signature: &[String], starts: &[usize], ) -> EmergingPattern { let len = signature.len(); let mut steps = Vec::with_capacity(len); for i in 0..len { // Argumentos de este paso a lo largo de todas las ocurrencias. let first = &history[starts[0] + i].args; let all_same = starts.iter().all(|&s| &history[s + i].args == first); let args = if all_same { StepArgs::Fixed(first.clone()) } else { StepArgs::Varies }; steps.push(PatternStep { binary: signature[i].clone(), args }); } // La ocurrencia más reciente da las líneas reales, ejecutables. let last = *starts.iter().max().expect("hay ocurrencias"); let example: Vec = (0..len) .map(|i| { let c = &history[last + i]; if c.args.is_empty() { c.binary.clone() } else { format!("{} {}", c.binary, c.args.join(" ")) } }) .collect(); // El directorio "de trabajo" de una ocurrencia: el cwd de su último // comando — para entonces todos los `cd` ya se hicieron. let mut directories: Vec = Vec::new(); for &s in starts { let d = &history[s + len - 1].cwd; if !directories.contains(d) { directories.push(d.clone()); } } EmergingPattern { signature: signature.to_vec(), steps, example, occurrences: starts.len(), directories, } } /// Detecta los patrones de comandos repetidos en `history`. /// /// Sólo cuentan las ventanas cuyos comandos terminaron todos con éxito. /// Se devuelven los patrones *maximales* (uno contenido en otro más /// largo no se reporta), ordenados por puntaje descendente. pub fn detect_patterns(history: &[CommandRecord], cfg: &InferConfig) -> Vec { // firma → posiciones de inicio de las ventanas que la producen. let mut windows: BTreeMap, Vec> = BTreeMap::new(); for len in cfg.min_len..=cfg.max_len { if history.len() < len { break; } for start in 0..=history.len() - len { let win = &history[start..start + len]; if !win.iter().all(|c| c.success) { continue; // una ventana con un fallo no es un patrón } let signature: Vec = win.iter().map(|c| c.binary.clone()).collect(); windows.entry(signature).or_default().push(start); } } // Firmas que se repiten lo suficiente. let qualifying: Vec<(Vec, Vec)> = windows .into_iter() .filter(|(_, starts)| starts.len() >= cfg.min_occurrences) .collect(); // Sólo las maximales: una firma contenida en otra más larga que // también califica se descarta (la larga la subsume). let mut patterns: Vec = qualifying .iter() .filter(|(sig, _)| { !qualifying .iter() .any(|(other, _)| other.len() > sig.len() && contains_subslice(other, sig)) }) .map(|(sig, starts)| build_pattern(history, sig, starts)) .collect(); patterns.sort_by(|a, b| { b.score() .cmp(&a.score()) .then(a.signature.cmp(&b.signature)) }); patterns } /// Predice la continuación de un patrón en curso. /// /// Mira el final del historial `recent`: si sus últimos comandos /// coinciden con el prefijo de la firma de algún patrón, devuelve las /// líneas que faltan para completarlo —tomadas de la ocurrencia más /// reciente, así son ejecutables—. Ante varios, gana el patrón cuyo /// prefijo coincidente sea más largo. Es lo que alimenta el "ghosting". /// Devuelve `(índice del patrón en `patterns`, líneas de continuación)`. pub fn predict_next( recent: &[CommandRecord], patterns: &[EmergingPattern], ) -> Option<(usize, Vec)> { let bins: Vec<&str> = recent.iter().map(|r| r.binary.as_str()).collect(); // best = (longitud del prefijo coincidente, índice del patrón). let mut best: Option<(usize, usize)> = None; for (pi, p) in patterns.iter().enumerate() { // Tiene que quedar al menos un paso por predecir. let max_k = p.signature.len().saturating_sub(1).min(bins.len()); for k in (1..=max_k).rev() { let tail = &bins[bins.len() - k..]; let prefix_matches = p .signature .iter() .take(k) .map(String::as_str) .eq(tail.iter().copied()); if prefix_matches { if best.map(|(bk, _)| k > bk).unwrap_or(true) { best = Some((k, pi)); } break; } } } best.map(|(k, pi)| (pi, patterns[pi].example[k..].to_vec())) } #[cfg(test)] mod tests { use super::*; /// Atajo: un `CommandRecord` exitoso. fn ok(line: &str, cwd: &str) -> CommandRecord { CommandRecord::parse(line, cwd, true) } #[test] fn parse_splits_binary_and_args() { let r = CommandRecord::parse("git commit -m mensaje", "/p", true); assert_eq!(r.binary, "git"); assert_eq!(r.args, vec!["commit", "-m", "mensaje"]); } #[test] fn detects_a_repeated_sequence() { // cd → git pull → cargo build, dos veces, en dos directorios. let history = vec![ ok("cd /proj/a", "/home"), ok("git pull", "/proj/a"), ok("cargo build", "/proj/a"), ok("cd /proj/b", "/home"), ok("git pull", "/proj/b"), ok("cargo build", "/proj/b"), ]; let patterns = detect_patterns(&history, &InferConfig::default()); assert_eq!(patterns.len(), 1); let p = &patterns[0]; assert_eq!(p.signature, vec!["cd", "git", "cargo"]); assert_eq!(p.occurrences, 2); } #[test] fn abstracts_varying_arguments() { let history = vec![ ok("cd /proj/a", "/home"), ok("git pull", "/proj/a"), ok("cd /proj/b", "/home"), ok("git pull", "/proj/b"), ]; let patterns = detect_patterns(&history, &InferConfig::default()); let p = &patterns[0]; // El `cd` cambia de argumento → Varies; `git pull` es constante. assert_eq!(p.steps[0].args, StepArgs::Varies); assert_eq!(p.steps[1].args, StepArgs::Fixed(vec!["pull".into()])); assert_eq!(p.steps[0].render(), "cd <…>"); assert_eq!(p.steps[1].render(), "git pull"); } #[test] fn example_is_the_most_recent_occurrence() { let history = vec![ ok("cd /proj/a", "/home"), ok("git pull", "/proj/a"), ok("cd /proj/b", "/home"), ok("git pull", "/proj/b"), ]; let p = &detect_patterns(&history, &InferConfig::default())[0]; // Las líneas reales y ejecutables de la última ocurrencia. assert_eq!(p.example, vec!["cd /proj/b", "git pull"]); } #[test] fn a_failed_command_breaks_the_pattern() { let history = vec![ ok("cd /proj/a", "/home"), ok("git pull", "/proj/a"), ok("cd /proj/b", "/home"), CommandRecord::parse("git pull", "/proj/b", false), // falló ]; // Sólo una ventana [cd, git] exitosa → no se repite → sin patrón. assert!(detect_patterns(&history, &InferConfig::default()).is_empty()); } #[test] fn no_repetition_yields_no_patterns() { let history = vec![ ok("ls", "/a"), ok("pwd", "/a"), ok("date", "/a"), ]; assert!(detect_patterns(&history, &InferConfig::default()).is_empty()); } #[test] fn longer_pattern_subsumes_its_subsequences() { // [cd, git, cargo] repetido → no se reporta también [cd, git]. let history = vec![ ok("cd /a", "/h"), ok("git pull", "/a"), ok("cargo build", "/a"), ok("cd /b", "/h"), ok("git pull", "/b"), ok("cargo build", "/b"), ]; let patterns = detect_patterns(&history, &InferConfig::default()); assert_eq!(patterns.len(), 1); assert_eq!(patterns[0].signature.len(), 3); } #[test] fn directories_are_collected() { let history = vec![ ok("cd /a", "/home"), ok("git pull", "/a"), ok("cd /b", "/work"), ok("git pull", "/b"), ]; let p = &detect_patterns(&history, &InferConfig::default())[0]; // El directorio de cada ocurrencia es el de su último comando. assert_eq!(p.directories, vec!["/a", "/b"]); } #[test] fn suggested_name_drops_the_cd() { let history = vec![ ok("cd /a", "/h"), ok("git pull", "/a"), ok("cargo build", "/a"), ok("cd /b", "/h"), ok("git pull", "/b"), ok("cargo build", "/b"), ]; let p = &detect_patterns(&history, &InferConfig::default())[0]; assert_eq!(p.suggested_name(), "git+cargo"); } /// Mundo de prueba: el patrón cd → git pull → cargo build, visto dos /// veces, y la lista de patrones que produce. fn pattern_world() -> Vec { let history = vec![ ok("cd /a", "/h"), ok("git pull", "/a"), ok("cargo build", "/a"), ok("cd /b", "/h"), ok("git pull", "/b"), ok("cargo build", "/b"), ]; detect_patterns(&history, &InferConfig::default()) } #[test] fn predicts_the_rest_after_a_cd() { let patterns = pattern_world(); // El usuario acaba de hacer `cd` → se predicen los pasos que faltan. let recent = vec![ok("cd /nuevo", "/h")]; let (_, next) = predict_next(&recent, &patterns).unwrap(); assert_eq!(next, vec!["git pull", "cargo build"]); } #[test] fn prediction_shrinks_as_the_pattern_advances() { let patterns = pattern_world(); let recent = vec![ok("cd /nuevo", "/h"), ok("git pull", "/nuevo")]; let (_, next) = predict_next(&recent, &patterns).unwrap(); assert_eq!(next, vec!["cargo build"]); } #[test] fn no_prediction_when_nothing_matches() { let patterns = pattern_world(); let recent = vec![ok("ls", "/h"), ok("pwd", "/h")]; assert!(predict_next(&recent, &patterns).is_none()); } #[test] fn score_ranks_longer_and_more_frequent_higher() { let short = EmergingPattern { signature: vec!["a".into(), "b".into()], steps: vec![], example: vec![], occurrences: 2, directories: vec![], }; let long = EmergingPattern { signature: vec!["a".into(), "b".into(), "c".into()], steps: vec![], example: vec![], occurrences: 3, directories: vec![], }; assert!(long.score() > short.score()); } }