hitman 0.12.1

A command line tool for hitting API endpoints
Documentation
use anyhow::{bail, Result};
use fuzzy_matcher::skim::SkimMatcherV2;
use inquire::{list_option::ListOption, DateSelect, MultiSelect, Select, Text};
use std::{collections::HashMap, env, string::ToString};
use toml::Value;

use crate::{
    request::HitmanRequest,
    resolve::Resolved,
    scope::{Replacement, Scope},
    substitute::{
        prepare_request,
        Substitution::{Complete, ValueMissing},
        SubstitutionValue,
    },
};

fn set_boolean(name: &str, value: bool) {
    env::set_var(name, if value { "y" } else { "n" });
}

fn get_boolean(name: &str) -> bool {
    env::var(name).is_ok_and(|v| v == "y")
}

pub fn set_interactive_mode(enable: bool) {
    set_boolean("interactive", enable);
}

pub fn is_interactive_mode() -> bool {
    get_boolean("interactive")
}

pub fn set_insecure_ssl(enable: bool) {
    set_boolean("insecure-ssl", enable);
}

pub fn has_insecure_ssl() -> bool {
    get_boolean("insecure-ssl")
}

pub fn fuzzy_match(filter: &str, value: &str) -> Option<i64> {
    let matcher = SkimMatcherV2::default();
    let fuzzy_score = matcher.fuzzy(value, filter, true);
    fuzzy_score.map(|(score, _)| score)
}

pub fn get_interaction() -> Box<dyn UserInteraction> {
    if is_interactive_mode() {
        Box::new(CliUserInteraction)
    } else {
        Box::new(NoUserInteraction)
    }
}

pub trait UserInteraction {
    fn prompt(&self, key: &str, fallback: Option<&str>) -> Result<String>;
    fn select(&self, key: &str, values: &[Value]) -> Result<String>;
    fn select_multiple(
        &self,
        key: &str,
        values: &[Value],
    ) -> Result<Vec<String>>;
}

pub fn prepare_request_interactive<I>(
    resolved: &Resolved,
    scope: &Scope,
    interaction: &I,
) -> Result<HitmanRequest>
where
    I: UserInteraction + ?Sized,
{
    let mut vars = HashMap::new();

    loop {
        match prepare_request(resolved, &vars)? {
            Complete(req) => return Ok(req),
            ValueMissing {
                key,
                fallback,
                multiple,
            } => {
                let value = match scope.lookup(&key)? {
                    Replacement::Value(value) => {
                        SubstitutionValue::Single(value)
                    }
                    Replacement::ValueNotFound { key } => {
                        SubstitutionValue::Single(
                            interaction.prompt(&key, fallback.as_deref())?,
                        )
                    }
                    Replacement::MultipleValuesFound { key, values } => {
                        if multiple {
                            SubstitutionValue::Multiple(
                                interaction.select_multiple(&key, &values)?,
                            )
                        } else {
                            SubstitutionValue::Single(
                                interaction.select(&key, &values)?,
                            )
                        }
                    }
                };

                vars.insert(key, value);
            }
        }
    }
}

pub struct NoUserInteraction;

impl NoUserInteraction {
    fn get_suggestions(&self, key: &str, values: &[toml::Value]) -> String {
        values
            .iter()
            .take(10)
            .filter_map(|v| match (v.get("value"), v.get("name")) {
                (Some(v), Some(n)) => Some(format!("{key}={v} => {n}")),
                _ => None,
            })
            .collect::<Vec<_>>()
            .join("\n")
    }
}

impl UserInteraction for NoUserInteraction {
    fn prompt(&self, key: &str, fallback: Option<&str>) -> Result<String> {
        if let Some(val) = fallback.map(ToString::to_string) {
            return Ok(val);
        }

        bail!("Replacement not found: {key}");
    }

    fn select(&self, key: &str, values: &[toml::Value]) -> Result<String> {
        let suggestions = self.get_suggestions(key, values);
        bail!("Replacement not selected: {key}\nSuggestions:\n{suggestions}");
    }

    fn select_multiple(
        &self,
        key: &str,
        values: &[toml::Value],
    ) -> Result<Vec<String>> {
        let suggestions = self.get_suggestions(key, values);
        bail!("Replacement not selected: {key}\nSuggestions:\n{suggestions}");
    }
}

pub struct CliUserInteraction;

impl UserInteraction for CliUserInteraction {
    fn prompt(&self, key: &str, fallback: Option<&str>) -> Result<String> {
        prompt_user(key, fallback)
    }

    fn select(&self, key: &str, values: &[toml::Value]) -> Result<String> {
        select_replacement(key, values)
    }

    fn select_multiple(
        &self,
        key: &str,
        values: &[Value],
    ) -> Result<Vec<String>> {
        select_replacement_multiple(key, values)
    }
}

fn prompt_user(key: &str, fallback: Option<&str>) -> Result<String> {
    let fb = fallback.unwrap_or("");

    if key.ends_with("_date") || key.ends_with("Date") {
        if let Some(date) = prompt_for_date(key)? {
            return Ok(date);
        }
    }

    let input = Text::new(&format!("Enter value for {key}"))
        .with_default(fb)
        .prompt()?;

    Ok(input)
}

fn prompt_for_date(key: &str) -> Result<Option<String>> {
    let msg = format!("Select a date for {key}");
    let formatter =
        |date: chrono::NaiveDate| date.format("%Y-%m-%d").to_string();

    let res = DateSelect::new(&msg)
        .with_week_start(chrono::Weekday::Mon)
        .with_formatter(&formatter)
        .prompt_skippable()?;

    Ok(res.map(formatter))
}

fn select_replacement(key: &str, values: &[Value]) -> Result<String> {
    let list_options = values_to_list_options(values);
    let selected =
        Select::new(&format!("Select value for {key}"), list_options)
            .with_scorer(&|filter, _, value, _| fuzzy_match(filter, value))
            .with_page_size(15)
            .prompt()?;

    list_option_to_string(key, values, &selected)
}

fn select_replacement_multiple(
    key: &str,
    values: &[Value],
) -> Result<Vec<String>> {
    let list_options = values_to_list_options(values);
    let selected =
        MultiSelect::new(&format!("Select value for {key}"), list_options)
            .with_scorer(&|filter, _, value, _| fuzzy_match(filter, value))
            .with_page_size(15)
            .prompt()?;

    selected
        .iter()
        .map(|item| list_option_to_string(key, values, item))
        .collect()
}

fn values_to_list_options(values: &[Value]) -> Vec<ListOption<String>> {
    values
        .iter()
        .enumerate()
        .map(|(i, v)| {
            ListOption::new(
                i,
                match v {
                    Value::Table(t) => match t.get("name") {
                        Some(Value::String(value)) => value.clone(),
                        Some(value) => value.to_string(),
                        None => t.to_string(),
                    },
                    other => other.to_string(),
                },
            )
        })
        .collect()
}

fn list_option_to_string(
    key: &str,
    values: &[Value],
    selected: &ListOption<String>,
) -> Result<String> {
    match &values[selected.index] {
        Value::Table(t) => match t.get("value") {
            Some(Value::String(value)) => Ok(value.clone()),
            Some(value) => Ok(value.to_string()),
            _ => bail!("Replacement not found: {key}"),
        },
        other => Ok(other.to_string()),
    }
}

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

    #[test]
    fn returns_true_for_identical() {
        assert!(fuzzy_match("a", "a").is_some());
    }

    #[test]
    fn returns_false_for_different() {
        assert!(fuzzy_match("a", "b").is_none());
    }

    #[test]
    fn returns_false_for_different_length() {
        assert!(fuzzy_match("ab", "a").is_none());
    }

    #[test]
    fn returns_true_for_different_case() {
        assert!(fuzzy_match("a", "A").is_some());
    }

    #[test]
    fn returns_true_if_filter_is_empty() {
        assert!(fuzzy_match("", "a").is_some());
    }

    #[test]
    fn returns_false_if_value_is_empty() {
        assert!(fuzzy_match("a", "").is_none());
    }

    #[test]
    fn returns_true_value_contains_filter() {
        assert!(fuzzy_match("a", "ab").is_some());
    }

    #[test]
    fn returns_true_if_value_contains_all_letters_in_filter_in_the_same_order()
    {
        assert!(fuzzy_match("abc", "uaaxbycz").is_some());
    }
}