hen 0.8.0

Run API collections from the command line.
/// Parses the contents of a collection file into a Collection struct.
use std::{collections::HashMap, hash::Hash, path::PathBuf};

use pest::Parser;
use pest_derive::Parser;

pub mod context;
mod preprocessor;

use crate::{
    collection::Collection,
    request::{FormDataType, Request},
};

#[derive(Parser)]
#[grammar = "src/parser/grammar.pest"]
struct CollectionParser;

pub fn parse_collection(
    input: &str,
    working_dir: PathBuf,
) -> Result<Collection, pest::error::Error<Rule>> {
    let preprocessed = preprocessor::preprocess(input, working_dir.clone()).map_err(|e| {
        pest::error::Error::new_from_span(
            pest::error::ErrorVariant::CustomError {
                message: e.to_string(),
            },
            pest::Span::new(input, 0, input.len()).unwrap(),
        )
    })?;

    log::debug!("PREPROCESSED COMPLETE:\n{}", preprocessed);

    let mut pairs = CollectionParser::parse(Rule::request_collection, preprocessed.as_str())?;

    let collection = pairs.next().unwrap();

    let mut name = String::new();
    let mut description = String::new();
    let mut requests = Vec::new();
    let mut context = HashMap::new();
    let mut global_headers: HashMap<String, String> = HashMap::new();
    let mut global_queries: HashMap<String, String> = HashMap::new();
    let mut global_callbacks: Vec<String> = vec![];

    for pair in collection.into_inner() {
        match pair.as_rule() {
            Rule::collection_name => {
                name = pair.as_str().trim().to_string();
            }

            Rule::collection_description => {
                description.push_str(pair.as_str().trim());
            }

            Rule::variable => {
                let inner_pairs = pair.into_inner();
                let key = inner_pairs
                    .clone()
                    .next()
                    .unwrap()
                    .as_str()
                    .trim()
                    .to_string();
                let value = inner_pairs.clone().nth(1).unwrap().as_str().to_string();

                // if value is a shell script, evaluate it
                if value.starts_with("$(") {
                    let script = value.trim_start_matches("$(").trim_end_matches(")");
                    let value = eval_shell_script(script, &working_dir, None)
                        .trim()
                        .to_string();
                    context.insert(key, value);
                    continue;
                }

                context.insert(key, context::inject_from_prompt(&value));
            }

            Rule::header => {
                let mut inner_pairs = pair.into_inner();
                let key = inner_pairs.next().unwrap().as_str().trim().to_string();
                let value = inner_pairs.next().unwrap().as_str().trim().to_string();

                global_headers.insert(key, context::inject_from_prompt(&value));
            }

            Rule::query => {
                let mut inner_pairs = pair.into_inner();
                let key = inner_pairs.next().unwrap().as_str().trim().to_string();
                let value = inner_pairs.next().unwrap().as_str().trim().to_string();

                global_queries.insert(key, context::inject_from_prompt(&value));
            }

            Rule::callback => {
                // drop the leading "!" character
                global_callbacks.push(pair.as_str().strip_prefix('!').unwrap().to_string());
            }

            Rule::requests => {
                for request_pair in pair.into_inner() {
                    requests.push(parse_request(
                        request_pair,
                        context.clone(),
                        global_headers.clone(),
                        global_queries.clone(),
                        global_callbacks.clone(),
                        &working_dir,
                    ));
                }
            }

            _ => {
                unreachable!("unexpected rule: {:?}", pair.as_rule());
            }
        }
    }

    Ok(Collection {
        name,
        description,
        requests,
    })
}

pub fn parse_request(
    pair: pest::iterators::Pair<Rule>,
    collection_context: HashMap<String, String>,
    global_headers: HashMap<String, String>,
    global_queries: HashMap<String, String>,
    global_callbacks: Vec<String>,
    working_dir: &PathBuf,
) -> Request {
    let mut method = None;
    let mut url = None;
    let mut headers = HashMap::new();
    let mut query_params = HashMap::new();
    let mut body = None;
    let mut body_content_type = None;
    let mut description = String::new();
    let mut form_data: HashMap<String, FormDataType> = HashMap::new();
    let mut callback_src: Vec<String> = global_callbacks;

    // add global headers to request headers
    global_headers.iter().for_each(|(key, value)| {
        headers.insert(
            key.clone(),
            context::inject_from_variable(value, &collection_context),
        );
    });

    // add global queries to request queries
    global_queries.iter().for_each(|(key, value)| {
        query_params.insert(
            key.clone(),
            context::inject_from_variable(value, &collection_context),
        );
    });

    for pair in pair.into_inner() {
        match pair.as_rule() {
            Rule::description => {
                // push description to string
                description.push_str(pair.as_str().trim());
            }
            Rule::http_method => {
                method = Some(pair.as_str().parse().unwrap());
            }
            Rule::url => {
                url = Some(context::inject_from_variable(
                    pair.as_str(),
                    &collection_context,
                ));
            }
            Rule::header => {
                let mut inner_pairs = pair.into_inner();
                let key = inner_pairs.next().unwrap().as_str().trim().to_string();
                let value = context::inject_from_variable(
                    inner_pairs.next().unwrap().as_str().trim(),
                    &collection_context,
                );

                headers.insert(key, value);
            }
            Rule::query => {
                let mut inner_pairs = pair.into_inner();
                let key = inner_pairs.next().unwrap().as_str().trim().to_string();
                let value = context::inject_from_variable(
                    inner_pairs.next().unwrap().as_str().trim(),
                    &collection_context,
                );
                query_params.insert(key, value);
            }
            Rule::form => {
                let mut inner_pairs = pair.into_inner();
                let key = inner_pairs.next().unwrap().as_str().trim().to_string();

                // the value can be either a File of Text type
                let value = inner_pairs.next().unwrap();
                match value.as_rule() {
                    Rule::file => {
                        // drop the first character from the filepath which is a "@" symbol
                        let trimmed = value.as_str().trim_start_matches('@');
                        let abs_path = working_dir.join(trimmed);
                        form_data.insert(key, FormDataType::File(abs_path));
                    }
                    Rule::text => {
                        // inject variables into the text
                        let value =
                            context::inject_from_variable(value.as_str(), &collection_context);
                        form_data.insert(key, FormDataType::Text(value));
                    }
                    _ => {
                        unreachable!("unexpected rule: {:?}", value.as_rule());
                    }
                }
            }
            Rule::body => {
                body = Some(context::inject_from_variable(
                    pair.as_str(),
                    &collection_context,
                ));
            }
            Rule::body_content_type => {
                body_content_type = Some(pair.as_str().to_string());
            }
            Rule::callback => {
                // drop the leading "!" character
                callback_src.push(pair.as_str().strip_prefix('!').unwrap().to_string());
            }
            _ => {
                unreachable!("unexpected rule: {:?}", pair.as_rule());
            }
        }
    }

    // if the description is empty, set it to "No description"
    if description.is_empty() {
        description = "[No Description]".to_string();
    }

    Request {
        description,
        method: method.unwrap(),
        url: url.unwrap(),
        headers,
        query_params,
        form_data,
        body,
        body_content_type,
        callback_src,
        working_dir: working_dir.clone(),
    }
}

pub fn eval_shell_script(
    script: &str,
    working_dir: &PathBuf,
    env: Option<HashMap<String, String>>,
) -> String {
    let env = env.unwrap_or_default();
    log::debug!("evaluating shell script: {}", script);
    log::debug!("using directory {:?}", working_dir);
    let output = std::process::Command::new("sh")
        .current_dir(working_dir)
        .arg("-c")
        .envs(env)
        .arg(script)
        .output()
        .expect("failed to execute process");

    String::from_utf8(output.stdout).unwrap()
}