shimpz-cli 0.1.1

Fast local tooling for Shimpz Assistants
//! Minimal command-line parsing without a runtime dependency.

use std::ffi::OsString;
use std::path::PathBuf;

pub(crate) const USAGE: &str = "Usage: shimpz <check|test> [options]";
pub(crate) const HELP: &str = "\
Fast local tooling for Shimpz Assistants.

Usage:
  shimpz check [--project <path>]
  shimpz test <power> [--input <json> | --input-file <path>] [--project <path>]
  shimpz --help
  shimpz --version
";

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

#[derive(Debug, Eq, PartialEq)]
pub(crate) enum Command {
    Check {
        project: PathBuf,
    },
    Test {
        project: PathBuf,
        power: String,
        input: Input,
    },
}

#[derive(Debug, Eq, PartialEq)]
pub(crate) enum Input {
    Inline(String),
    File(PathBuf),
    Stdin,
}

pub(crate) fn parse(arguments: impl IntoIterator<Item = OsString>) -> Result<Action, String> {
    let values = arguments
        .into_iter()
        .map(unicode)
        .collect::<Result<Vec<_>, _>>()?;
    let Some((command, rest)) = values.split_first() else {
        return Ok(Action::Help);
    };
    match command.as_str() {
        "-h" | "--help" | "help" => Ok(Action::Help),
        "-V" | "--version" | "version" => Ok(Action::Version),
        "check" => parse_check(rest),
        "test" => parse_test(rest),
        _ => Err("unknown command".into()),
    }
}

fn parse_check(arguments: &[String]) -> Result<Action, String> {
    if arguments == ["--help"] || arguments == ["-h"] {
        return Ok(Action::Help);
    }
    let project = project_option(arguments)?;
    Ok(Action::Run(Command::Check { project }))
}

fn parse_test(arguments: &[String]) -> Result<Action, String> {
    if arguments == ["--help"] || arguments == ["-h"] {
        return Ok(Action::Help);
    }
    let Some(power) = arguments.first().filter(|value| !value.starts_with('-')) else {
        return Err("test requires a Power id".into());
    };
    if !valid_power_id(power) {
        return Err("Power id is invalid".into());
    }
    let mut project = PathBuf::from(".");
    let mut project_seen = false;
    let mut input = None;
    let mut index = 1;
    while index < arguments.len() {
        let option = &arguments[index];
        let value = arguments
            .get(index + 1)
            .ok_or_else(|| format!("{option} requires a value"))?;
        match option.as_str() {
            "--project" if !project_seen => {
                project = PathBuf::from(value);
                project_seen = true;
            }
            "--input" if input.is_none() => {
                input = Some(if value == "-" {
                    Input::Stdin
                } else {
                    Input::Inline(value.clone())
                });
            }
            "--input-file" if input.is_none() => input = Some(Input::File(PathBuf::from(value))),
            "--project" | "--input" | "--input-file" => {
                return Err(format!("{option} was repeated"));
            }
            _ => return Err(format!("unknown option {option}")),
        }
        index += 2;
    }
    Ok(Action::Run(Command::Test {
        project,
        power: power.clone(),
        input: input.unwrap_or_else(|| Input::Inline("{}".into())),
    }))
}

fn project_option(arguments: &[String]) -> Result<PathBuf, String> {
    match arguments {
        [] => Ok(PathBuf::from(".")),
        [option, value] if option == "--project" => Ok(PathBuf::from(value)),
        [option] if option == "--project" => Err("--project requires a value".into()),
        _ => Err("check accepts only --project <path>".into()),
    }
}

fn unicode(value: OsString) -> Result<String, String> {
    value
        .into_string()
        .map_err(|_| "arguments must be valid UTF-8".into())
}

fn valid_power_id(value: &str) -> bool {
    !value.is_empty()
        && value.len() <= 64
        && value.starts_with(|character: char| character.is_ascii_lowercase())
        && value
            .bytes()
            .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-')
        && !value.ends_with('-')
}

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

    fn strings(values: &[&str]) -> Vec<OsString> {
        values.iter().map(OsString::from).collect()
    }

    #[test]
    fn parses_check_with_the_current_project() {
        assert_eq!(
            parse(strings(&["check"])),
            Ok(Action::Run(Command::Check {
                project: PathBuf::from(".")
            }))
        );
    }

    #[test]
    fn parses_a_file_backed_power_test() {
        assert_eq!(
            parse(strings(&[
                "test",
                "create-dns",
                "--input-file",
                "request.json",
                "--project",
                "assistant"
            ])),
            Ok(Action::Run(Command::Test {
                project: PathBuf::from("assistant"),
                power: "create-dns".into(),
                input: Input::File(PathBuf::from("request.json")),
            }))
        );
    }

    #[test]
    fn rejects_secrets_and_unknown_options() {
        assert_eq!(
            parse(strings(&["test", "create-dns", "--account", "token"])),
            Err("unknown option --account".into())
        );
    }

    #[test]
    fn rejects_invalid_power_ids() {
        assert_eq!(
            parse(strings(&["test", "CreateDns"])),
            Err("Power id is invalid".into())
        );
    }
}