cargo-lambda-new 1.9.1

Cargo subcommand to work with AWS Lambda
Documentation
use cargo_lambda_interactive::{
    Confirm, CustomUserError, Text,
    validator::{ErrorMessage, Validation},
};
use indexmap::IndexMap;
use liquid::{Object, model::Value};
use miette::{IntoDiagnostic, Result, WrapErr};
use serde::Deserialize;
use std::{
    collections::HashMap,
    fmt::Debug,
    fs,
    path::{Path, PathBuf},
};

use crate::template::PROMPT_WITH_OPTIONS_HELP_MESSAGE;

#[derive(Clone, Debug, Deserialize, PartialEq)]
#[serde(untagged)]
pub(crate) enum PromptValue {
    Boolean(bool),
    String(String),
}

impl PromptValue {
    pub fn to_value(&self) -> Value {
        match self {
            PromptValue::Boolean(b) => Value::scalar(*b),
            PromptValue::String(s) => Value::scalar(s.clone()),
        }
    }
}

impl Default for PromptValue {
    fn default() -> Self {
        PromptValue::String(String::default())
    }
}

impl From<PromptValue> for Value {
    fn from(value: PromptValue) -> Self {
        value.to_value()
    }
}

#[derive(Debug, Default, Deserialize)]
pub(crate) struct RenderCondition {
    pub var: String,
    pub r#match: Option<PromptValue>,
    pub not_match: Option<PromptValue>,
}

#[derive(Debug, Default, Deserialize)]
pub(crate) struct TemplatePrompt {
    pub message: String,
    #[serde(default)]
    pub choices: Option<Vec<String>>,
    #[serde(default)]
    pub default: Option<PromptValue>,
    #[serde(default)]
    pub help: Option<String>,
}

#[derive(Debug, Default, Deserialize)]
pub(crate) struct TemplateConfig {
    #[serde(default)]
    pub disable_default_prompts: bool,
    #[serde(default)]
    pub prompts: IndexMap<String, TemplatePrompt>,
    #[serde(default)]
    pub render_files: Vec<PathBuf>,
    #[serde(default)]
    pub render_all_files: bool,
    #[serde(default)]
    pub ignore_files: Vec<PathBuf>,
    #[serde(default)]
    pub render_conditional_files: HashMap<String, RenderCondition>,
    #[serde(default)]
    pub ignore_conditional_files: HashMap<String, RenderCondition>,
}

#[derive(Debug, Deserialize)]
pub(crate) struct CargoLambdaConfig {
    pub template: TemplateConfig,
}

#[tracing::instrument(target = "cargo_lambda")]
pub(crate) fn parse_template_config<P: AsRef<Path> + Debug>(path: P) -> Result<TemplateConfig> {
    let config_path = path.as_ref().join("CargoLambda.toml");
    if !config_path.exists() {
        return Ok(TemplateConfig::default());
    }

    let contents = fs::read_to_string(config_path)
        .into_diagnostic()
        .wrap_err_with(|| format!("failed to read CargoLambda.toml at {:?}", path.as_ref()))?;

    let config: CargoLambdaConfig = toml::from_str(&contents)
        .into_diagnostic()
        .wrap_err_with(|| format!("failed to parse CargoLambda.toml at {:?}", path.as_ref()))?;

    Ok(config.template)
}

impl TemplateConfig {
    pub(crate) fn ask_template_options(&self, no_interactive: bool) -> Result<Object> {
        let mut variables = Object::new();
        for (name, prompt) in &self.prompts {
            let value = if no_interactive {
                prompt.default.clone().unwrap_or_default()
            } else {
                prompt.ask()?
            };
            variables.insert(name.into(), value.into());
        }
        Ok(variables)
    }
}

impl TemplatePrompt {
    pub(crate) fn ask(&self) -> Result<PromptValue> {
        let help_message = self.help_message();

        match &self.default {
            Some(PromptValue::Boolean(b)) => {
                let prompt = Confirm::new(&self.message).with_default(*b);
                let value = if let Some(help_message) = help_message {
                    prompt.with_help_message(&help_message).prompt()
                } else {
                    prompt.prompt()
                };
                Ok(PromptValue::Boolean(value.into_diagnostic()?))
            }
            Some(PromptValue::String(s)) => {
                let prompt = self.text_prompt().with_default(s);
                let value = if let Some(help_message) = help_message {
                    prompt.with_help_message(&help_message).prompt()
                } else {
                    prompt.prompt()
                };
                Ok(PromptValue::String(value.into_diagnostic()?))
            }
            None => {
                let prompt = self.text_prompt();
                let value = if let Some(help_message) = help_message {
                    prompt.with_help_message(&help_message).prompt()
                } else {
                    prompt.prompt()
                };
                Ok(PromptValue::String(value.into_diagnostic()?))
            }
        }
    }

    fn text_prompt(&self) -> Text<'_> {
        let mut prompt = Text::new(&self.message);

        if let Some(choices) = &self.choices {
            let choices_for_suggest = choices.clone();
            let choices_for_validator = choices.clone();

            let autocomplete = move |input: &str| suggest_choice(input, &choices_for_suggest);
            let validator = move |input: &str| validate_choice(input, &choices_for_validator);

            prompt = prompt
                .with_autocomplete(autocomplete)
                .with_validator(validator);
        }

        prompt
    }

    fn help_message(&self) -> Option<String> {
        match (self.choices.is_some(), &self.help) {
            (true, Some(user_help)) => {
                Some(format!("{PROMPT_WITH_OPTIONS_HELP_MESSAGE}.\n{user_help}"))
            }
            (true, None) => Some(PROMPT_WITH_OPTIONS_HELP_MESSAGE.to_string()),
            (false, Some(user_help)) => Some(user_help.clone()),
            (false, None) => None,
        }
    }
}

fn suggest_choice(input: &str, choices: &[String]) -> Result<Vec<String>, CustomUserError> {
    Ok(choices
        .iter()
        .filter_map(|s| {
            if s.starts_with(input) {
                Some(s.to_string())
            } else {
                None
            }
        })
        .collect())
}

fn validate_choice(input: &str, choices: &[String]) -> Result<Validation, CustomUserError> {
    if choices.contains(&input.to_string()) {
        Ok(Validation::Valid)
    } else {
        Ok(Validation::Invalid(ErrorMessage::Custom(format!(
            "invalid choice: {input}"
        ))))
    }
}

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

    #[test]
    fn test_parse_missing_template_config() {
        let config = parse_template_config("../../tests/templates/function-template").unwrap();
        assert_eq!(config.prompts.len(), 0);
    }

    #[test]
    fn test_parse_template_config_prompts() {
        let config = parse_template_config("../../tests/templates/config-template").unwrap();
        assert!(config.disable_default_prompts);
        assert_eq!(config.prompts.len(), 9);

        assert_eq!(
            config.prompts["project_description"].message,
            "What is the description of your project?"
        );
        assert_eq!(
            config.prompts["project_description"].default,
            Some(PromptValue::String("My Lambda".to_string()))
        );
        assert_eq!(config.prompts["project_description"].choices, None);

        assert_eq!(
            config.prompts["enable_tracing"].message,
            "Would you like to enable tracing?"
        );
        assert_eq!(
            config.prompts["enable_tracing"].default,
            Some(PromptValue::Boolean(false))
        );
        assert_eq!(config.prompts["enable_tracing"].choices, None);

        assert_eq!(
            config.prompts["runtime"].message,
            "Which runtime would you like to use?"
        );
        assert_eq!(
            config.prompts["runtime"].default,
            Some(PromptValue::String("provided.al2023".to_string()))
        );
        assert_eq!(
            config.prompts["runtime"].choices,
            Some(vec![
                "provided.al2023".to_string(),
                "provided.al2".to_string()
            ])
        );
    }

    #[test]
    fn test_parse_template_config_render_files() {
        let config = parse_template_config("../../tests/templates/config-template").unwrap();
        assert_eq!(
            config.render_files,
            ["Cargo.toml", "README.md", "main.rs"]
                .iter()
                .map(PathBuf::from)
                .collect::<Vec<PathBuf>>()
        );
        assert!(config.render_all_files);
    }

    #[test]
    fn test_parse_template_config_ignore_files() {
        let config = parse_template_config("../../tests/templates/config-template").unwrap();
        assert_eq!(
            config.ignore_files,
            ["README.md"]
                .iter()
                .map(PathBuf::from)
                .collect::<Vec<PathBuf>>()
        );
    }

    #[test]
    fn test_validate_choice() {
        let choices = vec!["a".to_string(), "b".to_string()];
        assert_eq!(validate_choice("a", &choices).unwrap(), Validation::Valid);
        assert_eq!(validate_choice("b", &choices).unwrap(), Validation::Valid);
        assert_eq!(
            validate_choice("c", &choices).unwrap(),
            Validation::Invalid(ErrorMessage::Custom("invalid choice: c".to_string()))
        );
    }

    #[test]
    fn test_suggest_choice() {
        let choices = vec!["a".to_string(), "b".to_string()];
        assert_eq!(
            suggest_choice("a", &choices).unwrap(),
            vec!["a".to_string()]
        );
        assert_eq!(
            suggest_choice("b", &choices).unwrap(),
            vec!["b".to_string()]
        );
    }

    #[test]
    fn test_ask_template_options() {
        let config = parse_template_config("../../tests/templates/config-template").unwrap();
        let variables = config.ask_template_options(true).unwrap();
        assert_eq!(variables.len(), 9);

        assert_eq!(variables["project_description"], "My Lambda");
        assert_eq!(variables["enable_tracing"], false);
        assert_eq!(variables["runtime"], "provided.al2023");
        assert_eq!(variables["architecture"], "x86_64");
        assert_eq!(variables["memory"], "128");
        assert_eq!(variables["timeout"], "3");
        assert_eq!(variables["github_actions"], false);
        assert_eq!(variables["ci_provider"], ".github");
        assert_eq!(variables["license"], "Ignore license");
    }

    #[test]
    fn test_parse_template_config_render_conditions() {
        let config = parse_template_config("../../tests/templates/config-template").unwrap();
        assert_eq!(config.render_conditional_files.len(), 1);
        assert_eq!(
            config.render_conditional_files[".github"].var,
            "github_actions"
        );
        assert_eq!(
            config.render_conditional_files[".github"].r#match,
            Some(PromptValue::Boolean(true))
        );
    }

    #[test]
    fn test_parse_template_config_ignore_conditions() {
        let config = parse_template_config("../../tests/templates/config-template").unwrap();
        assert_eq!(config.ignore_conditional_files.len(), 2);
        assert_eq!(config.ignore_conditional_files["Apache.txt"].var, "license");
        assert_eq!(
            config.ignore_conditional_files["Apache.txt"].not_match,
            Some(PromptValue::String("APACHE".to_string()))
        );
        assert_eq!(config.ignore_conditional_files["MIT.txt"].var, "license");
        assert_eq!(
            config.ignore_conditional_files["MIT.txt"].not_match,
            Some(PromptValue::String("MIT".to_string()))
        );
    }
}