checkmate-cli 0.4.1

Checkmate - API Testing Framework CLI
//! HTTP request executor

use std::cell::RefCell;
use std::collections::HashMap;
use std::time::{Duration, Instant};

use reqwest::blocking::{Client, Response};
use reqwest::header::{HeaderMap, HeaderName, HeaderValue};

use super::spec::{EnvConfig, TestSpec};
use super::RunnerError;

/// HTTP request executor
pub struct TestExecutor {
    client: Client,
    base_url: String,
    default_timeout: Duration,
    variables: RefCell<HashMap<String, String>>,
}

/// Result of executing a single request
#[derive(Debug, Clone)]
pub struct RequestResult {
    pub status: u16,
    pub body: serde_json::Value,
    pub duration_ms: u64,
    pub headers: HashMap<String, String>,
}

impl TestExecutor {
    /// Create a new executor from environment config
    pub fn new(env: &EnvConfig) -> Result<Self, RunnerError> {
        let base_url = env
            .base_url
            .clone()
            .or_else(|| std::env::var("BASE_URL").ok())
            .unwrap_or_else(|| "http://localhost:8080".to_string());

        let base_url = expand_env_vars(&base_url);

        let timeout_ms = env.timeout_ms.unwrap_or(30000);
        let default_timeout = Duration::from_millis(timeout_ms);

        let client = Client::builder()
            .timeout(default_timeout)
            .build()
            .map_err(|e| RunnerError::HttpError(e))?;

        Ok(Self {
            client,
            base_url,
            default_timeout,
            variables: RefCell::new(HashMap::new()),
        })
    }

    /// Execute a single request
    pub fn execute(
        &self,
        spec: &TestSpec,
        request_name: &str,
        endpoint: &str,
        method: &str,
        timeout_override: Option<u64>,
    ) -> Result<RequestResult, RunnerError> {
        let request = spec.resolve_request(request_name)?;

        let url = format!("{}{}", self.base_url, self.expand_all(endpoint));

        let timeout = timeout_override
            .map(Duration::from_millis)
            .unwrap_or(self.default_timeout);

        let mut headers = HeaderMap::new();
        for (key, value) in &request.headers {
            let expanded = self.expand_all(value);
            let header_name = HeaderName::try_from(key.as_str())
                .map_err(|e| RunnerError::ParseError(format!("Invalid header name '{}': {}", key, e)))?;
            let header_value = HeaderValue::try_from(expanded.as_str())
                .map_err(|e| RunnerError::ParseError(format!("Invalid header value '{}': {}", value, e)))?;
            headers.insert(header_name, header_value);
        }

        // Add default Content-Type if not present and we have a body
        if request.body.is_some() && !headers.contains_key("content-type") {
            headers.insert(
                HeaderName::from_static("content-type"),
                HeaderValue::from_static("application/json"),
            );
        }

        let start = Instant::now();

        let mut req_builder = match method.to_uppercase().as_str() {
            "GET" => self.client.get(&url),
            "POST" => self.client.post(&url),
            "PUT" => self.client.put(&url),
            "PATCH" => self.client.patch(&url),
            "DELETE" => self.client.delete(&url),
            _ => return Err(RunnerError::ParseError(format!("Unknown HTTP method: {}", method))),
        };

        req_builder = req_builder.headers(headers).timeout(timeout);

        // Add query params
        if !request.query_params.is_empty() {
            let params: Vec<(String, String)> = request
                .query_params
                .iter()
                .map(|(k, v)| (self.expand_all(k), self.expand_all(v)))
                .collect();
            req_builder = req_builder.query(&params);
        }

        // Add body (with env var + variable expansion)
        if let Some(ref body) = request.body {
            req_builder = req_builder.json(&self.expand_all_in_json(body));
        }

        let response = req_builder.send()?;
        let duration_ms = start.elapsed().as_millis() as u64;

        self.process_response(response, duration_ms)
    }

    /// Expand both env vars (${VAR}) and extracted variables ({{var}})
    fn expand_all(&self, input: &str) -> String {
        let env_expanded = expand_env_vars(input);
        expand_variables(&env_expanded, &self.variables.borrow())
    }

    /// Recursively expand both env vars and extracted variables in JSON
    fn expand_all_in_json(&self, value: &serde_json::Value) -> serde_json::Value {
        match value {
            serde_json::Value::String(s) => serde_json::Value::String(self.expand_all(s)),
            serde_json::Value::Object(map) => {
                let expanded: serde_json::Map<String, serde_json::Value> = map
                    .iter()
                    .map(|(k, v)| (self.expand_all(k), self.expand_all_in_json(v)))
                    .collect();
                serde_json::Value::Object(expanded)
            }
            serde_json::Value::Array(arr) => {
                serde_json::Value::Array(arr.iter().map(|v| self.expand_all_in_json(v)).collect())
            }
            other => other.clone(),
        }
    }

    /// Extract variables from a response body using Clove queries
    pub fn extract_variables(
        &self,
        extractions: &HashMap<String, String>,
        response_body: &serde_json::Value,
    ) -> Result<(), RunnerError> {
        use clove_lang::{Evaluator, Lexer, Parser};

        let clove_data = clove_lang::json_to_clove(response_body.clone());
        let mut vars = self.variables.borrow_mut();

        for (var_name, query) in extractions {
            let lexer = Lexer::new(query);
            let mut parser = Parser::new(lexer)
                .map_err(|e| RunnerError::CloveError(format!("Extract '{}': {}", var_name, e)))?;
            let parsed = parser.parse()
                .map_err(|e| RunnerError::CloveError(format!("Extract '{}': {}", var_name, e)))?;

            let result = Evaluator::new()
                .eval_expression(&parsed, clove_data.clone())
                .map_err(|e| RunnerError::CloveError(format!("Extract '{}': {}", var_name, e)))?;

            let json_val = clove_lang::clove_to_json(result);
            let string_val = match &json_val {
                serde_json::Value::String(s) => s.clone(),
                other => other.to_string(),
            };

            vars.insert(var_name.clone(), string_val);
        }

        Ok(())
    }

    fn process_response(&self, response: Response, duration_ms: u64) -> Result<RequestResult, RunnerError> {
        let status = response.status().as_u16();

        let headers: HashMap<String, String> = response
            .headers()
            .iter()
            .map(|(k, v)| (k.to_string(), v.to_str().unwrap_or("").to_string()))
            .collect();

        let body: serde_json::Value = response.json().unwrap_or(serde_json::Value::Null);

        Ok(RequestResult {
            status,
            body,
            duration_ms,
            headers,
        })
    }
}

/// Expand environment variables in a string using ${VAR} or ${VAR:-default} syntax
pub(crate) fn expand_env_vars(input: &str) -> String {
    let mut result = input.to_string();

    // Pattern: ${VAR} or ${VAR:-default}
    let re = regex::Regex::new(r"\$\{([A-Za-z_][A-Za-z0-9_]*)(?::-([^}]*))?\}").unwrap();

    for cap in re.captures_iter(input) {
        let full_match = cap.get(0).unwrap().as_str();
        let var_name = cap.get(1).unwrap().as_str();
        let default = cap.get(2).map(|m| m.as_str());

        let value = std::env::var(var_name)
            .ok()
            .or_else(|| default.map(|s| s.to_string()))
            .unwrap_or_default();

        result = result.replace(full_match, &value);
    }

    result
}

/// Expand extracted variables using {{var}} syntax
fn expand_variables(input: &str, variables: &HashMap<String, String>) -> String {
    let re = regex::Regex::new(r"\{\{([A-Za-z_][A-Za-z0-9_]*)\}\}").unwrap();
    let mut result = input.to_string();

    for cap in re.captures_iter(input) {
        let full_match = cap.get(0).unwrap().as_str();
        let var_name = cap.get(1).unwrap().as_str();
        if let Some(value) = variables.get(var_name) {
            result = result.replace(full_match, value);
        }
    }

    result
}

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

    #[test]
    fn test_expand_env_vars_simple() {
        // SAFETY: Test isolation - running single-threaded
        unsafe {
            std::env::set_var("TEST_VAR", "test_value");
        }
        assert_eq!(expand_env_vars("${TEST_VAR}"), "test_value");
        unsafe {
            std::env::remove_var("TEST_VAR");
        }
    }

    #[test]
    fn test_expand_env_vars_with_default() {
        // SAFETY: Test isolation - running single-threaded
        unsafe {
            std::env::remove_var("NONEXISTENT_VAR");
        }
        assert_eq!(expand_env_vars("${NONEXISTENT_VAR:-default}"), "default");
    }

    #[test]
    fn test_expand_env_vars_default_not_used() {
        // SAFETY: Test isolation - running single-threaded
        unsafe {
            std::env::set_var("EXISTS_VAR", "actual");
        }
        assert_eq!(expand_env_vars("${EXISTS_VAR:-default}"), "actual");
        unsafe {
            std::env::remove_var("EXISTS_VAR");
        }
    }

    #[test]
    fn test_expand_variables_basic() {
        let mut vars = HashMap::new();
        vars.insert("token".to_string(), "abc123".to_string());
        assert_eq!(expand_variables("Bearer {{token}}", &vars), "Bearer abc123");
    }

    #[test]
    fn test_expand_variables_missing() {
        let vars = HashMap::new();
        assert_eq!(expand_variables("{{missing}}", &vars), "{{missing}}");
    }

    #[test]
    fn test_expand_variables_multiple() {
        let mut vars = HashMap::new();
        vars.insert("host".to_string(), "api.example.com".to_string());
        vars.insert("id".to_string(), "42".to_string());
        assert_eq!(
            expand_variables("https://{{host}}/users/{{id}}", &vars),
            "https://api.example.com/users/42"
        );
    }

    #[test]
    fn test_extract_variables_from_json() {
        let executor = make_test_executor();

        let body = serde_json::json!({
            "token": "secret-jwt-token",
            "user": { "id": 42 }
        });

        let mut extractions = HashMap::new();
        extractions.insert("auth_token".to_string(), "$[token]".to_string());
        extractions.insert("user_id".to_string(), "$[user][id]".to_string());

        executor.extract_variables(&extractions, &body).unwrap();

        let vars = executor.variables.borrow();
        assert_eq!(vars.get("auth_token").unwrap(), "secret-jwt-token");
        assert_eq!(vars.get("user_id").unwrap(), "42");
    }

    fn make_test_executor() -> TestExecutor {
        let _ = rustls::crypto::ring::default_provider().install_default();
        let env = EnvConfig { base_url: Some("http://localhost".to_string()), timeout_ms: None };
        TestExecutor::new(&env).unwrap()
    }

    #[test]
    fn test_expand_all_in_json_string_values() {
        let executor = make_test_executor();
        unsafe {
            std::env::set_var("JSON_USER", "alice");
        }
        let input = serde_json::json!({
            "username": "${JSON_USER}",
            "static": "unchanged"
        });
        let result = executor.expand_all_in_json(&input);
        assert_eq!(result["username"], "alice");
        assert_eq!(result["static"], "unchanged");
        unsafe {
            std::env::remove_var("JSON_USER");
        }
    }

    #[test]
    fn test_expand_all_in_json_nested() {
        let executor = make_test_executor();
        unsafe {
            std::env::set_var("NESTED_VAL", "deep");
        }
        let input = serde_json::json!({
            "outer": {
                "inner": "${NESTED_VAL}",
                "list": ["${NESTED_VAL}", "static"]
            }
        });
        let result = executor.expand_all_in_json(&input);
        assert_eq!(result["outer"]["inner"], "deep");
        assert_eq!(result["outer"]["list"][0], "deep");
        assert_eq!(result["outer"]["list"][1], "static");
        unsafe {
            std::env::remove_var("NESTED_VAL");
        }
    }

    #[test]
    fn test_expand_all_in_json_non_string() {
        let executor = make_test_executor();
        let input = serde_json::json!({
            "count": 42,
            "enabled": true,
            "nothing": null,
            "price": 9.99
        });
        let result = executor.expand_all_in_json(&input);
        assert_eq!(result, input);
    }

    #[test]
    fn test_expand_env_vars_in_url() {
        // SAFETY: Test isolation - running single-threaded
        unsafe {
            std::env::set_var("BASE_HOST", "localhost");
            std::env::set_var("PORT", "8080");
        }
        assert_eq!(
            expand_env_vars("http://${BASE_HOST}:${PORT}/api"),
            "http://localhost:8080/api"
        );
        unsafe {
            std::env::remove_var("BASE_HOST");
            std::env::remove_var("PORT");
        }
    }
}