catalyst 0.1.1

A lightweight API testing tool
Documentation
use crate::dsl::models::{Test, TestSuite};
use crate::dsl::parser::parse_tests;
use colored::*;
use reqwest::Client;
use serde_json::Value;
use std::collections::HashMap;

pub struct TestRunner {
    client: Client,
    variables: HashMap<String, String>,
    disable_color: bool,
    results: Vec<(
        String,
        bool,
        u16,
        u16,
        Option<Value>,
        HashMap<String, String>,
    )>,
}

impl TestRunner {
    pub fn new(disable_color: bool) -> Self {
        TestRunner {
            client: Client::new(),
            variables: HashMap::new(),
            disable_color,
            results: Vec::new(),
        }
    }

    fn colorize(&self, text: &str, color: Color) -> String {
        if self.disable_color {
            text.to_string()
        } else {
            text.color(color).bold().to_string()
        }
    }

    fn replace_variables(&self, input: &str) -> String {
        let mut result = input.to_string();
        for (key, value) in &self.variables {
            let placeholder = format!("{{{{{}}}}}", key);
            result = result.replace(&placeholder, value);
        }
        result
    }

    pub async fn execute_tests(&mut self, filter: Option<String>, verbose: bool) {
        let test_suite = match parse_tests() {
            Ok(tests) => tests,
            Err(err) => {
                println!(
                    "{}",
                    self.colorize(&format!("Error parsing tests.toml: {}", err), Color::Red)
                );
                return;
            }
        };

        println!("{}", "────────────────────────────────────────".dimmed());

        for test in &test_suite.tests {
            if let Some(ref name_filter) = filter {
                if &test.name != name_filter {
                    continue;
                }
            }

            let success = self.execute_test(test, &test_suite).await;
            self.results.push((
                test.name.clone(),
                success.0,
                success.1,
                success.2,
                success.3,
                success.4,
            ));
        }

        self.print_results(verbose);
    }

    async fn execute_test(
        &mut self,
        test: &Test,
        test_suite: &TestSuite,
    ) -> (bool, u16, u16, Option<Value>, HashMap<String, String>) {
        let url = format!(
            "{}{}",
            test_suite.config.base_url,
            self.replace_variables(&test.endpoint)
        );
        let mut request = self.client.request(test.method.parse().unwrap(), &url);
        let mut headers = test_suite
            .config
            .default_headers
            .clone()
            .unwrap_or_default();

        if let Some(test_headers) = &test.headers {
            for (key, value) in test_headers {
                headers.insert(key.clone(), value.clone());
            }
        }

        for (_key, value) in headers.iter_mut() {
            *value = self.replace_variables(value);
        }

        for (key, value) in &headers {
            request = request.header(key, value);
        }

        if let Some(query_params) = &test.query_params {
            let mut query_string = Vec::new();
            for (key, value) in query_params {
                query_string.push((key.clone(), self.replace_variables(value)));
            }
            request = request.query(&query_string);
        }

        if let Some(body) = &test.body {
            let body_json = self.replace_variables(&body.to_string());
            request = request.body(body_json);
        }

        match request.send().await {
            Ok(response) => self.process_response(test, response).await,
            Err(_) => {
                println!(
                    "{}",
                    self.colorize(
                        &format!("Test `{}` failed: Request error", test.name),
                        Color::Red
                    )
                );
                (false, 0, 0, None, HashMap::new())
            }
        }
    }

    async fn process_response(
        &mut self,
        test: &Test,
        response: reqwest::Response,
    ) -> (bool, u16, u16, Option<Value>, HashMap<String, String>) {
        let status = response.status().as_u16();
        let expected_status = test.expected_status;
        let headers = response.headers().clone();
        let mut headers_map = HashMap::new();
        for (key, value) in headers.iter() {
            headers_map.insert(key.to_string(), value.to_str().unwrap_or("").to_string());
        }

        let body: Value = match response.json().await {
            Ok(json) => json,
            Err(_) => Value::Null,
        };

        let success = status == expected_status;

        if let Some(store) = &test.store {
            for (json_path, variable_name) in store {
                if let Some(value) = self.extract_json_value(&body, json_path) {
                    self.variables.insert(variable_name.clone(), value);
                }
            }
        }

        if let Some(get_cookie) = &test.get_cookie {
            for (cookie_name, variable_name) in get_cookie {
                if let Some(set_cookie) = headers.get("set-cookie") {
                    if let Ok(cookie_str) = set_cookie.to_str() {
                        if let Some(cookie_value) =
                            self.extract_cookie_value(cookie_str, cookie_name)
                        {
                            self.variables.insert(variable_name.clone(), cookie_value);
                        }
                    }
                }
            }
        }

        (success, expected_status, status, Some(body), headers_map)
    }

    fn print_results(&self, verbose: bool) {
        let mut failed_tests = Vec::new();

        for (test_name, success, expected_status, actual_status, body, headers) in &self.results {
            let status_display =
                format!("({} {})", actual_status, self.status_text(*actual_status));

            if *success {
                println!(
                    "[PASS] {:<25} {}",
                    test_name,
                    self.colorize(&status_display, Color::Green)
                );
            } else {
                println!(
                    "[FAIL] {:<25} {}",
                    test_name,
                    self.colorize(&status_display, Color::Red)
                );
                println!(
                    "       Expected: {} {}",
                    expected_status,
                    self.status_text(*expected_status)
                );
                println!(
                    "       Got:      {} {}",
                    actual_status,
                    self.status_text(*actual_status)
                );
                failed_tests.push(test_name.clone());

                if verbose {
                    println!("       Headers: {:?}", headers);
                    if let Some(b) = body {
                        println!("       Body: {}", b);
                    }
                }
            }
        }

        println!("{}", "────────────────────────────────────────".dimmed());
        self.print_summary();

        if !failed_tests.is_empty() {
            println!("\nFailed Tests:");
            for test in failed_tests {
                println!("  - {}", test);
            }
        }
    }

    fn print_summary(&self) {
        let passed = self
            .results
            .iter()
            .filter(|(_, status, _, _, _, _)| *status)
            .count();
        let failed = self.results.len() - passed;

        let summary = format!("Summary: {} passed, {} failed", passed, failed);
        if failed > 0 {
            println!("{}", self.colorize(&summary, Color::Red));
        } else {
            println!("{}", self.colorize(&summary, Color::Green));
        }
    }

    fn extract_json_value(&self, json: &Value, path: &str) -> Option<String> {
        let parts: Vec<&str> = path.split('.').collect();
        let mut current = json;
        for part in parts {
            if let Some(obj) = current.as_object() {
                current = obj.get(part)?;
            } else {
                return None;
            }
        }

        match current {
            Value::String(s) => Some(s.clone()),
            _ => Some(current.to_string().trim_matches('"').to_string()),
        }
    }

    fn status_text(&self, status: u16) -> &'static str {
        match status {
            200 => "OK",
            201 => "Created",
            204 => "No Content",
            400 => "Bad Request",
            401 => "Unauthorized",
            403 => "Forbidden",
            404 => "Not Found",
            405 => "Method Not Allowed",
            409 => "Conflict",
            500 => "Internal Server Error",
            _ => "Unknown",
        }
    }

    fn extract_cookie_value(&self, cookie_header: &str, cookie_name: &str) -> Option<String> {
        for cookie in cookie_header.split(',') {
            let parts: Vec<&str> = cookie.trim().split(';').collect();
            if let Some(main_cookie) = parts.first() {
                let key_value: Vec<&str> = main_cookie.split('=').collect();
                if key_value.len() == 2 && key_value[0].trim() == cookie_name {
                    return Some(main_cookie.trim().to_string());
                }
            }
        }
        None
    }
}