Files
agent-manager/src/commands/history_cmd.rs
T

184 lines
6.0 KiB
Rust

//! history: search and reuse the structured command history.
use super::*;
use crate::history::{self, HistoryEntry};
use crate::output::print_json;
use anyhow::{anyhow, Result};
use clap::Parser;
/// Re-run the Nth most recent entry: shell commands go through the
/// active shell, am commands are parsed and dispatched like the REPL does.
fn rerun_entry(app: &App, n: usize) -> Result<i32> {
if n == 0 {
return Err(anyhow!("invalid entry number 0 — am history numbers start at 1"));
}
let entries = history::read_all(app, n);
let entry = entries.get(n - 1).cloned().ok_or_else(|| {
anyhow!("history entry {n} not found — 'am history' lists them")
})?;
let question = format!("re-run: {} [y/N]", entry.cmd);
if !app.confirm(&question)? {
app.log.info("cancelled");
return Ok(0);
}
let cmd = entry.cmd.strip_prefix('!').unwrap_or(&entry.cmd);
if entry.kind == "shell" {
let session = crate::shell::ShellSession::start(&app.config);
return Ok(session.run(cmd)?);
}
let tokens = shell_words::split(cmd)
.map_err(|e| anyhow!("cannot parse '{cmd}': {e}"))?;
if tokens.is_empty() {
return Ok(0);
}
let cli = crate::cli::Cli::try_parse_from(std::iter::once("am".to_string()).chain(tokens))
.map_err(|e| anyhow!("cannot parse '{cmd}': {e}"))?;
match &cli.command {
Some(cmd) => execute_command(app, cmd),
None => {
app.log.info("nothing to re-run (no subcommand)");
Ok(0)
}
}
}
/// Filter history entries; returns them most recent first.
/// Parse "12..25" (inclusive bounds, 1-based) or a single "12".
fn parse_range(r: &str) -> Result<(usize, usize)> {
let r = r.trim();
if r.is_empty() {
anyhow::bail!("empty range — usage: am history <from>..<to> --save <file>");
}
if let Some((a, b)) = r.split_once("..") {
let from = a
.trim()
.parse::<usize>()
.map_err(|_| anyhow!("invalid range '{r}' — expected <from>..<to>"))?;
let to = b
.trim()
.parse::<usize>()
.map_err(|_| anyhow!("invalid range '{r}' — expected <from>..<to>"))?;
if from == 0 || to == 0 {
anyhow::bail!("history entries start at 1");
}
Ok((from, to))
} else {
let n = r
.parse::<usize>()
.map_err(|_| anyhow!("invalid range '{r}' — expected <from>..<to>"))?;
if n == 0 {
anyhow::bail!("history entries start at 1");
}
Ok((n, n))
}
}
/// 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,
rerun: Option<usize>,
range: Option<&str>,
save: Option<&Path>,
) -> Result<i32> {
history::migrate(app)?;
if let Some(n) = rerun {
return rerun_entry(app, n);
}
// Issue #51: 'am history 12..25 --save <file>' exports a playbook.
if let Some(r) = range {
let file = save.ok_or_else(|| anyhow!("--save <file> is required with a range"))?;
let (from, to) = parse_range(r)?;
let (n, path) = crate::playbook::export_range(app, from, to, file)?;
app.log
.success(&format!("playbook saved: {} ({n} step(s))", path.display()));
return Ok(0);
}
if save.is_some() {
anyhow::bail!("usage: am history <from>..<to> --save <file>");
}
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)
}