duckdb-slt 0.1.8

Command-line sqllogictest runner for DuckDB.
use std::io::{IsTerminal, Write};
use std::path::Path;

use anyhow::{Context, Result};
use sqllogictest::Runner;
use sqllogictest::runner::TestErrorKind;

use crate::cli::Cli;
use crate::pathing::{expand_files, format_user_path, normalize_path};
use crate::preprocessor::preprocess_file;
use crate::reporting::{
    RecordMetadataCache, format_error, format_failed, format_ok, render_failure_report,
};
use crate::runtime::{compile_extensions, open_driver};

pub(crate) const EXIT_OK: u8 = 0;
pub(crate) const EXIT_RUNTIME_ERROR: u8 = 1;
pub(crate) const EXIT_TEST_FAIL: u8 = 2;

#[allow(clippy::ptr_arg)]
fn validate_query_column_count(
    actual: &Vec<sqllogictest::DefaultColumnType>,
    expected: &Vec<sqllogictest::DefaultColumnType>,
) -> bool {
    actual.len() == expected.len()
}

enum FileRunError {
    TestFailure(String),
    Runtime(anyhow::Error),
}

pub(crate) fn run(cli: Cli) -> Result<u8> {
    if let Some(workdir) = &cli.workdir {
        std::env::set_current_dir(workdir)
            .with_context(|| format!("failed to set workdir: {}", workdir.display()))?;
    }

    let base_dir = std::env::current_dir()?;

    let files = expand_files(&cli.files)?
        .into_iter()
        .map(|path| normalize_path(&path))
        .collect::<Result<Vec<_>>>()?;

    println!("running {} tests", files.len());
    let started = std::time::Instant::now();
    let use_color = std::io::stdout().is_terminal();

    let mut files_passed = 0usize;
    let mut files_failed = 0usize;
    let mut files_errored = 0usize;

    let mut failed_files: Vec<String> = Vec::new();
    let mut errored_files: Vec<String> = Vec::new();

    for path in files {
        let display_path = format_user_path(&base_dir, &path);
        let res = run_one_file(&cli, &base_dir, &path);
        match res {
            Ok(()) => {
                files_passed += 1;
                println!("test {display_path} ... {}", format_ok(use_color));
            }
            Err(FileRunError::TestFailure(err)) => {
                files_failed += 1;
                failed_files.push(display_path.clone());
                println!("test {display_path} ... {}", format_failed(use_color));
                let _ = std::io::stdout().flush();
                eprintln!("{err}");

                if cli.fail_fast {
                    break;
                }
            }
            Err(FileRunError::Runtime(err)) => {
                files_errored += 1;
                errored_files.push(display_path.clone());
                println!("test {display_path} ... {}", format_error(use_color));
                let _ = std::io::stdout().flush();
                eprintln!("{err:?}");
                break;
            }
        }
    }

    if !failed_files.is_empty() || !errored_files.is_empty() {
        println!("\nfailures:\n");
        for file in &failed_files {
            println!("    {file}");
        }
        for file in &errored_files {
            println!("    {file}");
        }
        println!();
    }

    let exit_code = if files_errored > 0 {
        EXIT_RUNTIME_ERROR
    } else if files_failed > 0 {
        EXIT_TEST_FAIL
    } else {
        EXIT_OK
    };

    let status = if files_failed == 0 && files_errored == 0 {
        format_ok(use_color)
    } else {
        format_failed(use_color)
    };
    println!(
        "test result: {status}. {files_passed} passed; {files_failed} failed; {files_errored} errored; 0 ignored; 0 measured; 0 filtered out; finished in {:.2}s",
        started.elapsed().as_secs_f64()
    );

    Ok(exit_code)
}

fn run_one_file(cli: &Cli, base_dir: &Path, path: &Path) -> std::result::Result<(), FileRunError> {
    if !path.exists() {
        return Err(FileRunError::Runtime(anyhow::anyhow!(
            "file does not exist: {}",
            path.display()
        )));
    }

    let db = cli.db.clone();
    let allow_unsigned_extensions = cli.allow_unsigned_extensions;
    let extensions = compile_extensions(&cli.extensions).map_err(FileRunError::Runtime)?;

    let preprocessed = preprocess_file(path).map_err(FileRunError::Runtime)?;
    let run_path = preprocessed
        .as_ref()
        .map(|prep| prep.preprocessed_path().to_path_buf())
        .unwrap_or_else(|| path.to_path_buf());
    let record_metadata = RecordMetadataCache::build(&run_path);

    let required_extensions = preprocessed
        .as_ref()
        .map(|prep| prep.directives.required_extensions.clone())
        .unwrap_or_default();

    let mut runner = Runner::new(move || {
        let db = db.clone();
        let extensions = extensions.clone();
        let required_extensions = required_extensions.clone();

        async move {
            open_driver(
                db.as_deref(),
                allow_unsigned_extensions,
                &extensions,
                &required_extensions,
            )
        }
    });

    runner.with_column_validator(validate_query_column_count);

    match runner.run_file(&run_path) {
        Ok(()) => Ok(()),
        Err(test_err) => match test_err.kind() {
            TestErrorKind::ParseError(_) => {
                let mut details = test_err.display(false).to_string();
                if let Some(preprocessed) = preprocessed.as_ref() {
                    let from = preprocessed.preprocessed_path().to_string_lossy();
                    let to = path.to_string_lossy();
                    details = details.replace(from.as_ref(), to.as_ref());
                }

                Err(FileRunError::Runtime(anyhow::anyhow!(
                    "parse error in {}: {}",
                    path.display(),
                    details
                )))
            }
            _ => {
                let parse_main_file = preprocessed
                    .as_ref()
                    .map(|prep| prep.preprocessed_path())
                    .unwrap_or(path);

                Err(FileRunError::TestFailure(render_failure_report(
                    path,
                    parse_main_file,
                    base_dir,
                    &test_err,
                    record_metadata.as_ref(),
                )))
            }
        },
    }
}