alixt 0.8.0

A command line http API testing utility with TOML configuration
Documentation
// This file is part of alixt.
// Copyright (C) 2025 Devon Harley Offutt
//
// alixt is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.

use colored::Colorize;
use serde_json::Value;

use crate::{
    models::{
        error::AlixtError,
        test_data::{AssertionOutcome, FailureType, TestData},
    },
    reporting::table::{
        BOTTOM_LEFT, BOTTOM_RIGHT, HORIZONTAL, TOP_LEFT, TOP_RIGHT, Table, VERTICAL,
    },
};

pub fn generate_text<W: std::io::Write>(
    writer: &mut W,
    outcome: TestData,
) -> Result<(), AlixtError> {
    writeln!(writer, "[TEST RESULTS]")?;
    for run in outcome.run_data {
        writeln!(writer, "\n[RUN]: '{}'", run.name)?;
        for req in run.outcomes {
            writeln!(
                writer,
                "\n[REQUEST]: '{}',\nTarget: '{}',\nPassed: {},\nBreaking: {},\nDuration: {} seconds,",
                req.name,
                req.url,
                req.passing.is_passing(),
                req.breaking,
                req.duration.as_secs_f64(),
            )?;
            if let Some(body) = req.response_body {
                let body = if let Ok(json) = serde_json::from_str::<Value>(&body) {
                    serde_json::to_string_pretty(&json).unwrap_or(body.clone())
                } else {
                    body
                };
                writeln!(writer, "Body = ```\n{}```", body)?;
            } else {
                writeln!(writer)?;
            }
        }
    }
    Ok(())
}

struct TableData {
    table: Table<5>,
    assertions: Vec<(String, AssertionOutcome)>,
}

pub fn generate_table<W: std::io::Write>(
    writer: &mut W,
    outcome: TestData,
    verbose: bool,
) -> Result<(), AlixtError> {
    let mut tables: Vec<TableData> = vec![];
    let mut passing: u16 = 0;
    let mut failing: u16 = 0;
    for mut run in outcome.run_data {
        let mut failed = false;
        let mut table = TableData {
            table: Table::<5>::new()
                .title(run.name.blue())
                .headers([
                    "".white(),
                    "Result".blue(),
                    "Name".blue(),
                    "Code".blue(),
                    "Duration".blue(),
                ])
                .collect()?,
            assertions: Vec::new(),
        };
        for request in &mut run.outcomes {
            let passed = if request.passing.is_passing() {
                "PASS".green()
            } else {
                table
                    .assertions
                    .push((request.name.clone(), request.passing.take()));
                failed = true;
                "FAIL".red()
            };
            let breaking = if request.breaking && !request.passing.is_passing() {
                "BREAK".red()
            } else {
                "".white()
            };
            let status = if let Some(status) = request.status {
                format!("{status}").yellow()
            } else {
                "".white()
            };
            table.table.push_row([
                breaking,
                passed,
                request.name.yellow(),
                status,
                format!("{}s", request.duration.as_secs_f32()).yellow(),
            ])?;
        }
        if failed {
            failing += 1;
        } else {
            passing += 1;
        }
        tables.push(table);
    }
    let passing_text = format!("{passing}").green();
    let failing_text = format!("{failing}").red();

    let message = vec![
        "All runs finished. ".blue(),
        passing_text,
        " passing, ".blue(),
        failing_text,
        " failing.".blue(),
    ];
    let message_len: usize = message.iter().fold(0, |acc, w| acc + w.len());
    writeln!(
        writer,
        "{}{}{}",
        TOP_LEFT.blue(),
        HORIZONTAL.repeat(message_len).blue(),
        TOP_RIGHT.blue(),
    )?;
    write!(writer, "{}", VERTICAL.blue())?;
    for word in &message {
        write!(writer, "{word}")?;
    }
    writeln!(
        writer,
        "{}\n{}{}{}",
        VERTICAL.blue(),
        BOTTOM_LEFT.blue(),
        HORIZONTAL.repeat(message_len).blue(),
        BOTTOM_RIGHT.blue()
    )?;
    for table in tables {
        table.table.render(writer)?;
        if !verbose || table.assertions.is_empty() {
            continue;
        }
        for (name, assertion_data) in table.assertions {
            let AssertionOutcome::Failed(fails) = assertion_data else {
                continue;
            };
            let mut request_table = Table::<3>::new()
                .title(format!("{name} failed assertions").red())
                .headers(["reason".blue(), "expected".green(), "found".red()])
                .collect()?;
            for fail in fails {
                match fail {
                    FailureType::StatusMismatch { expected, found } => {
                        request_table.push_row([
                            "StatusMismatch".blue(),
                            format!("{}", expected).green(),
                            found.map_or("None".to_string(), |n| n.to_string()).red(),
                        ])?;
                    }
                    FailureType::InvalidJson() => {
                        request_table.push_row([
                            "InvalidJson".blue(),
                            "Json".green(),
                            "Not Json".red(),
                        ])?;
                    },
                    FailureType::JsonMissingField { path } => {
                        request_table.push_row([
                            "JsonMissingField".blue(),
                            path.green(),
                            "None".red(),
                        ])?;
                    },
                    FailureType::JsonExtraField { path } => {
                        request_table.push_row([
                            "JsonExtraField".blue(),
                            "None".green(),
                            path.red(),
                        ])?;
                    },
                    FailureType::JsonValueMismatch {
                        path,
                        expected,
                        found,
                    } => {
                        request_table.push_row([
                            "JsonValueMismatch".blue(),
                            format!("{} = {}", path, expected).green(),
                            found.red(),
                        ])?;
                    },
                    FailureType::JsonRegexMismatch {
                        path,
                        pattern,
                        found,
                    } => {
                        request_table.push_row([
                            "JsonRegexMismatch".blue(),
                            format!("{} = {}", path, pattern).green(),
                            found.red(),
                        ])?;
                    },
                    FailureType::JsonNotString { path } => {
                        request_table.push_row([
                            "JsonNotString".blue(),
                            format!("{} = <String>", path).green(),
                            "Not A String".red(),
                        ])?;
                    },
                }
            }
            request_table.render(writer)?;
        }
    }

    if failing == 0 {
        writeln!(
            writer,
            "\n{}{}{}",
            TOP_LEFT.blue(),
            HORIZONTAL.repeat(7).blue(),
            TOP_RIGHT.blue()
        )?;
        writeln!(
            writer,
            "{}{}{}",
            VERTICAL.blue(),
            "SUCCESS".green(),
            VERTICAL.blue()
        )?;
        writeln!(
            writer,
            "{}{}{}",
            BOTTOM_LEFT.blue(),
            HORIZONTAL.repeat(7).blue(),
            BOTTOM_RIGHT.blue()
        )?;
    } else {
        writeln!(
            writer,
            "\n{}{}{}",
            TOP_LEFT.yellow(),
            HORIZONTAL.repeat(6).yellow(),
            TOP_RIGHT.yellow()
        )?;
        writeln!(
            writer,
            "{}{}{}",
            VERTICAL.yellow(),
            "FAILED".red(),
            VERTICAL.yellow()
        )?;
        writeln!(
            writer,
            "{}{}{}",
            BOTTOM_LEFT.yellow(),
            HORIZONTAL.repeat(6).yellow(),
            BOTTOM_RIGHT.yellow()
        )?;
    }

    Ok(())
}

pub fn generate_json<W: std::io::Write>(
    writer: &mut W,
    outcome: TestData,
) -> Result<(), AlixtError> {
    serde_json::to_writer_pretty(&mut *writer, &outcome)?;
    writeln!(writer)?;
    Ok(())
}