historion 0.1.1

Record and search shell history stored in plain text logs
Documentation
use crate::record::RecordArgs;
use crate::search::SearchArgs;
use crate::shell::{InitArgs, InstallArgs, ShellKind};
use std::ffi::OsString;
use std::path::PathBuf;

#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Command {
    Help,
    Search(SearchArgs),
    Record(RecordArgs),
    Init(InitArgs),
    Install(InstallArgs),
}

pub fn parse_args<I, T>(args: I) -> Result<Command, String>
where
    I: IntoIterator<Item = T>,
    T: Into<OsString>,
{
    let args = args
        .into_iter()
        .map(|item| {
            item.into()
                .into_string()
                .map_err(|_| String::from("arguments must be valid unicode"))
        })
        .collect::<Result<Vec<_>, _>>()?;

    let mut args = args.into_iter();
    let _program = args.next();

    let Some(first) = args.next() else {
        return Ok(Command::Help);
    };

    match first.as_str() {
        "-h" | "--help" | "help" => Ok(Command::Help),
        "record" => parse_record(args.collect()),
        "init" => parse_init(args.collect()),
        "install" => parse_install(args.collect()),
        value if value.starts_with("--") => {
            parse_search(None, std::iter::once(first).chain(args).collect())
        }
        _ => parse_search(Some(first), args.collect()),
    }
}

fn parse_search(query: Option<String>, rest: Vec<String>) -> Result<Command, String> {
    let mut query = query;
    let mut folder = None;
    let mut today = false;
    let mut since = None;
    let mut limit = None;
    let mut json = false;
    let mut ignore_case = false;

    let mut rest = rest.into_iter();
    while let Some(arg) = rest.next() {
        match arg.as_str() {
            "--folder" => {
                let value = rest
                    .next()
                    .ok_or_else(|| String::from("--folder requires a path"))?;
                folder = Some(PathBuf::from(value));
            }
            "--today" => today = true,
            "--since" => {
                let value = rest
                    .next()
                    .ok_or_else(|| String::from("--since requires a number of days"))?;
                since = Some(
                    value
                        .parse()
                        .map_err(|_| String::from("--since expects an integer"))?,
                );
            }
            "--limit" => {
                let value = rest
                    .next()
                    .ok_or_else(|| String::from("--limit requires a number"))?;
                limit = Some(
                    value
                        .parse()
                        .map_err(|_| String::from("--limit expects an integer"))?,
                );
            }
            "-i" | "--ignore-case" => ignore_case = true,
            "--json" => json = true,
            "-h" | "--help" => return Ok(Command::Help),
            value if value.starts_with("--") => {
                return Err(format!("unknown search flag: {value}"));
            }
            value => {
                if query.is_none() {
                    query = Some(value.to_owned());
                } else {
                    return Err(String::from("search accepts only one query argument"));
                }
            }
        }
    }

    Ok(Command::Search(SearchArgs {
        query,
        folder,
        today,
        since_days: since,
        limit,
        json,
        ignore_case,
    }))
}

fn parse_record(args: Vec<String>) -> Result<Command, String> {
    let mut cwd = None;
    let mut command = None;
    let mut history_id = None;
    let mut shell = None;

    let mut args = args.into_iter();
    while let Some(arg) = args.next() {
        match arg.as_str() {
            "--cwd" => {
                let value = args
                    .next()
                    .ok_or_else(|| String::from("--cwd requires a path"))?;
                cwd = Some(PathBuf::from(value));
            }
            "--command" => {
                command = Some(
                    args.next()
                        .ok_or_else(|| String::from("--command requires a value"))?,
                );
            }
            "--history-id" => {
                history_id = Some(
                    args.next()
                        .ok_or_else(|| String::from("--history-id requires a value"))?,
                );
            }
            "--shell" => {
                shell = Some(parse_shell_kind(
                    &args
                        .next()
                        .ok_or_else(|| String::from("--shell requires a value"))?,
                )?);
            }
            value => return Err(format!("unknown record flag: {value}")),
        }
    }

    Ok(Command::Record(RecordArgs {
        cwd,
        command,
        history_id,
        shell,
    }))
}

fn parse_init(args: Vec<String>) -> Result<Command, String> {
    let shell = parse_single_shell_arg("init", args)?;
    Ok(Command::Init(InitArgs { shell }))
}

fn parse_install(args: Vec<String>) -> Result<Command, String> {
    let shell = parse_single_shell_arg("install", args)?;
    Ok(Command::Install(InstallArgs { shell }))
}

fn parse_single_shell_arg(command: &str, args: Vec<String>) -> Result<ShellKind, String> {
    let mut args = args.into_iter();
    let value = args
        .next()
        .ok_or_else(|| format!("{command} requires a shell argument"))?;

    if args.next().is_some() {
        return Err(format!("{command} accepts only one shell argument"));
    }

    parse_shell_kind(&value)
}

fn parse_shell_kind(value: &str) -> Result<ShellKind, String> {
    match value {
        "bash" => Ok(ShellKind::Bash),
        "zsh" => Ok(ShellKind::Zsh),
        _ => Err(format!("unsupported shell: {value}")),
    }
}

#[cfg(test)]
mod tests {
    use super::{Command, parse_args};
    use crate::record::RecordArgs;
    use crate::search::SearchArgs;
    use crate::shell::{InitArgs, InstallArgs, ShellKind};
    use std::path::PathBuf;

    #[test]
    fn parses_help_without_arguments() {
        let command = parse_args(["hy"]).expect("cli should parse");
        assert_eq!(command, Command::Help);
    }

    #[test]
    fn parses_search_command_from_bare_query() {
        let command = parse_args(["hy", "needle"]).expect("cli should parse");
        assert_eq!(
            command,
            Command::Search(SearchArgs {
                query: Some(String::from("needle")),
                folder: None,
                today: false,
                since_days: None,
                limit: None,
                json: false,
                ignore_case: false,
            })
        );
    }

    #[test]
    fn parses_search_flags_without_a_query() {
        let command = parse_args(["hy", "--folder", "."]).expect("cli should parse");

        assert_eq!(
            command,
            Command::Search(SearchArgs {
                query: None,
                folder: Some(PathBuf::from(".")),
                today: false,
                since_days: None,
                limit: None,
                json: false,
                ignore_case: false,
            })
        );
    }

    #[test]
    fn parses_search_query_after_folder_flag() {
        let command = parse_args(["hy", "--folder", ".", "cargo"]).expect("cli should parse");

        assert_eq!(
            command,
            Command::Search(SearchArgs {
                query: Some(String::from("cargo")),
                folder: Some(PathBuf::from(".")),
                today: false,
                since_days: None,
                limit: None,
                json: false,
                ignore_case: false,
            })
        );
    }

    #[test]
    fn parses_ignore_case_flag() {
        let command =
            parse_args(["hy", "--folder", ".", "--ignore-case"]).expect("cli should parse");

        assert_eq!(
            command,
            Command::Search(SearchArgs {
                query: None,
                folder: Some(PathBuf::from(".")),
                today: false,
                since_days: None,
                limit: None,
                json: false,
                ignore_case: true,
            })
        );
    }

    #[test]
    fn parses_record_command_arguments() {
        let command = parse_args([
            "hy",
            "record",
            "--cwd",
            "/tmp/demo",
            "--command",
            "cargo test",
            "--history-id",
            "42",
            "--shell",
            "zsh",
        ])
        .expect("cli should parse");

        assert_eq!(
            command,
            Command::Record(RecordArgs {
                cwd: Some(PathBuf::from("/tmp/demo")),
                command: Some(String::from("cargo test")),
                history_id: Some(String::from("42")),
                shell: Some(ShellKind::Zsh),
            })
        );
    }

    #[test]
    fn parses_init_and_install_commands() {
        let init = parse_args(["hy", "init", "bash"]).expect("init should parse");
        let install = parse_args(["hy", "install", "zsh"]).expect("install should parse");

        assert_eq!(
            init,
            Command::Init(InitArgs {
                shell: ShellKind::Bash
            })
        );
        assert_eq!(
            install,
            Command::Install(InstallArgs {
                shell: ShellKind::Zsh
            })
        );
    }
}