//! 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 { 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 .. --save "); } if let Some((a, b)) = r.split_once("..") { let from = a .trim() .parse::() .map_err(|_| anyhow!("invalid range '{r}' — expected .."))?; let to = b .trim() .parse::() .map_err(|_| anyhow!("invalid range '{r}' — expected .."))?; if from == 0 || to == 0 { anyhow::bail!("history entries start at 1"); } Ok((from, to)) } else { let n = r .parse::() .map_err(|_| anyhow!("invalid range '{r}' — expected .."))?; 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 = text .lines() .filter(|l| !l.trim().is_empty()) .filter_map(|l| serde_json::from_str::(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, range: Option<&str>, save: Option<&Path>, ) -> Result { history::migrate(app)?; if let Some(n) = rerun { return rerun_entry(app, n); } // Issue #51: 'am history 12..25 --save ' exports a playbook. if let Some(r) = range { let file = save.ok_or_else(|| anyhow!("--save 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 .. --save "); } let rows = filter(app, kind, cwd, search, failed, session, limit); if app.json() { let payload: Vec = 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) }