plexus-engine 0.3.4

Engine integration traits for consuming Plexus plans
Documentation
use super::*;
use plexus_conformance::{
    load_cases_from_paths_with_options, load_external_cases_from_root, parse_corpus_group_map,
    run_conformance_suite, ConformanceCase, ConformanceFilterConfig, CorpusLoadOptions,
};
use std::fs;
use std::path::{Path, PathBuf};

mod runner;

use runner::{
    execute_case_optimized, execute_case_optimized_for_conformance, execute_case_unoptimized,
    execute_case_unoptimized_for_conformance, execute_case_with_pipeline,
};

fn case_dir() -> PathBuf {
    Path::new(env!("CARGO_MANIFEST_DIR"))
        .join("src")
        .join("tests")
        .join("fixtures")
        .join("readonly")
}

fn load_filter_config() -> ConformanceFilterConfig {
    let path = case_dir().join("tck_filter.conf");
    let Ok(text) = fs::read_to_string(path) else {
        return ConformanceFilterConfig::default();
    };
    let mut cfg = ConformanceFilterConfig::default();
    for raw in text.lines() {
        let line = raw.trim();
        if line.is_empty() || line.starts_with('#') {
            continue;
        }
        let Some((k, v)) = line.split_once('=') else {
            continue;
        };
        let key = k.trim();
        let value = v.trim();
        match key {
            "include_untagged_readonly" => {
                cfg.include_untagged = value.eq_ignore_ascii_case("true");
            }
            "allow_tags" => {
                cfg.allow_tags = parse_csv_lower(value);
            }
            "deny_tags" => {
                cfg.deny_tags = parse_csv_lower(value);
            }
            "deny_query_keywords" => {
                cfg.deny_query_keywords = value
                    .split(',')
                    .map(|x| x.trim().to_ascii_uppercase())
                    .filter(|x| !x.is_empty())
                    .collect();
            }
            _ => {}
        }
    }
    cfg
}

fn parse_csv_lower(value: &str) -> Vec<String> {
    value
        .split(',')
        .map(|x| x.trim().to_ascii_lowercase())
        .filter(|x| !x.is_empty())
        .collect()
}

fn local_case_paths() -> Vec<PathBuf> {
    let mut files = fs::read_dir(case_dir())
        .expect("read readonly tck dir")
        .map(|e| e.expect("read_dir entry").path())
        .filter(|p| {
            matches!(
                p.extension().and_then(|x| x.to_str()),
                Some("case") | Some("feature")
            )
        })
        .collect::<Vec<_>>();
    files.sort();
    files
}

fn load_external_readonly_cases(
    external_root: &Path,
    groups_csv: Option<&str>,
    group_map_path: Option<&Path>,
    filter: &ConformanceFilterConfig,
) -> Vec<ConformanceCase> {
    let options = CorpusLoadOptions {
        filter: Some(filter.clone()),
        groups_csv: groups_csv.map(|s| s.to_string()),
        group_map_path: group_map_path.map(Path::to_path_buf),
    };
    load_external_cases_from_root(external_root, &options).expect("load external readonly cases")
}

pub(super) fn load_readonly_cases() -> Vec<ConformanceCase> {
    let filter = load_filter_config();
    let options = CorpusLoadOptions {
        filter: Some(filter.clone()),
        groups_csv: None,
        group_map_path: None,
    };

    let mut out = load_cases_from_paths_with_options(&local_case_paths(), &options)
        .expect("load local readonly cases");

    if let Ok(external_root) = std::env::var("PLEXUS_OPENCYPHER_TCK_DIR") {
        let groups_csv = std::env::var("PLEXUS_OPENCYPHER_TCK_GROUPS").ok();
        let group_map_path = std::env::var("PLEXUS_OPENCYPHER_TCK_GROUP_MAP")
            .ok()
            .map(PathBuf::from);
        out.extend(load_external_readonly_cases(
            Path::new(&external_root),
            groups_csv.as_deref(),
            group_map_path.as_deref(),
            &filter,
        ));
    }

    out
}

#[test]
fn readonly_tck_parse_opencypher_group_map_config() {
    let parsed = parse_corpus_group_map(
        r#"
readonly-core=match,return,where
optional=optional
"#,
    );
    assert_eq!(
        parsed.get("readonly-core"),
        Some(&vec![
            "match".to_string(),
            "return".to_string(),
            "where".to_string()
        ])
    );
    assert_eq!(parsed.get("optional"), Some(&vec!["optional".to_string()]));
}

#[test]
fn readonly_tck_external_group_selection_filters_cases() {
    let base = std::env::temp_dir().join(format!(
        "plexus_tck_ext_{}_{}",
        std::process::id(),
        std::time::SystemTime::now()
            .duration_since(std::time::UNIX_EPOCH)
            .expect("clock")
            .as_nanos()
    ));
    fs::create_dir_all(&base).expect("mkdir temp");
    let feature_path = base.join("group_filter.feature");
    fs::write(
        &feature_path,
        r#"
@Match
Scenario: external-match
When executing query:
"""
MATCH (n:Person)
RETURN n.name
ORDER BY n.name
"""
Then the result should be, in order:
| string:Alice |
| string:Bob |

@Optional
Scenario: external-optional
When executing query:
"""
OPTIONAL MATCH (n:Person)-[r:WORKS_AT]->(c:Company)
RETURN n.name, c.name
ORDER BY n.name
"""
Then the result should be, in order:
| string:Alice | null |
| string:Bob | string:Acme |
"#,
    )
    .expect("write feature");

    let filter = ConformanceFilterConfig::default();
    let only_match = load_external_readonly_cases(&base, Some("match"), None, &filter);
    let names = only_match
        .iter()
        .map(|c| c.name.as_str())
        .collect::<Vec<_>>();
    assert!(
        names.contains(&"external-match"),
        "match group should include @Match scenario",
    );
    assert!(
        !names.contains(&"external-optional"),
        "match group should exclude @Optional scenario",
    );

    let only_optional = load_external_readonly_cases(&base, Some("optional"), None, &filter);
    let names = only_optional
        .iter()
        .map(|c| c.name.as_str())
        .collect::<Vec<_>>();
    assert!(
        names.contains(&"external-optional"),
        "optional group should include @Optional scenario",
    );
    assert!(
        !names.contains(&"external-match"),
        "optional group should exclude @Match scenario",
    );

    let _ = fs::remove_file(&feature_path);
    let _ = fs::remove_dir_all(&base);
}

#[test]
fn readonly_tck_filters_write_tagged_scenarios() {
    let cases = load_readonly_cases();
    assert!(
        !cases.iter().any(|c| c.name.contains("write-tagged")),
        "write-tagged scenario should be filtered from readonly corpus",
    );
    assert!(
        !cases
            .iter()
            .any(|c| c.name.contains("unknown-tag-readonly")),
        "unknown-tag scenario should be filtered by allow_tags",
    );
}

#[test]
fn readonly_tck_corpus_cases() {
    let cases = load_readonly_cases();
    let report = run_conformance_suite(&cases, execute_case_unoptimized_for_conformance);
    assert_eq!(
        report.failed,
        0,
        "readonly unoptimized conformance failures: {:?}",
        report
            .cases
            .iter()
            .filter_map(|c| c
                .outcome
                .as_ref()
                .err()
                .map(|e| (c.case.clone(), format!("{e}"))))
            .collect::<Vec<_>>()
    );
}

#[test]
fn readonly_tck_corpus_cases_optimized_pipeline() {
    let cases = load_readonly_cases();
    let report = run_conformance_suite(&cases, execute_case_optimized_for_conformance);
    assert_eq!(
        report.failed,
        0,
        "readonly optimized conformance failures: {:?}",
        report
            .cases
            .iter()
            .filter_map(|c| c
                .outcome
                .as_ref()
                .err()
                .map(|e| (c.case.clone(), format!("{e}"))))
            .collect::<Vec<_>>()
    );
}

pub(super) fn execute_case_unoptimized_for_equivalence(
    case: &ConformanceCase,
) -> Result<QueryResult, String> {
    execute_case_unoptimized(case)
}

pub(super) fn execute_case_optimized_for_equivalence(
    case: &ConformanceCase,
) -> Result<QueryResult, String> {
    execute_case_optimized(case)
}

pub(super) fn execute_case_with_pipeline_for_equivalence(
    case: &ConformanceCase,
    pipeline: &str,
) -> Result<QueryResult, String> {
    execute_case_with_pipeline(case, pipeline)
}