184 lines
6.0 KiB
Rust
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)
|
|
}
|