git-file-history 0.1.0

TUI for browsing the Git history of a single file
use std::{ffi::OsString, path::PathBuf};

use crate::error::{AppError, Result};

pub(crate) const USAGE: &str = "Usage: git-file-history <file_path>";
const MISSING_FILE_PATH: &str = "missing file path";
const TOO_MANY_ARGUMENTS: &str = "too many arguments";

#[derive(Debug, PartialEq, Eq)]
pub(crate) enum CliAction {
    Run(PathBuf),
    Help,
    Version,
}

/// Parses command-line arguments into an executable CLI action.
#[must_use = "the parsed CLI action or error must be handled"]
pub(crate) fn parse_args<I>(mut args: I) -> Result<CliAction>
where
    I: Iterator<Item = OsString>,
{
    let _program = args.next();
    let Some(file_path) = args.next() else {
        return Err(AppError::message(format!("{MISSING_FILE_PATH}\n{USAGE}")));
    };
    let has_extra_arg = args.next().is_some();
    if file_path == "--help" || file_path == "-h" {
        return if has_extra_arg {
            Err(AppError::message(format!("{TOO_MANY_ARGUMENTS}\n{USAGE}")))
        } else {
            Ok(CliAction::Help)
        };
    }
    if file_path == "--version" || file_path == "-V" {
        return if has_extra_arg {
            Err(AppError::message(format!("{TOO_MANY_ARGUMENTS}\n{USAGE}")))
        } else {
            Ok(CliAction::Version)
        };
    }
    if has_extra_arg {
        return Err(AppError::message(format!("{TOO_MANY_ARGUMENTS}\n{USAGE}")));
    }
    Ok(CliAction::Run(PathBuf::from(file_path)))
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn accepts_exactly_one_file_path() {
        let args = ["git-file-history", "src/main.rs"].map(OsString::from);
        let action = parse_args(args.into_iter()).expect("args should parse");

        assert_eq!(action, CliAction::Run(PathBuf::from("src/main.rs")));
    }

    #[test]
    fn rejects_missing_file_path() {
        let args = ["git-file-history"].map(OsString::from);

        let error = parse_args(args.into_iter()).expect_err("args should fail");
        assert_eq!(error.to_string(), format!("{MISSING_FILE_PATH}\n{USAGE}"));
    }

    #[test]
    fn rejects_extra_args() {
        let args = ["git-file-history", "a", "b"].map(OsString::from);

        let error = parse_args(args.into_iter()).expect_err("args should fail");
        assert_eq!(error.to_string(), format!("{TOO_MANY_ARGUMENTS}\n{USAGE}"));
    }

    #[test]
    fn accepts_help_flags() {
        for flag in ["--help", "-h"] {
            let args = ["git-file-history", flag].map(OsString::from);

            assert!(matches!(parse_args(args.into_iter()), Ok(CliAction::Help)));
        }
    }

    #[test]
    fn rejects_help_flags_with_extra_args() {
        let args = ["git-file-history", "--help", "extra"].map(OsString::from);

        let error = parse_args(args.into_iter()).expect_err("args should fail");
        assert_eq!(error.to_string(), format!("{TOO_MANY_ARGUMENTS}\n{USAGE}"));
    }

    #[test]
    fn accepts_version_flags() {
        for flag in ["--version", "-V"] {
            let args = ["git-file-history", flag].map(OsString::from);

            assert!(matches!(
                parse_args(args.into_iter()),
                Ok(CliAction::Version)
            ));
        }
    }

    #[test]
    fn rejects_version_flags_with_extra_args() {
        let args = ["git-file-history", "--version", "extra"].map(OsString::from);

        let error = parse_args(args.into_iter()).expect_err("args should fail");
        assert_eq!(error.to_string(), format!("{TOO_MANY_ARGUMENTS}\n{USAGE}"));
    }
}