#![forbid(unsafe_code)]
pub mod bash;
pub mod fish;
pub mod powershell;
pub mod zsh;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Shell {
Bash,
Zsh,
Fish,
PowerShell,
Unknown,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HistoryEntry {
pub shell: Shell,
pub command: String,
pub timestamp: Option<i64>,
pub elapsed: Option<i64>,
pub paths: Vec<String>,
}
impl HistoryEntry {
pub(crate) fn plain(shell: Shell, command: impl Into<String>) -> Self {
Self {
shell,
command: command.into(),
timestamp: None,
elapsed: None,
paths: Vec::new(),
}
}
}
#[must_use]
pub fn strip_bom(data: &[u8]) -> &[u8] {
data.strip_prefix(&[0xEF, 0xBB, 0xBF]).unwrap_or(data)
}
#[must_use]
pub fn detect(data: &[u8], filename: Option<&str>) -> Shell {
let text = String::from_utf8_lossy(strip_bom(data));
for line in text.lines().take(200) {
if zsh::is_extended_line(line) {
return Shell::Zsh;
}
if line.starts_with("- cmd:") {
return Shell::Fish;
}
if bash::parse_timestamp_line(line).is_some() {
return Shell::Bash;
}
}
if let Some(name) = filename {
let lower = name.to_ascii_lowercase();
if lower.contains("zsh_history") {
return Shell::Zsh;
}
if lower.contains("fish_history") {
return Shell::Fish;
}
if lower.contains("bash_history") {
return Shell::Bash;
}
if lower.contains("consolehost_history") || lower.contains("psreadline") {
return Shell::PowerShell;
}
}
Shell::Unknown
}
#[must_use]
pub fn parse(data: &[u8], shell: Shell) -> Vec<HistoryEntry> {
match shell {
Shell::Bash | Shell::Unknown => bash::parse(data),
Shell::Zsh => zsh::parse(data),
Shell::Fish => fish::parse(data),
Shell::PowerShell => powershell::parse(data),
}
}
#[must_use]
pub fn parse_auto(data: &[u8], filename: Option<&str>) -> Vec<HistoryEntry> {
parse(data, detect(data, filename))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn strip_bom_removes_only_a_leading_bom() {
assert_eq!(strip_bom(b"\xEF\xBB\xBFhi"), b"hi");
assert_eq!(strip_bom(b"hi"), b"hi");
}
#[test]
fn detect_zsh_by_extended_line() {
assert_eq!(detect(b": 1700000000:0;ls", None), Shell::Zsh);
}
#[test]
fn detect_bash_by_timestamp_line() {
assert_eq!(detect(b"#1700000000\nls\n", None), Shell::Bash);
}
#[test]
fn detect_fish_by_cmd_record() {
assert_eq!(
detect(b"- cmd: ls\n when: 1700000000\n", None),
Shell::Fish
);
}
#[test]
fn detect_powershell_by_filename_when_content_is_plain() {
assert_eq!(
detect(b"Get-Process\nls\n", Some("ConsoleHost_history.txt")),
Shell::PowerShell
);
}
#[test]
fn detect_falls_back_to_unknown_for_plain_unnamed() {
assert_eq!(detect(b"ls\ncd /tmp\n", None), Shell::Unknown);
}
#[test]
fn parse_auto_unknown_is_plain_lines() {
let e = parse_auto(b"ls\ncd /tmp\n", None);
assert_eq!(e.len(), 2);
assert_eq!(e[0].command, "ls");
assert_eq!(e[1].timestamp, None);
}
}