history : historique structuré + am history (issue #7) #22
+21
@@ -169,6 +169,27 @@ pub enum Command {
|
||||
#[arg(long, value_name = "CATEGORY")]
|
||||
category: Option<String>,
|
||||
},
|
||||
/// Search the command history (am and shell commands)
|
||||
History {
|
||||
/// Only commands of this kind (am or shell)
|
||||
#[arg(long, value_name = "KIND")]
|
||||
kind: Option<String>,
|
||||
/// Only commands run in this directory
|
||||
#[arg(long, value_name = "DIR")]
|
||||
cwd: Option<String>,
|
||||
/// Full-text search in the command line
|
||||
#[arg(long, value_name = "TEXT")]
|
||||
search: Option<String>,
|
||||
/// Only failed commands
|
||||
#[arg(long)]
|
||||
failed: bool,
|
||||
/// Only commands of this session
|
||||
#[arg(long, value_name = "ID")]
|
||||
session: Option<String>,
|
||||
/// Maximum number of entries (default: 100)
|
||||
#[arg(long, value_name = "N", default_value = "100")]
|
||||
limit: usize,
|
||||
},
|
||||
/// Show detailed information about one agent
|
||||
Info {
|
||||
/// Agent name or alias
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
//! history: search and reuse the structured command history.
|
||||
|
||||
use super::*;
|
||||
use crate::history::{self, HistoryEntry};
|
||||
use crate::output::print_json;
|
||||
|
||||
/// Filter history entries; returns them most recent first.
|
||||
pub fn filter(
|
||||
app: &App,
|
||||
kind: Option<&str>,
|
||||
cwd: Option<&str>,
|
||||
search: Option<&str>,
|
||||
failed: bool,
|
||||
session: Option<&str>,
|
||||
limit: usize,
|
||||
) -> Vec<(String, HistoryEntry)> {
|
||||
let _ = session; // session filtering happens by reading one file directly
|
||||
let mut out: Vec<(String, HistoryEntry)> = Vec::new();
|
||||
let dir = history::dir(app);
|
||||
for f in history::files(&dir).iter().rev() {
|
||||
let sid = history::session_of_file(f).unwrap_or_default();
|
||||
if let Some(s) = session {
|
||||
if sid != s {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
let Ok(text) = std::fs::read_to_string(f) else {
|
||||
continue;
|
||||
};
|
||||
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();
|
||||
for e in entries {
|
||||
if kind.map_or(true, |k| e.kind == k)
|
||||
&& cwd.map_or(true, |c| e.cwd.as_deref() == Some(c))
|
||||
&& search.map_or(true, |s| e.cmd.to_lowercase().contains(&s.to_lowercase()))
|
||||
&& (!failed || e.exit_code.is_some())
|
||||
{
|
||||
out.push((sid.clone(), e));
|
||||
if limit > 0 && out.len() >= limit {
|
||||
return out;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
pub fn run(
|
||||
app: &App,
|
||||
kind: Option<&str>,
|
||||
cwd: Option<&str>,
|
||||
search: Option<&str>,
|
||||
failed: bool,
|
||||
session: Option<&str>,
|
||||
limit: usize,
|
||||
) -> Result<i32> {
|
||||
history::migrate(app)?;
|
||||
let rows = filter(app, kind, cwd, search, failed, session, limit);
|
||||
if app.json() {
|
||||
let payload: Vec<serde_json::Value> = rows
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, (sid, e))| {
|
||||
serde_json::json!({
|
||||
"n": rows.len() - i,
|
||||
"ts": e.ts,
|
||||
"cmd": e.cmd,
|
||||
"kind": e.kind,
|
||||
"cwd": e.cwd,
|
||||
"exit_code": e.exit_code,
|
||||
"duration_ms": e.duration_ms,
|
||||
"session": sid,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
print_json(&payload);
|
||||
return Ok(0);
|
||||
}
|
||||
for (i, (_sid, e)) in rows.iter().enumerate() {
|
||||
let n = rows.len() - i;
|
||||
let ts = e.ts.get(0..16).unwrap_or(&e.ts);
|
||||
let flag = match e.exit_code {
|
||||
Some(_) => "✗".to_string(),
|
||||
None => " ".to_string(),
|
||||
};
|
||||
println!("{n:>4} {flag} {ts} {}", e.cmd);
|
||||
}
|
||||
if rows.is_empty() {
|
||||
app.log.info("no matching history — run 'am' and type some commands");
|
||||
}
|
||||
Ok(0)
|
||||
}
|
||||
@@ -5,6 +5,7 @@ pub mod config_cmd;
|
||||
pub mod doctor_cmd;
|
||||
pub mod export_import;
|
||||
pub mod help_cmd;
|
||||
pub mod history_cmd;
|
||||
pub mod info_cmd;
|
||||
pub mod install_cmd;
|
||||
pub mod list_cmd;
|
||||
@@ -78,6 +79,22 @@ pub fn execute_command(app: &App, cmd: &Command) -> Result<i32> {
|
||||
Command::Uninstall { agent, purge } => uninstall_cmd::run(app, agent, *purge),
|
||||
Command::Update { agent, all } => update_cmd::run(app, agent.as_deref(), *all),
|
||||
Command::Search { keyword, category } => search_cmd::run(app, keyword, category.as_deref()),
|
||||
Command::History {
|
||||
kind,
|
||||
cwd,
|
||||
search,
|
||||
failed,
|
||||
session,
|
||||
limit,
|
||||
} => history_cmd::run(
|
||||
app,
|
||||
kind.as_deref(),
|
||||
cwd.as_deref(),
|
||||
search.as_deref(),
|
||||
*failed,
|
||||
session.as_deref(),
|
||||
*limit,
|
||||
),
|
||||
Command::Info { agent } => info_cmd::run(app, agent),
|
||||
Command::Help { command, find } => help_cmd::run(app, command.as_deref(), find.as_deref()),
|
||||
Command::Version => version_cmd::run(app),
|
||||
|
||||
+22
@@ -238,6 +238,28 @@ pub static HELP_SPECS: &[HelpSpec] = &[
|
||||
HelpExample { desc: "Starts of one agent since a date.", code: "log claude-code --kind start --since 2026-08-01" },
|
||||
],
|
||||
},
|
||||
HelpSpec {
|
||||
name: "history",
|
||||
category: "Commands",
|
||||
usage: "history {flags}",
|
||||
about: "Search the command history (am and shell commands).",
|
||||
search_terms: &["past", "commands", "rerun"],
|
||||
flags: &[
|
||||
HelpFlag { short: "", long: "--kind", value: "KIND", desc: "Only commands of this kind (am or shell)" },
|
||||
HelpFlag { short: "", long: "--cwd", value: "DIR", desc: "Only commands run in this directory" },
|
||||
HelpFlag { short: "", long: "--search", value: "TEXT", desc: "Full-text search in the command line" },
|
||||
HelpFlag { short: "", long: "--failed", value: "", desc: "Only failed commands" },
|
||||
HelpFlag { short: "", long: "--session", value: "ID", desc: "Only commands of this session" },
|
||||
],
|
||||
subcommands: &[],
|
||||
parameters: &[],
|
||||
io: None,
|
||||
examples: &[
|
||||
HelpExample { desc: "The last 100 commands.", code: "history" },
|
||||
HelpExample { desc: "Find past installs.", code: "history --search install" },
|
||||
HelpExample { desc: "Failed commands only.", code: "history --failed" },
|
||||
],
|
||||
},
|
||||
HelpSpec {
|
||||
name: "search",
|
||||
category: "Commands",
|
||||
|
||||
+274
@@ -0,0 +1,274 @@
|
||||
//! 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;
|
||||
for line in text.lines() {
|
||||
let trimmed = line.trim();
|
||||
if trimmed.is_empty() || trimmed.starts_with('#') {
|
||||
continue;
|
||||
}
|
||||
let entry = HistoryEntry::now(trimmed.to_string())
|
||||
.with_kind("am".to_string())
|
||||
.with_cwd(None);
|
||||
record(app, "migrated", &entry)?;
|
||||
count += 1;
|
||||
}
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
/// 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())
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,7 @@ pub mod doctor;
|
||||
pub mod download;
|
||||
pub mod events;
|
||||
pub mod help;
|
||||
pub mod history;
|
||||
pub mod installers;
|
||||
pub mod nav;
|
||||
pub mod output;
|
||||
|
||||
+40
-5
@@ -63,6 +63,7 @@ const COMMAND_DESCRIPTIONS: &[(&str, &str)] = &[
|
||||
("sessions", "list agent sessions"),
|
||||
("stats", "usage statistics"),
|
||||
("log", "read the event journal"),
|
||||
("history", "search command history"),
|
||||
("search", "search the catalog"),
|
||||
("info", "agent details"),
|
||||
("install", "install an agent"),
|
||||
@@ -139,7 +140,7 @@ impl AmCompleter {
|
||||
Self {
|
||||
theme: std::cell::Cell::new(crate::theme::default_theme()),
|
||||
commands: vec![
|
||||
"list", "status", "sessions", "stats", "log", "search", "info", "install", "uninstall", "update",
|
||||
"list", "status", "sessions", "stats", "log", "history", "search", "info", "install", "uninstall", "update",
|
||||
"start", "stop", "restart", "run", "doctor", "config", "completion",
|
||||
"self-update", "self-uninstall", "export", "import", "shell", "theme",
|
||||
"ls", "dir", "cd", "ps", "where", "get", "help", "version", "exit",
|
||||
@@ -894,21 +895,24 @@ pub fn run(app: &App) -> Result<i32> {
|
||||
.with_cwd(Some(current_dir_string())),
|
||||
);
|
||||
let _ = crate::sessions::start_repl(app, &sid, Some(current_dir_string()));
|
||||
if let Err(e) = crate::history::migrate(app) {
|
||||
app.log.verbose(&format!("history migration skipped: {e:#}"));
|
||||
}
|
||||
print!("{}", banner(app, &session, &sid));
|
||||
let result = match run_with_editor(app, &mut session) {
|
||||
let result = match run_with_editor(app, &mut session, &sid) {
|
||||
Ok(code) => Ok(code),
|
||||
Err(e) => {
|
||||
app.log.verbose(&format!(
|
||||
"line editor unavailable ({e:#}); falling back to plain input"
|
||||
));
|
||||
run_plain(app, &mut session)
|
||||
run_plain(app, &mut session, &sid)
|
||||
}
|
||||
};
|
||||
let _ = crate::sessions::finish_repl(app, &sid);
|
||||
result
|
||||
}
|
||||
|
||||
fn run_with_editor(app: &App, session: &mut ShellSession) -> Result<i32> {
|
||||
fn run_with_editor(app: &App, session: &mut ShellSession, sid: &str) -> Result<i32> {
|
||||
let mut rl: rustyline::Editor<AmCompleter, rustyline::history::FileHistory> =
|
||||
rustyline::Editor::new()?;
|
||||
rl.set_helper(Some(AmCompleter::new(app)));
|
||||
@@ -936,6 +940,7 @@ fn run_with_editor(app: &App, session: &mut ShellSession) -> Result<i32> {
|
||||
let t = Instant::now();
|
||||
let result = handle_input(app, session, &mut last, &trimmed);
|
||||
last_duration = Some(t.elapsed());
|
||||
record_history(app, sid, &trimmed, &result, t.elapsed());
|
||||
// 'theme <name>' may have switched the app theme: repoint the
|
||||
// completion menu at the new palette.
|
||||
if let Some(h) = rl.helper_mut() {
|
||||
@@ -959,8 +964,33 @@ fn run_with_editor(app: &App, session: &mut ShellSession) -> Result<i32> {
|
||||
Ok(0)
|
||||
}
|
||||
|
||||
/// Append one structured history entry for a REPL command.
|
||||
fn record_history(
|
||||
app: &App,
|
||||
sid: &str,
|
||||
line: &str,
|
||||
result: &Result<bool>,
|
||||
elapsed: std::time::Duration,
|
||||
) {
|
||||
let kind = if is_am_command(line.split_whitespace().next().unwrap_or("")) {
|
||||
"am"
|
||||
} else {
|
||||
"shell"
|
||||
};
|
||||
let exit_code = if result.is_err() { Some(1) } else { None };
|
||||
let _ = crate::history::record(
|
||||
app,
|
||||
sid,
|
||||
&crate::history::HistoryEntry::now(line.to_string())
|
||||
.with_kind(kind.to_string())
|
||||
.with_cwd(Some(current_dir_string()))
|
||||
.with_exit_code(exit_code)
|
||||
.with_duration_ms(elapsed.as_millis() as u64),
|
||||
);
|
||||
}
|
||||
|
||||
/// Plain fallback when no terminal/line editor is available.
|
||||
fn run_plain(app: &App, session: &mut ShellSession) -> Result<i32> {
|
||||
fn run_plain(app: &App, session: &mut ShellSession, sid: &str) -> Result<i32> {
|
||||
let started = Instant::now();
|
||||
let mut last: Option<DataTable> = None;
|
||||
let mut last_duration: Option<Duration> = None;
|
||||
@@ -979,6 +1009,7 @@ fn run_plain(app: &App, session: &mut ShellSession) -> Result<i32> {
|
||||
let t = Instant::now();
|
||||
let result = handle_input(app, session, &mut last, &line);
|
||||
last_duration = Some(t.elapsed());
|
||||
record_history(app, sid, &line, &result, t.elapsed());
|
||||
match result {
|
||||
Ok(true) => break,
|
||||
Ok(false) => {}
|
||||
@@ -1273,6 +1304,10 @@ fn is_am_command(word: &str) -> bool {
|
||||
word,
|
||||
"list"
|
||||
| "status"
|
||||
| "sessions"
|
||||
| "stats"
|
||||
| "log"
|
||||
| "history"
|
||||
| "search"
|
||||
| "info"
|
||||
| "install"
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
//! Structured history: record, read, filter, migration, command.
|
||||
|
||||
mod common;
|
||||
|
||||
use agent_manager::commands::history_cmd;
|
||||
use agent_manager::history::{self, HistoryEntry};
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[test]
|
||||
fn cli_parses_history() {
|
||||
use agent_manager::cli::{Cli, Command};
|
||||
use clap::Parser;
|
||||
let cli = Cli::parse_from(["am", "history", "--failed", "--search", "install"]);
|
||||
match &cli.command {
|
||||
Some(Command::History {
|
||||
failed, search, ..
|
||||
}) => {
|
||||
assert!(*failed);
|
||||
assert_eq!(search.as_deref(), Some("install"));
|
||||
}
|
||||
other => panic!("unexpected command: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn record_and_read_most_recent_first() {
|
||||
let app = common::test_app("hist", &["history"]);
|
||||
history::record(&app, "s1", &HistoryEntry::now("install jcode")).unwrap();
|
||||
history::record(
|
||||
&app,
|
||||
"s1",
|
||||
&HistoryEntry::now("ls")
|
||||
.with_kind("shell".to_string())
|
||||
.with_duration_ms(12),
|
||||
)
|
||||
.unwrap();
|
||||
let all = history::read_all(&app, 0);
|
||||
assert_eq!(all.len(), 2);
|
||||
assert_eq!(all[0].cmd, "ls");
|
||||
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_history_once() {
|
||||
let app = common::test_app("mig", &["history"]);
|
||||
let txt = app.paths.state_file.with_file_name("history.txt");
|
||||
std::fs::write(&txt, "install jcode
|
||||
start group:dev
|
||||
|
||||
").unwrap();
|
||||
assert_eq!(history::migrate(&app).unwrap(), 2);
|
||||
assert_eq!(history::migrate(&app).unwrap(), 0, "no-op on second run");
|
||||
let all = history::read_all(&app, 0);
|
||||
assert_eq!(all.len(), 2);
|
||||
assert_eq!(all[0].cmd, "start group:dev");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filters_by_kind_search_and_failed() {
|
||||
let app = common::test_app("filt", &["history"]);
|
||||
history::record(
|
||||
&app,
|
||||
"s1",
|
||||
&HistoryEntry::now("install jcode")
|
||||
.with_kind("am".to_string())
|
||||
.with_exit_code(Some(1)),
|
||||
)
|
||||
.unwrap();
|
||||
history::record(
|
||||
&app,
|
||||
"s1",
|
||||
&HistoryEntry::now("ls -la")
|
||||
.with_kind("shell".to_string())
|
||||
.with_cwd(Some("/tmp".to_string())),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(history_cmd::filter(&app, Some("shell"), None, None, false, None, 10).len(), 1);
|
||||
assert_eq!(history_cmd::filter(&app, None, None, Some("install"), false, None, 10).len(), 1);
|
||||
assert_eq!(history_cmd::filter(&app, None, None, None, true, None, 10).len(), 1);
|
||||
assert_eq!(history_cmd::filter(&app, None, Some("/tmp"), None, false, None, 10).len(), 1);
|
||||
assert_eq!(history_cmd::filter(&app, None, None, Some("zzz"), false, None, 10).len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_runs_and_migrates() {
|
||||
let app = common::test_app("cmd", &["history"]);
|
||||
let txt = app.paths.state_file.with_file_name("history.txt");
|
||||
std::fs::write(&txt, "install jcode
|
||||
").unwrap();
|
||||
let code = history_cmd::run(&app, None, None, None, false, None, 100).unwrap();
|
||||
assert_eq!(code, 0);
|
||||
// Migration created the structured file.
|
||||
assert!(history::dir(&app).join("migrated.jsonl").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fixture_session_file_parses() {
|
||||
// Guard for session_of_file used by the display layer.
|
||||
assert_eq!(
|
||||
history::session_of_file(&PathBuf::from("20260815_143926.jsonl")),
|
||||
Some("20260815_143926".to_string())
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user