//! Structured command history: one JSONL file per REPL session under //! history/.jsonl. Replaces the flat history.txt (imported once). use crate::app::App; use anyhow::{anyhow, Context, Result}; use serde::{Deserialize, Serialize}; use std::path::{Path, PathBuf}; #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct HistoryEntry { /// RFC 3339 timestamp. pub ts: String, pub cmd: String, /// "am" (agent-manager command) or "shell" (system gateway command). #[serde(default = "kind_am")] pub kind: String, #[serde(default)] pub cwd: Option, #[serde(default)] pub exit_code: Option, #[serde(default)] pub duration_ms: Option, #[serde(default)] pub agent: Option, } fn kind_am() -> String { "am".to_string() } impl HistoryEntry { pub fn now(cmd: impl Into) -> Self { HistoryEntry { ts: crate::installers::now_rfc3339(), cmd: cmd.into(), kind: "am".to_string(), cwd: None, exit_code: None, duration_ms: None, agent: None, } } pub fn with_kind(mut self, kind: impl Into) -> Self { self.kind = kind.into(); self } pub fn with_cwd(mut self, cwd: Option) -> Self { self.cwd = cwd; self } pub fn with_exit_code(mut self, code: Option) -> Self { self.exit_code = code; self } pub fn with_duration_ms(mut self, ms: u64) -> Self { self.duration_ms = Some(ms); self } pub fn with_agent(mut self, agent: Option) -> Self { self.agent = agent; self } } /// Directory holding the per-session history files. pub fn dir(app: &App) -> PathBuf { app.events_dir().join("history") } /// Append one entry to the session file of a REPL session. pub fn record(app: &App, session: &str, entry: &HistoryEntry) -> Result<()> { let d = dir(app); std::fs::create_dir_all(&d).ok(); let path = d.join(format!("{session}.jsonl")); let line = serde_json::to_string(entry)?; let mut f = std::fs::OpenOptions::new() .create(true) .append(true) .open(&path) .with_context(|| format!("cannot open history file {}", path.display()))?; use std::io::Write; f.write_all(line.as_bytes())?; f.write_all(b"\n")?; Ok(()) } /// One-shot migration: import the flat history.txt into the structured /// directory. Returns the number of imported entries. pub fn migrate(app: &App) -> Result { let txt = app.paths.state_file.with_file_name("history.txt"); if !txt.exists() { return Ok(0); } let d = dir(app); let already = d.join("migrated.jsonl").exists(); if already { return Ok(0); } let text = std::fs::read_to_string(&txt) .with_context(|| format!("cannot read {}", txt.display()))?; let mut count = 0; let mut previous: Option = None; for line in text.lines() { let trimmed = line.trim(); if trimmed.is_empty() || trimmed.starts_with('#') { continue; } // Déduplication des répétitions consécutives (quick win #13). if previous.as_deref() == Some(trimmed) { continue; } previous = Some(trimmed.to_string()); let entry = HistoryEntry::now(trimmed.to_string()) .with_kind("am".to_string()) .with_cwd(None); record(app, "migrated", &entry)?; count += 1; } Ok(count) } /// Expand a history reference against the most recent entries /// (most recent first): !! (last), !N (the Nth most recent), !prefix (the /// most recent command starting with prefix) and ^old^new (substitution in /// the last command). Returns None when the line is not a reference. pub fn expand(entries: &[HistoryEntry], line: &str) -> Option { let trimmed = line.trim(); if trimmed == "!!" { return entries.first().map(|e| e.cmd.clone()); } if let Some(rest) = trimmed.strip_prefix('!') { if !rest.is_empty() { if let Ok(n) = rest.parse::() { if n >= 1 { return entries.get(n - 1).map(|e| e.cmd.clone()); } return None; } return entries .iter() .find(|e| e.cmd.starts_with(rest)) .map(|e| e.cmd.clone()); } } if let Some(body) = trimmed.strip_prefix('^') { if let Some((old, new)) = body.split_once('^') { if !old.is_empty() { let last = entries.first()?; return Some(last.cmd.replacen(old, new, 1)); } } } None } /// History files of a directory, oldest first. pub fn files(dir: &Path) -> Vec { let mut out: Vec = std::fs::read_dir(dir) .map(|rd| { rd.flatten() .map(|e| e.path()) .filter(|p| p.is_file()) .filter(|p| p.extension().and_then(|e| e.to_str()) == Some("jsonl")) .collect() }) .unwrap_or_default(); out.sort(); out } /// Read all entries across sessions, most recent first. Corrupt lines are /// skipped silently. pub fn read_all(app: &App, limit: usize) -> Vec { let d = dir(app); let mut out: Vec = Vec::new(); for f in files(&d).iter().rev() { if let Ok(text) = std::fs::read_to_string(f) { let mut entries: Vec = text .lines() .filter(|l| !l.trim().is_empty()) .filter_map(|l| serde_json::from_str::(l).ok()) .collect(); entries.reverse(); // newest first within one file out.extend(entries); if limit > 0 && out.len() >= limit { break; } } } if limit > 0 { out.truncate(limit); } out } /// Read the entries of one session file (oldest first). Missing or corrupt /// files yield an empty list — an export never fails on history. pub fn read_session(app: &App, session: &str) -> Vec { let path = dir(app).join(format!("{session}.jsonl")); let Ok(text) = std::fs::read_to_string(&path) else { return Vec::new(); }; text.lines() .filter(|l| !l.trim().is_empty()) .filter_map(|l| serde_json::from_str::(l).ok()) .collect() } /// Parse the session id from a history file name ("20260815_143926.jsonl"). pub fn session_of_file(path: &Path) -> Option { path.file_name() .and_then(|n| n.to_str()) .map(|n| n.trim_end_matches(".jsonl").to_string()) } /// Corrupt-history guard: a session file that cannot be parsed is reported, /// never fatal. pub fn validate_file(path: &Path) -> Result<()> { let text = std::fs::read_to_string(path) .with_context(|| format!("cannot read history file {}", path.display()))?; for (i, line) in text.lines().enumerate() { if line.trim().is_empty() { continue; } serde_json::from_str::(line).map_err(|e| { anyhow!("history file {} line {} is corrupt: {e}", path.display(), i + 1) })?; } Ok(()) } #[cfg(test)] mod tests { use super::*; use clap::Parser; fn test_app(tag: &str) -> App { let guard = tempfile::tempdir().unwrap(); let dir = guard.path().to_path_buf(); std::mem::forget(guard); let cfg = dir.join("config.yaml"); std::fs::write( &cfg, concat!( "version: \"1.0\"\n", "settings:\n", " auto_install_deps: false\n", "agents: []\n", ), ) .unwrap(); let cli = crate::cli::Cli::parse_from(["am", "--config", cfg.to_str().unwrap()]); let mut app = App::from_cli(cli).unwrap(); let mut p = app.paths.clone(); p.install_dir = dir.join("agents"); p.bin_dir = dir.join("agents").join("bin"); p.log_dir = dir.join("logs"); p.state_file = dir.join("state.json"); p.probe_cache_file = dir.join("probe-cache.json"); p.config_dir = Some(dir.join("config")); app.paths = p; let _ = tag; app } #[test] fn record_and_read_back() { let app = test_app("hist"); record(&app, "s1", &HistoryEntry::now("install jcode")).unwrap(); record( &app, "s1", &HistoryEntry::now("ls").with_kind("shell".to_string()).with_duration_ms(12), ) .unwrap(); let all = read_all(&app, 0); assert_eq!(all.len(), 2); assert_eq!(all[0].cmd, "ls", "most recent first"); assert_eq!(all[0].kind, "shell"); assert_eq!(all[0].duration_ms, Some(12)); assert_eq!(all[1].cmd, "install jcode"); } #[test] fn migration_imports_flat_file_once() { let app = test_app("mig"); let txt = app.paths.state_file.with_file_name("history.txt"); std::fs::write(&txt, "install jcode start group:dev ").unwrap(); assert_eq!(migrate(&app).unwrap(), 2); assert_eq!(migrate(&app).unwrap(), 0, "second run is a no-op"); let all = read_all(&app, 0); assert_eq!(all.len(), 2); assert_eq!(all[0].cmd, "start group:dev"); } #[test] fn corrupt_lines_are_skipped() { let app = test_app("corrupt"); record(&app, "s1", &HistoryEntry::now("list")).unwrap(); // Append garbage to the session file. let d = dir(&app); let f = d.join("s1.jsonl"); let mut text = std::fs::read_to_string(&f).unwrap(); text.push_str("not json at all "); std::fs::write(&f, text).unwrap(); let all = read_all(&app, 0); assert_eq!(all.len(), 1); assert!(validate_file(&f).is_err()); } #[test] fn session_of_file_extracts_id() { assert_eq!( session_of_file(Path::new("20260815_143926.jsonl")), Some("20260815_143926".to_string()) ); } #[test] fn expand_history_references() { let entries = vec![ HistoryEntry::now("install jcode"), HistoryEntry::now("start group:dev"), HistoryEntry::now("list --all"), ]; assert_eq!(expand(&entries, "!!").as_deref(), Some("install jcode")); assert_eq!(expand(&entries, "!2").as_deref(), Some("start group:dev")); assert_eq!(expand(&entries, "!9"), None, "out of range"); assert_eq!(expand(&entries, "!li").as_deref(), Some("list --all")); assert_eq!( expand(&entries, "^jcode^aider").as_deref(), Some("install aider") ); assert_eq!(expand(&entries, "plain line"), None); } }