use crate::models::HistoryEntry;
use crate::parsers::HistoryParser;
#[derive(Debug, Default, Clone, Copy)]
pub struct FishHistoryParser;
impl FishHistoryParser {
pub const fn new() -> Self {
Self
}
}
impl HistoryParser for FishHistoryParser {
fn parse(&self, input: &str) -> Vec<HistoryEntry> {
let mut entries = Vec::new();
let mut current_cmd: Option<String> = None;
let mut current_ts: Option<u64> = None;
for line in input.lines() {
let trimmed = line.trim();
if let Some(rest) = trimmed
.strip_prefix("- cmd:")
.or_else(|| trimmed.strip_prefix("cmd:"))
{
flush(&mut entries, current_cmd.take(), current_ts.take());
let cmd = rest.trim().to_string();
if !cmd.is_empty() {
current_cmd = Some(cmd);
}
} else if let Some(rest) = trimmed.strip_prefix("when:")
&& let Ok(t) = rest.trim().parse::<u64>()
{
current_ts = Some(t);
}
}
flush(&mut entries, current_cmd.take(), current_ts.take());
entries
}
}
fn flush(entries: &mut Vec<HistoryEntry>, cmd: Option<String>, ts: Option<u64>) {
if let Some(cmd) = cmd {
let cmd = unescape_fish(&cmd);
let command = cmd.split_whitespace().next().unwrap_or("").to_string();
let mut entry = HistoryEntry::new(cmd, command);
entry.timestamp = ts;
entries.push(entry);
}
}
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 == '\\' {
match chars.next() {
Some('\\') => out.push('\\'),
Some('n') => out.push('\n'),
Some(other) => {
out.push('\\');
out.push(other);
}
None => out.push('\\'),
}
} else {
out.push(c);
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_basic_fish_history() {
let input = "- cmd: git push\n when: 1577836800\n- cmd: ls\n when: 1577836801\n";
let entries = FishHistoryParser::new().parse(input);
assert_eq!(entries.len(), 2);
assert_eq!(entries[0].command, "git");
assert_eq!(entries[0].raw, "git push");
assert_eq!(entries[0].timestamp, Some(1577836800));
assert_eq!(entries[1].command, "ls");
assert_eq!(entries[1].timestamp, Some(1577836801));
}
#[test]
fn handles_missing_when() {
let input = "- cmd: ls\n- cmd: git status\n";
let entries = FishHistoryParser::new().parse(input);
assert_eq!(entries.len(), 2);
assert_eq!(entries[0].timestamp, None);
}
#[test]
fn ignores_paths_block() {
let input = "- cmd: cd\n when: 1\n paths:\n - /home\n- cmd: ls\n when: 2\n";
let entries = FishHistoryParser::new().parse(input);
assert_eq!(entries.len(), 2);
assert_eq!(entries[0].command, "cd");
assert_eq!(entries[1].command, "ls");
}
#[test]
fn handles_empty_cmd_value() {
let input = "- cmd:\n- cmd: ls\n when: 1\n";
let entries = FishHistoryParser::new().parse(input);
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].command, "ls");
}
#[test]
fn handles_empty_input() {
assert!(FishHistoryParser::new().parse("").is_empty());
}
#[test]
fn handles_trailing_cmd_without_when() {
let input = "- cmd: ls\n when: 1\n- cmd: git status\n";
let entries = FishHistoryParser::new().parse(input);
assert_eq!(entries.len(), 2);
assert_eq!(entries[1].command, "git");
assert_eq!(entries[1].timestamp, None);
}
#[test]
fn ignores_garbage_lines() {
let input = "garbage line\n- cmd: ls\n when: 5\nrandom: value\n";
let entries = FishHistoryParser::new().parse(input);
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].timestamp, Some(5));
}
#[test]
fn unescapes_embedded_newline() {
let input = "- cmd: \\nmv ~/.config/nvim ~/.config/nvim.bak\\nmv ~/.local/share/nvim ~/.local/share/nvim.bak\n when: 1\n";
let entries = FishHistoryParser::new().parse(input);
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].command, "mv");
assert!(entries[0].raw.contains("\nmv"));
}
#[test]
fn unescapes_escaped_backslash() {
let input = "- cmd: echo pa\\\\th\n when: 1\n";
let entries = FishHistoryParser::new().parse(input);
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].raw, "echo pa\\th");
assert_eq!(entries[0].command, "echo");
}
}