use 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 { 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; `#` lines set the following command's timestamp. pub fn parse_bash(input: &str, now: i64) -> Vec { 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::() { 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 { let mut out: Vec = 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::() && let Some(last) = out.last_mut() { last.ts = ts; } } out } /// Splits an `EXTENDED_HISTORY` metadata prefix (`: :;`) /// 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 { 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 { 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)> { 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 `:;` 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); } }