Skip to main content

shellhist_core/
fish.rs

1//! Fish history (`fish_history`).
2//!
3//! A "nearly-YAML" record list (fish `src/history/yaml_backend.rs`): each entry is
4//! `- cmd: <text>`, `  when: <epoch>`, and an optional `  paths:` block of
5//! `    - <path>` lines. The `cmd` value can contain literal `:` and `#`, so it is
6//! NOT valid YAML — parse by line prefix. Fish escapes EXACTLY two things in
7//! `cmd`: `\` → `\\` and newline → `\n`; decoding reverses only those.
8
9use crate::{HistoryEntry, Shell};
10
11/// Reverse fish's two-rule escaping: `\\` → `\`, `\n` → newline. No other escape
12/// exists, so a lone backslash before any other char is kept verbatim.
13#[must_use]
14pub fn unescape(s: &str) -> String {
15    let mut out = String::with_capacity(s.len());
16    let mut chars = s.chars();
17    while let Some(c) = chars.next() {
18        if c == '\\' {
19            match chars.next() {
20                // An escaped backslash, or a trailing lone backslash at EOF: both
21                // yield a single literal backslash.
22                Some('\\') | None => out.push('\\'),
23                Some('n') => out.push('\n'),
24                Some(other) => {
25                    out.push('\\');
26                    out.push(other);
27                }
28            }
29        } else {
30            out.push(c);
31        }
32    }
33    out
34}
35
36/// Parse fish history bytes into entries.
37#[must_use]
38pub fn parse(data: &[u8]) -> Vec<HistoryEntry> {
39    let text = String::from_utf8_lossy(crate::strip_bom(data));
40    let mut entries: Vec<HistoryEntry> = Vec::new();
41    let mut in_paths = false;
42
43    for line in text.lines() {
44        if let Some(cmd) = line.strip_prefix("- cmd: ") {
45            entries.push(HistoryEntry {
46                shell: Shell::Fish,
47                command: unescape(cmd),
48                timestamp: None,
49                elapsed: None,
50                paths: Vec::new(),
51            });
52            in_paths = false;
53        } else if let Some(when) = line.strip_prefix("  when: ") {
54            if let Some(last) = entries.last_mut() {
55                last.timestamp = when.trim().parse::<i64>().ok();
56            }
57            in_paths = false;
58        } else if line.trim_end() == "  paths:" {
59            in_paths = true;
60        } else if in_paths {
61            if let Some(path) = line.strip_prefix("    - ") {
62                if let Some(last) = entries.last_mut() {
63                    last.paths.push(unescape(path));
64                }
65            } else {
66                in_paths = false;
67            }
68        }
69    }
70    entries
71}
72
73#[cfg(test)]
74mod tests {
75    use super::*;
76
77    #[test]
78    fn unescape_reverses_only_backslash_and_newline() {
79        assert_eq!(unescape(r"a\\b"), r"a\b");
80        assert_eq!(unescape(r"line1\nline2"), "line1\nline2");
81        assert_eq!(unescape(r"keep\:colon"), r"keep\:colon"); // ':' not an escape
82    }
83
84    #[test]
85    fn parses_cmd_when_and_paths() {
86        let data = b"- cmd: git status\n  when: 1700000000\n  paths:\n    - /repo\n- cmd: ls\n  when: 1700000005\n";
87        let e = parse(data);
88        assert_eq!(e.len(), 2);
89        assert_eq!(e[0].command, "git status");
90        assert_eq!(e[0].timestamp, Some(1_700_000_000));
91        assert_eq!(e[0].paths, vec!["/repo".to_string()]);
92        assert_eq!(e[1].command, "ls");
93        assert!(e[1].paths.is_empty());
94    }
95
96    #[test]
97    fn cmd_with_colon_and_hash_is_kept_verbatim() {
98        // The reason fish_history is not valid YAML — must not be lost.
99        let e = parse(b"- cmd: echo http://x # note\n  when: 1700000000\n");
100        assert_eq!(e.len(), 1);
101        assert_eq!(e[0].command, "echo http://x # note");
102    }
103
104    #[test]
105    fn escaped_newline_in_cmd_is_restored() {
106        let e = parse(b"- cmd: echo a\\nb\n  when: 1700000000\n");
107        assert_eq!(e[0].command, "echo a\nb");
108    }
109
110    #[test]
111    fn empty_input_yields_no_entries() {
112        assert!(parse(b"").is_empty());
113    }
114}