duckdb-slt 0.1.8

Command-line sqllogictest runner for DuckDB.
use std::collections::HashMap;
use std::path::Path;

use sqllogictest::runner::TestErrorKind;
use sqllogictest::{QueryExpect, Record};

use crate::pathing::format_user_path_str;

pub(crate) fn format_ok(use_color: bool) -> &'static str {
    if use_color { "\x1b[32mok\x1b[0m" } else { "ok" }
}

pub(crate) fn format_failed(use_color: bool) -> &'static str {
    if use_color {
        "\x1b[31mFAILED\x1b[0m"
    } else {
        "FAILED"
    }
}

pub(crate) fn format_error(use_color: bool) -> &'static str {
    if use_color {
        "\x1b[31mERROR\x1b[0m"
    } else {
        "ERROR"
    }
}

#[derive(Debug, Clone, PartialEq, Eq)]
struct RecordId {
    index_1_based: usize,
    name: Option<String>,
}

#[derive(Debug, Clone, Default)]
pub(crate) struct RecordMetadataCache {
    by_file_and_line: HashMap<String, HashMap<u32, RecordId>>,
}

impl RecordMetadataCache {
    pub(crate) fn build(main_file: &Path) -> Option<Self> {
        let records =
            sqllogictest::parse_file::<sqllogictest::DefaultColumnType>(main_file).ok()?;

        let mut cache = Self::default();
        let mut index = 0usize;

        for record in records {
            match record {
                Record::Statement {
                    loc: record_loc, ..
                }
                | Record::System {
                    loc: record_loc, ..
                } => {
                    index += 1;
                    cache.insert(&record_loc, index, None);
                }
                Record::Query {
                    loc: record_loc,
                    expected,
                    ..
                } => {
                    index += 1;
                    let name = match expected {
                        QueryExpect::Results { label, .. } => label,
                        QueryExpect::Error(_) => None,
                    };
                    cache.insert(&record_loc, index, name);
                }
                _ => {}
            }
        }

        Some(cache)
    }

    fn insert(&mut self, loc: &sqllogictest::Location, index_1_based: usize, name: Option<String>) {
        self.by_file_and_line
            .entry(loc.file().to_string())
            .or_default()
            .insert(
                loc.line(),
                RecordId {
                    index_1_based,
                    name,
                },
            );
    }

    fn find(&self, loc: &sqllogictest::Location) -> Option<RecordId> {
        self.by_file_and_line
            .get(loc.file())
            .and_then(|entries| entries.get(&loc.line()))
            .cloned()
    }
}

pub(crate) fn render_failure_report(
    display_main_file: &Path,
    parse_main_file: &Path,
    base_dir: &Path,
    test_err: &sqllogictest::TestError,
    record_metadata: Option<&RecordMetadataCache>,
) -> String {
    use std::fmt::Write;

    let kind = test_err.kind();
    let loc = test_err.location();
    let record_id = record_metadata.and_then(|metadata| metadata.find(&loc));

    let parse_main_file_str = parse_main_file.to_string_lossy();
    let display_loc_file = if loc.file() == parse_main_file_str {
        display_main_file.to_string_lossy().to_string()
    } else {
        loc.file().to_string()
    };

    let mut out = String::new();
    writeln!(
        out,
        "  at: {}:{}",
        format_user_path_str(base_dir, &display_loc_file),
        loc.line()
    )
    .expect("writing to String should not fail");
    if let Some(id) = &record_id {
        writeln!(
            out,
            "  record: {}{}",
            id.index_1_based,
            id.name
                .as_deref()
                .map(|n| format!(" name={n}"))
                .unwrap_or_default()
        )
        .expect("writing to String should not fail");
    }

    let sql = match &kind {
        TestErrorKind::Ok { sql, .. }
        | TestErrorKind::Fail { sql, .. }
        | TestErrorKind::ErrorMismatch { sql, .. }
        | TestErrorKind::StatementResultMismatch { sql, .. }
        | TestErrorKind::QueryResultMismatch { sql, .. }
        | TestErrorKind::QueryResultColumnsMismatch { sql, .. } => Some(sql.as_str()),
        TestErrorKind::ParseError(_)
        | TestErrorKind::SystemFail { .. }
        | TestErrorKind::SystemStdoutMismatch { .. } => None,
        _ => None,
    };

    if let Some(sql) = sql {
        writeln!(out, "sql:\n{sql}").expect("writing to String should not fail");
    }

    match &kind {
        TestErrorKind::QueryResultMismatch {
            expected, actual, ..
        } => {
            writeln!(out, "expected: {expected}").expect("writing to String should not fail");
            writeln!(out, "actual: {actual}").expect("writing to String should not fail");
        }
        TestErrorKind::QueryResultColumnsMismatch {
            expected, actual, ..
        } => {
            let expected_count = expected.chars().count();
            let actual_count = actual.chars().count();
            writeln!(
                out,
                "details: Expected {expected_count} columns, but got {actual_count} columns"
            )
            .expect("writing to String should not fail");
            writeln!(out, "expected_columns: {expected}")
                .expect("writing to String should not fail");
            writeln!(out, "actual_columns: {actual}").expect("writing to String should not fail");
        }
        TestErrorKind::ErrorMismatch {
            expected_err,
            err,
            actual_sqlstate,
            ..
        } => {
            writeln!(out, "expected_error: {expected_err}")
                .expect("writing to String should not fail");
            if let Some(sqlstate) = actual_sqlstate {
                writeln!(out, "actual_sqlstate: {sqlstate}")
                    .expect("writing to String should not fail");
            }
            writeln!(out, "actual_error: {err}").expect("writing to String should not fail");
        }
        TestErrorKind::StatementResultMismatch {
            expected, actual, ..
        } => {
            writeln!(out, "expected_rows: {expected}").expect("writing to String should not fail");
            writeln!(out, "actual_rows: {actual}").expect("writing to String should not fail");
        }
        TestErrorKind::Ok { .. }
        | TestErrorKind::Fail { .. }
        | TestErrorKind::SystemFail { .. }
        | TestErrorKind::SystemStdoutMismatch { .. }
        | TestErrorKind::ParseError(_)
        | _ => {
            writeln!(out, "details: {}", test_err.display(false))
                .expect("writing to String should not fail");
        }
    }

    out.trim_end_matches('\n').to_string()
}

#[cfg(test)]
mod tests {
    use super::{RecordMetadataCache, format_error, format_failed, format_ok};
    use sqllogictest::Record;

    #[test]
    fn status_formatters_plain_text() {
        assert_eq!(format_ok(false), "ok");
        assert_eq!(format_failed(false), "FAILED");
        assert_eq!(format_error(false), "ERROR");
    }

    #[test]
    fn status_formatters_colored_text() {
        assert!(format_ok(true).contains("ok"));
        assert!(format_failed(true).contains("FAILED"));
        assert!(format_error(true).contains("ERROR"));
    }

    #[test]
    fn metadata_cache_supports_repeated_lookups_for_same_file() {
        let unique = std::time::SystemTime::now()
            .duration_since(std::time::UNIX_EPOCH)
            .expect("system time should be after unix epoch")
            .as_nanos();
        let path = std::env::temp_dir().join(format!("duckdb-slt-reporting-{unique}.slt"));

        std::fs::write(
            &path,
            "query I first\nSELECT 1;\n----\n1\n\nquery I second\nSELECT 2;\n----\n2\n",
        )
        .expect("fixture should be written");

        let cache = RecordMetadataCache::build(&path).expect("metadata should be built");
        let records = sqllogictest::parse_file::<sqllogictest::DefaultColumnType>(&path)
            .expect("fixture should parse");
        let query_locs: Vec<_> = records
            .into_iter()
            .filter_map(|record| match record {
                Record::Query { loc, .. } => Some(loc),
                _ => None,
            })
            .collect();

        assert_eq!(query_locs.len(), 2);

        let first = cache
            .find(&query_locs[0])
            .expect("first record metadata should exist");
        assert_eq!(first.index_1_based, 1);
        assert_eq!(first.name.as_deref(), Some("first"));

        let second = cache
            .find(&query_locs[1])
            .expect("second record metadata should exist");
        assert_eq!(second.index_1_based, 2);
        assert_eq!(second.name.as_deref(), Some("second"));

        let repeated_first = cache
            .find(&query_locs[0])
            .expect("repeated lookup should return same metadata");
        assert_eq!(repeated_first, first);

        let _ = std::fs::remove_file(&path);
    }
}