historion 0.1.1

Record and search shell history stored in plain text logs
Documentation
use crate::entry::{EntrySource, FIELD_DELIMITER, HistoryEntry};
use std::path::{Path, PathBuf};

pub fn parse_line(line: &str, file: &Path, line_number: usize) -> Result<HistoryEntry, String> {
    if line.contains(FIELD_DELIMITER) {
        parse_structured_line(line, file, line_number)
    } else {
        parse_legacy_line(line, file, line_number)
    }
}

pub fn parse_structured_line(
    line: &str,
    file: &Path,
    line_number: usize,
) -> Result<HistoryEntry, String> {
    let mut parts = line.splitn(4, FIELD_DELIMITER);
    let timestamp = parts
        .next()
        .ok_or_else(|| String::from("structured line is missing the timestamp"))?;
    let cwd = parts
        .next()
        .ok_or_else(|| String::from("structured line is missing the cwd"))?;
    let command = parts
        .next()
        .ok_or_else(|| String::from("structured line is missing the command"))?;

    if parts.next().is_some() {
        return Err(String::from("structured line has too many fields"));
    }

    let timestamp = unescape_field(timestamp)?;
    let cwd = unescape_field(cwd)?;
    let command = unescape_field(command)?;

    if timestamp.is_empty() || cwd.is_empty() || command.is_empty() {
        return Err(String::from("structured line contains an empty field"));
    }

    Ok(HistoryEntry {
        timestamp,
        cwd: PathBuf::from(cwd),
        command,
        source: EntrySource {
            file: file.to_path_buf(),
            line_number,
        },
    })
}

pub fn parse_legacy_line(
    line: &str,
    file: &Path,
    line_number: usize,
) -> Result<HistoryEntry, String> {
    let line = line.trim_end();
    let timestamp = line
        .get(..19)
        .ok_or_else(|| String::from("legacy line is too short"))?;

    if !looks_like_legacy_timestamp(timestamp) {
        return Err(String::from(
            "legacy line does not start with YYYY-MM-DD.HH:MM:SS",
        ));
    }

    let remainder = line
        .get(20..)
        .ok_or_else(|| String::from("legacy line is missing cwd and command"))?;

    let (cwd, command) = split_legacy_remainder(remainder)?;

    Ok(HistoryEntry {
        timestamp: timestamp.to_owned(),
        cwd: PathBuf::from(cwd),
        command,
        source: EntrySource {
            file: file.to_path_buf(),
            line_number,
        },
    })
}

pub fn unescape_field(value: &str) -> Result<String, String> {
    let mut result = String::with_capacity(value.len());
    let mut chars = value.chars();

    while let Some(ch) = chars.next() {
        if ch != '\\' {
            result.push(ch);
            continue;
        }

        let escaped = chars
            .next()
            .ok_or_else(|| String::from("field ends with a trailing escape"))?;

        match escaped {
            '\\' => result.push('\\'),
            't' => result.push('\t'),
            'n' => result.push('\n'),
            other => {
                result.push('\\');
                result.push(other);
            }
        }
    }

    Ok(result)
}

fn looks_like_legacy_timestamp(value: &str) -> bool {
    let bytes = value.as_bytes();

    bytes.len() == 19
        && bytes[4] == b'-'
        && bytes[7] == b'-'
        && bytes[10] == b'.'
        && bytes[13] == b':'
        && bytes[16] == b':'
}

fn split_legacy_remainder(remainder: &str) -> Result<(String, String), String> {
    let mut candidate = None;
    let bytes = remainder.as_bytes();

    for start in 0..bytes.len() {
        if !bytes[start].is_ascii_whitespace() {
            continue;
        }

        let mut cursor = start;
        while cursor < bytes.len() && bytes[cursor].is_ascii_whitespace() {
            cursor += 1;
        }

        let digit_start = cursor;
        while cursor < bytes.len() && bytes[cursor].is_ascii_digit() {
            cursor += 1;
        }

        if cursor == digit_start {
            continue;
        }

        let whitespace_after_digits = cursor;
        while cursor < bytes.len() && bytes[cursor].is_ascii_whitespace() {
            cursor += 1;
        }

        if cursor == whitespace_after_digits {
            continue;
        }

        let cwd = remainder[..start].trim_end();
        if cwd.starts_with('/') || cwd == "." {
            candidate = Some((cwd.to_owned(), remainder[cursor..].to_owned()));
            break;
        }
    }

    candidate.ok_or_else(|| String::from("legacy line is missing a recognizable history marker"))
}

#[cfg(test)]
mod tests {
    use super::{parse_legacy_line, parse_line, parse_structured_line, unescape_field};
    use crate::entry::{HistoryEntry, escape_field};
    use std::path::{Path, PathBuf};

    #[test]
    fn structured_lines_round_trip() {
        let line = format!(
            "{}\t{}\t{}",
            escape_field("2026-04-19T10:23:45+0100"),
            escape_field("/tmp/demo project"),
            escape_field("printf 'a\tb'\n")
        );

        let entry = parse_structured_line(&line, Path::new("/tmp/log.log"), 12)
            .expect("structured line should parse");

        assert_eq!(
            entry,
            HistoryEntry {
                timestamp: String::from("2026-04-19T10:23:45+0100"),
                cwd: PathBuf::from("/tmp/demo project"),
                command: String::from("printf 'a\tb'\n"),
                source: crate::entry::EntrySource {
                    file: PathBuf::from("/tmp/log.log"),
                    line_number: 12,
                },
            }
        );
    }

    #[test]
    fn structured_lines_require_three_fields() {
        let result = parse_structured_line(
            "2026-04-19T10:23:45+0100\t/tmp/demo",
            Path::new("/tmp/log.log"),
            3,
        );

        assert_eq!(
            result,
            Err(String::from("structured line is missing the command"))
        );
    }

    #[test]
    fn unescape_field_restores_special_characters() {
        let result =
            unescape_field("first\\tsecond\\nthird\\\\fourth").expect("field should unescape");

        assert_eq!(result, "first\tsecond\nthird\\fourth");
    }

    #[test]
    fn legacy_lines_parse_history_number_and_command() {
        let entry = parse_legacy_line(
            "2026-04-19.10:23:45 /tmp/demo  41  cargo test --lib",
            Path::new("/tmp/legacy.log"),
            8,
        )
        .expect("legacy line should parse");

        assert_eq!(entry.timestamp, "2026-04-19.10:23:45");
        assert_eq!(entry.cwd, PathBuf::from("/tmp/demo"));
        assert_eq!(entry.command, "cargo test --lib");
        assert_eq!(entry.source.line_number, 8);
    }

    #[test]
    fn legacy_lines_support_spaces_in_path() {
        let entry = parse_legacy_line(
            "2026-04-19.10:23:45 /tmp/demo project  41  cargo test",
            Path::new("/tmp/legacy.log"),
            9,
        )
        .expect("legacy line should parse");

        assert_eq!(entry.cwd, PathBuf::from("/tmp/demo project"));
        assert_eq!(entry.command, "cargo test");
    }

    #[test]
    fn legacy_lines_fail_without_history_marker() {
        let result = parse_legacy_line(
            "2026-04-19.10:23:45 /tmp/demo cargo test",
            Path::new("/tmp/legacy.log"),
            4,
        );

        assert_eq!(
            result,
            Err(String::from(
                "legacy line is missing a recognizable history marker"
            ))
        );
    }

    #[test]
    fn parse_line_auto_detects_structured_and_legacy() {
        let structured = parse_line(
            "2026-04-19T10:23:45+0100\t/tmp/demo\tcargo test",
            Path::new("/tmp/structured.log"),
            1,
        )
        .expect("structured line should parse");
        let legacy = parse_line(
            "2026-04-19.10:23:45 /tmp/demo  41  cargo test",
            Path::new("/tmp/legacy.log"),
            2,
        )
        .expect("legacy line should parse");

        assert_eq!(structured.command, "cargo test");
        assert_eq!(legacy.command, "cargo test");
    }
}