use clap::{Parser, Subcommand};
use rusqlite::Connection;
use crate::engine::CommandResult;
use crate::storage::BlackBox;
#[derive(Parser)]
#[command(name = "history", about = "Display or manage command history")]
struct HistoryArgs {
#[command(subcommand)]
command: Option<HistoryCommand>,
#[arg(short = 'n', long, default_value = "50")]
count: usize,
#[arg(short = 'd', long = "dirs")]
dirs: bool,
}
#[derive(Subcommand)]
enum HistoryCommand {
Clear,
}
pub(super) fn execute(args: &[&str]) -> CommandResult {
let parsed = match super::parse_args::<HistoryArgs>("history", args) {
Ok(a) => a,
Err(result) => return result,
};
match parsed.command {
Some(HistoryCommand::Clear) => clear_history(),
None => list_history(parsed.count, parsed.dirs),
}
}
fn list_history(count: usize, dirs: bool) -> CommandResult {
let conn = match open_history_db() {
Ok(c) => c,
Err(result) => return result,
};
let sql = if dirs {
"SELECT id, command, cwd FROM command_history ORDER BY id DESC LIMIT ?1"
} else {
"SELECT id, command FROM command_history ORDER BY id DESC LIMIT ?1"
};
let mut stmt = match conn.prepare(sql) {
Ok(s) => s,
Err(e) => {
let msg = format!("jarvish: history: failed to query: {e}\n");
eprint!("{msg}");
return CommandResult::error(msg, 1);
}
};
if dirs {
list_history_with_dirs(&mut stmt, count)
} else {
list_history_simple(&mut stmt, count)
}
}
fn list_history_simple(stmt: &mut rusqlite::Statement, count: usize) -> CommandResult {
let rows = match stmt.query_map(rusqlite::params![count as i64], |row| {
Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?))
}) {
Ok(r) => r,
Err(e) => {
let msg = format!("jarvish: history: failed to query: {e}\n");
eprint!("{msg}");
return CommandResult::error(msg, 1);
}
};
let mut entries: Vec<(i64, String)> = Vec::new();
for row in rows {
match row {
Ok(entry) => entries.push(entry),
Err(e) => {
let msg = format!("jarvish: history: failed to read row: {e}\n");
eprint!("{msg}");
return CommandResult::error(msg, 1);
}
}
}
entries.reverse();
let mut output = String::new();
for (id, command) in &entries {
let line = format!("{id:>6} {command}\n");
output.push_str(&line);
}
print!("{output}");
CommandResult::success(output)
}
fn list_history_with_dirs(stmt: &mut rusqlite::Statement, count: usize) -> CommandResult {
let rows = match stmt.query_map(rusqlite::params![count as i64], |row| {
Ok((
row.get::<_, i64>(0)?,
row.get::<_, String>(1)?,
row.get::<_, String>(2)?,
))
}) {
Ok(r) => r,
Err(e) => {
let msg = format!("jarvish: history: failed to query: {e}\n");
eprint!("{msg}");
return CommandResult::error(msg, 1);
}
};
let mut entries: Vec<(i64, String, String)> = Vec::new();
for row in rows {
match row {
Ok(entry) => entries.push(entry),
Err(e) => {
let msg = format!("jarvish: history: failed to read row: {e}\n");
eprint!("{msg}");
return CommandResult::error(msg, 1);
}
}
}
entries.reverse();
let mut output = String::new();
for (id, command, cwd) in &entries {
let line = format!("{id:>6} {cwd} {command}\n");
output.push_str(&line);
}
print!("{output}");
CommandResult::success(output)
}
fn clear_history() -> CommandResult {
let conn = match open_history_db() {
Ok(c) => c,
Err(result) => return result,
};
match conn.execute("DELETE FROM command_history", []) {
Ok(_) => {
let msg = "history cleared\n".to_string();
print!("{msg}");
CommandResult::success(msg)
}
Err(e) => {
let msg = format!("jarvish: history: failed to clear: {e}\n");
eprint!("{msg}");
CommandResult::error(msg, 1)
}
}
}
fn open_history_db() -> Result<Connection, CommandResult> {
let db_path = BlackBox::data_dir().join("history.db");
let conn = Connection::open(&db_path).map_err(|e| {
let msg = format!("jarvish: history: failed to open database: {e}\n");
eprint!("{msg}");
CommandResult::error(msg, 1)
})?;
let _ = conn.execute_batch("PRAGMA journal_mode=WAL;");
Ok(conn)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::storage::BlackBox;
use tempfile::TempDir;
fn setup_test_db(commands: &[&str]) -> TempDir {
let tmp = TempDir::new().unwrap();
let bb = BlackBox::open_at(tmp.path().to_path_buf(), 1).unwrap();
for cmd in commands {
let result = crate::engine::CommandResult {
stdout: String::new(),
stderr: String::new(),
exit_code: 0,
action: crate::engine::LoopAction::Continue,
used_alt_screen: false,
};
bb.record(cmd, &result).unwrap();
}
tmp
}
#[test]
fn history_list_shows_entries() {
let tmp = setup_test_db(&["echo hello", "ls -la", "git status"]);
let db_path = tmp.path().join("history.db");
let conn = Connection::open(&db_path).unwrap();
let mut stmt = conn
.prepare("SELECT id, command FROM command_history ORDER BY id DESC LIMIT 50")
.unwrap();
let rows: Vec<(i64, String)> = stmt
.query_map([], |row| Ok((row.get(0)?, row.get(1)?)))
.unwrap()
.filter_map(|r| r.ok())
.collect();
assert_eq!(rows.len(), 3);
assert_eq!(rows[0].1, "git status");
assert_eq!(rows[1].1, "ls -la");
assert_eq!(rows[2].1, "echo hello");
}
#[test]
fn history_clear_removes_all() {
let tmp = setup_test_db(&["cmd1", "cmd2", "cmd3"]);
let db_path = tmp.path().join("history.db");
let conn = Connection::open(&db_path).unwrap();
let count: i64 = conn
.query_row("SELECT COUNT(*) FROM command_history", [], |row| row.get(0))
.unwrap();
assert_eq!(count, 3);
conn.execute("DELETE FROM command_history", []).unwrap();
let count: i64 = conn
.query_row("SELECT COUNT(*) FROM command_history", [], |row| row.get(0))
.unwrap();
assert_eq!(count, 0);
}
#[test]
fn history_help_returns_success() {
let result = execute(&["--help"]);
assert_eq!(result.exit_code, 0);
assert!(result.stdout.contains("history"));
}
#[test]
fn history_clap_parses_count() {
let args = HistoryArgs::try_parse_from(["history", "-n", "100"]).unwrap();
assert_eq!(args.count, 100);
assert!(!args.dirs);
assert!(args.command.is_none());
}
#[test]
fn history_clap_parses_dirs() {
let args = HistoryArgs::try_parse_from(["history", "--dirs"]).unwrap();
assert!(args.dirs);
assert_eq!(args.count, 50);
let args = HistoryArgs::try_parse_from(["history", "-d", "-n", "20"]).unwrap();
assert!(args.dirs);
assert_eq!(args.count, 20);
}
#[test]
fn history_clap_parses_clear() {
let args = HistoryArgs::try_parse_from(["history", "clear"]).unwrap();
assert!(matches!(args.command, Some(HistoryCommand::Clear)));
}
}