a tiny, local-first shell history manager with fuzzy search.
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494use std::io::Read;use std::path::{Path, PathBuf};
use rusqlite::Connection;
#[derive(Copy, Clone, PartialEq, Eq, Debug, clap::ValueEnum)]pub enum Shell { Auto, Bash, Fish, Zsh,}
#[derive(Debug, PartialEq, Eq)]pub struct Record { pub cmd: String, /// Unix seconds. pub ts: i64,}
/// Picks the history file to read when `--file` was not given.pub fn default_path(shell: Shell) -> PathBuf { let home = PathBuf::from(std::env::var_os("HOME").unwrap_or_default());
match shell { Shell::Fish => match std::env::var_os("XDG_DATA_HOME") { Some(d) if !d.is_empty() => PathBuf::from(d).join("fish/fish_history"), _ => home.join(".local/share/fish/fish_history"), }, // Zsh also honours $HISTFILE, but falls back to its own file. Shell::Zsh => match std::env::var_os("HISTFILE") { Some(f) if !f.is_empty() => PathBuf::from(f), _ => home.join(".zsh_history"), }, // Bash, and Auto that did not resolve to fish or zsh. _ => match std::env::var_os("HISTFILE") { Some(f) if !f.is_empty() => PathBuf::from(f), _ => home.join(".bash_history"), }, }}
/// Resolves `Shell::Auto` from `$SHELL`; other variants pass through.pub fn shell_from_env(shell: Shell) -> Shell { if shell != Shell::Auto { return shell; }
let sh = std::env::var("SHELL").unwrap_or_default(); match Path::new(&sh).file_name().and_then(|s| s.to_str()) { Some(name) if name.contains("fish") => Shell::Fish, Some(name) if name.contains("zsh") => Shell::Zsh, _ => Shell::Bash, }}
/// Resolves `Shell::Auto` by looking at a file's first non-empty line.pub fn sniff(text: &str) -> Shell { for line in text.lines() { if line.trim().is_empty() { continue; } return if line.starts_with("- cmd:") { Shell::Fish } else if split_zsh_meta(line).is_some() { Shell::Zsh } else { Shell::Bash }; }
Shell::Bash}
/// Reads a history file, tolerating invalid UTF-8; errors name the path.pub fn read_lossy(path: &Path) -> std::io::Result<String> { let annotate = |e: std::io::Error| std::io::Error::new(e.kind(), format!("{}: {e}", path.display()));
let mut buf = Vec::new(); std::fs::File::open(path) .map_err(annotate)? .read_to_end(&mut buf) .map_err(annotate)?;
Ok(String::from_utf8_lossy(&buf).into_owned())}
/// Parses bash history; `#<epoch>` lines set the following command's timestamp.pub fn parse_bash(input: &str, now: i64) -> Vec<Record> { let mut out = Vec::new(); let mut ts = now;
for line in input.lines() { if let Some(rest) = line.strip_prefix('#') && let Ok(parsed) = rest.parse::<i64>() { ts = parsed; continue; }
let cmd = line.trim_end_matches([' ', '\t']); if cmd.trim().is_empty() { continue; }
out.push(Record { cmd: cmd.to_string(), ts, }); }
out}
/// Parses fish's `fish_history` format.pub fn parse_fish(input: &str, now: i64) -> Vec<Record> { let mut out: Vec<Record> = Vec::new();
for line in input.lines() { if let Some(rest) = line.strip_prefix("- cmd:") { let cmd = unescape_fish(rest.strip_prefix(' ').unwrap_or(rest)); let cmd = cmd.trim_end_matches([' ', '\t', '\n']); if cmd.trim().is_empty() { continue; }
out.push(Record { cmd: cmd.to_string(), ts: now, }); } else if let Some(rest) = line.strip_prefix(" when:") && let Ok(ts) = rest.trim().parse::<i64>() && let Some(last) = out.last_mut() { last.ts = ts; } }
out}
/// Splits an `EXTENDED_HISTORY` metadata prefix (`: <ts>:<elapsed>;<cmd>`)/// into its timestamp and command, if the line carries one.fn split_zsh_meta(line: &str) -> Option<(i64, &str)> { let rest = line.strip_prefix(": ")?; let (head, cmd) = rest.split_once(';')?; let (ts, _elapsed) = head.split_once(':')?; let ts = ts.trim().parse().ok()?; Some((ts, cmd))}
/// Parses zsh history. Entries may carry an `EXTENDED_HISTORY` metadata/// prefix; multi-line commands are stored with a trailing backslash/// escaping the embedded newline, which is restored on read.pub fn parse_zsh(input: &str, now: i64) -> Vec<Record> { let mut out = Vec::new(); let mut ts = now; let mut entry = String::new(); let mut continued = false;
for line in input.lines() { if !continued { entry.clear(); ts = now; match split_zsh_meta(line) { Some((parsed, cmd)) => { ts = parsed; entry.push_str(cmd); } None => entry.push_str(line), } } else { entry.push('\n'); entry.push_str(line); }
if line.ends_with('\\') { // drop the escape backslash; the newline stays part of the command entry.pop(); continued = true; continue; }
continued = false; let cmd = entry.trim_end_matches([' ', '\t']); if !cmd.trim().is_empty() { out.push(Record { cmd: cmd.to_string(), ts, }); } }
// file ended mid-continuation if continued { let cmd = entry.trim_end_matches([' ', '\t']); if !cmd.trim().is_empty() { out.push(Record { cmd: cmd.to_string(), ts, }); } }
out}
/// Decodes fish escaping (`\\` → `\`, `\n` → newline) in one left-to-right pass.fn unescape_fish(s: &str) -> String { let mut out = String::with_capacity(s.len()); let mut chars = s.chars();
while let Some(c) = chars.next() { if c != '\\' { out.push(c); continue; }
match chars.next() { Some('\\') => out.push('\\'), Some('n') => out.push('\n'), Some(other) => { out.push('\\'); out.push(other); } None => out.push('\\'), } }
out}
/// Inserts records in one transaction, skipping consecutive duplicates.pub fn insert_all(db: &mut Connection, records: &[Record]) -> rusqlite::Result<usize> { let tx = db.transaction()?; let mut n = 0;
{ let mut stmt = tx.prepare( "insert into history(cmd, cwd, exit, ts, session) values(?1, '', 0, ?2, '') on conflict(cmd) do update set ts = max(ts, excluded.ts)", )?;
let mut last: Option<&str> = None; for r in records { if last == Some(r.cmd.as_str()) { continue; }
stmt.execute(rusqlite::params![r.cmd, r.ts])?; last = Some(r.cmd.as_str()); n += 1; } }
tx.commit()?; Ok(n)}
/// Reads `path` and returns its records, detecting the format when needed.pub fn parse_file(path: &Path, shell: Shell, now: i64) -> std::io::Result<(Shell, Vec<Record>)> { let text = read_lossy(path)?; let shell = if shell == Shell::Auto { sniff(&text) } else { shell };
let records = match shell { Shell::Fish => parse_fish(&text, now), Shell::Zsh => parse_zsh(&text, now), _ => parse_bash(&text, now), };
Ok((shell, records))}
#[cfg(test)]mod tests { use super::*;
const NOW: i64 = 1_700_000_000;
#[test] fn unescape_single_pass() { assert_eq!(unescape_fish(r"plain"), "plain"); assert_eq!(unescape_fish(r"a\nb"), "a\nb"); assert_eq!(unescape_fish(r"a\\b"), r"a\b"); assert_eq!(unescape_fish(r"curl x \\\n -H y"), "curl x \\\n -H y"); assert_eq!(unescape_fish(r"grep \t"), r"grep \t"); assert_eq!(unescape_fish(r"trailing\"), r"trailing\"); }
#[test] fn fish_entry_with_paths_block() { let input = "\- cmd: hx dev when: 1787234415 paths: - dev- cmd: cd when: 1787234416"; assert_eq!( parse_fish(input, NOW), vec![ Record { cmd: "hx dev".into(), ts: 1787234415 }, Record { cmd: "cd".into(), ts: 1787234416 }, ] ); }
#[test] fn fish_entry_without_when_falls_back_to_now() { let records = parse_fish("- cmd: ls\n", NOW); assert_eq!( records, vec![Record { cmd: "ls".into(), ts: NOW }] ); }
#[test] fn fish_escaped_command_round_trips() { let input = r"- cmd: xattr -rd com.apple.quarantine Parallels\\ Desktop.dmg".to_string() + "\n when: 100\n"; let records = parse_fish(&input, NOW); assert_eq!( records, vec![Record { cmd: r"xattr -rd com.apple.quarantine Parallels\ Desktop.dmg".into(), ts: 100 }] ); }
#[test] fn fish_skips_blank_commands() { assert_eq!(parse_fish("- cmd: \n when: 5\n", NOW), vec![]); }
#[test] fn bash_epoch_comments_set_timestamps() { let input = "#1700000001\nls -la\n#1700000002\ncd /tmp\n"; assert_eq!( parse_bash(input, NOW), vec![ Record { cmd: "ls -la".into(), ts: 1700000001 }, Record { cmd: "cd /tmp".into(), ts: 1700000002 }, ] ); }
#[test] fn bash_without_timestamps_uses_now_and_keeps_comments() { // a `#` line that is not an epoch is a real command, not a timestamp. let records = parse_bash("ls\n# a comment\n", NOW); assert_eq!( records, vec![ Record { cmd: "ls".into(), ts: NOW }, Record { cmd: "# a comment".into(), ts: NOW }, ] ); }
#[test] fn zsh_extended_history_metadata_sets_timestamps() { let input = ": 1700000001:0;ls -la\n: 1700000002:5;cd /tmp\n"; assert_eq!( parse_zsh(input, NOW), vec![ Record { cmd: "ls -la".into(), ts: 1700000001 }, Record { cmd: "cd /tmp".into(), ts: 1700000002 }, ] ); }
#[test] fn zsh_without_metadata_uses_now() { let records = parse_zsh("ls -la\ncd /tmp\n", NOW); assert_eq!( records, vec![ Record { cmd: "ls -la".into(), ts: NOW }, Record { cmd: "cd /tmp".into(), ts: NOW }, ] ); }
#[test] fn zsh_backslash_newline_becomes_a_real_newline() { // matches zsh's own reader: one trailing backslash is dropped, // the newline stays part of the command. let input = "for i in 1 2\\\ndo\\\necho $i\\\ndone\n"; assert_eq!( parse_zsh(input, NOW), vec![Record { cmd: "for i in 1 2\ndo\necho $i\ndone".into(), ts: NOW }] ); }
#[test] fn zsh_escaped_backslash_survives_the_continuation() { // a command genuinely ending in `\` keeps one backslash // plus the newline, as zsh itself reads it. let input = "echo a\\\\\nb\n"; // file bytes: `echo a\\` NL `b` NL assert_eq!( parse_zsh(input, NOW), vec![Record { cmd: "echo a\\\nb".into(), ts: NOW }] ); }
#[test] fn zsh_metadata_applies_to_the_whole_multiline_entry() { let input = ": 1700000000:3;for i in 1 2\\\ndo\\\ndone\nls\n"; let records = parse_zsh(input, NOW); assert_eq!(records.len(), 2); assert_eq!(records[0].ts, 1700000000); assert_eq!(records[0].cmd, "for i in 1 2\ndo\ndone"); assert_eq!(records[1].ts, NOW, "second entry falls back to now"); }
#[test] fn zsh_commands_that_merely_start_with_a_colon_are_kept() { // `: ` without a parseable `<ts>:<elapsed>;` head is a real command. let input = ":(){ :|:& };:\n: 1700000001:0;ls\n"; let records = parse_zsh(input, NOW); assert_eq!(records.len(), 2); assert_eq!(records[0].cmd, ":(){ :|:& };:"); assert_eq!(records[0].ts, NOW); assert_eq!(records[1].ts, 1700000001); }
#[test] fn zsh_trailing_continuation_is_still_recorded() { let records = parse_zsh("echo a\\", NOW); // no final newline assert_eq!(records.len(), 1); assert_eq!(records[0].cmd, "echo a"); }
#[test] fn zsh_skips_blank_commands() { assert_eq!(parse_zsh("\n \n: 1700000001:0; \n", NOW), vec![]); }
#[test] fn sniffing_distinguishes_the_formats() { assert_eq!(sniff("- cmd: ls\n when: 1\n"), Shell::Fish); assert_eq!(sniff("\n\n- cmd: ls\n"), Shell::Fish); assert_eq!(sniff(": 1700000001:0;ls\n"), Shell::Zsh); assert_eq!(sniff("#1700000001\nls\n"), Shell::Bash); assert_eq!(sniff(""), Shell::Bash); }}