333 lines
10 KiB
Rust
333 lines
10 KiB
Rust
//! Structured command history: one JSONL file per REPL session under
|
|
//! history/<session>.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<String>,
|
|
#[serde(default)]
|
|
pub exit_code: Option<i32>,
|
|
#[serde(default)]
|
|
pub duration_ms: Option<u64>,
|
|
#[serde(default)]
|
|
pub agent: Option<String>,
|
|
}
|
|
|
|
fn kind_am() -> String {
|
|
"am".to_string()
|
|
}
|
|
|
|
impl HistoryEntry {
|
|
pub fn now(cmd: impl Into<String>) -> 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<String>) -> Self {
|
|
self.kind = kind.into();
|
|
self
|
|
}
|
|
|
|
pub fn with_cwd(mut self, cwd: Option<String>) -> Self {
|
|
self.cwd = cwd;
|
|
self
|
|
}
|
|
|
|
pub fn with_exit_code(mut self, code: Option<i32>) -> 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<String>) -> 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<usize> {
|
|
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<String> = 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<String> {
|
|
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::<usize>() {
|
|
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<PathBuf> {
|
|
let mut out: Vec<PathBuf> = 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<HistoryEntry> {
|
|
let d = dir(app);
|
|
let mut out: Vec<HistoryEntry> = Vec::new();
|
|
for f in files(&d).iter().rev() {
|
|
if let Ok(text) = std::fs::read_to_string(f) {
|
|
let mut entries: Vec<HistoryEntry> = text
|
|
.lines()
|
|
.filter(|l| !l.trim().is_empty())
|
|
.filter_map(|l| serde_json::from_str::<HistoryEntry>(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
|
|
}
|
|
|
|
/// Parse the session id from a history file name ("20260815_143926.jsonl").
|
|
pub fn session_of_file(path: &Path) -> Option<String> {
|
|
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::<HistoryEntry>(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);
|
|
}
|
|
}
|