haematite-cli 0.21.3

The haematite command: serve a store, its door and Studio, answer MCP, query with jq, and host the store's assistant seat
Documentation
use serde_json::json;

use super::super::{Argument, Source, pipeline};

#[test]
fn collection_arguments_keep_every_json_type() -> Result<(), String> {
    let text = concat!(
        "patients(n: -2.5 and yes: true and no: false and empty: null and ",
        r#"text: "and ) | \" café" and array: [1, "and", {"x": ")"}] and object: {"a": [false]})"#
    );
    let plan = pipeline(text).map_err(|error| error.to_string())?;
    let expected = [
        ("n", json!(-2.5)),
        ("yes", json!(true)),
        ("no", json!(false)),
        ("empty", json!(null)),
        ("text", json!("and ) | \" café")),
        ("array", json!([1, "and", {"x": ")"}])),
        ("object", json!({"a": [false]})),
    ];
    assert_eq!(
        plan.source,
        Source::Collection {
            name: "patients".to_owned(),
            arguments: expected
                .into_iter()
                .map(|(field, value)| Argument {
                    field: field.to_owned(),
                    value
                })
                .collect(),
        }
    );
    assert!(plan.stages.is_empty());
    Ok(())
}

#[test]
fn explicit_collection_names_decode_json_and_can_name_reserved_words() -> Result<(), String> {
    for (text, expected) in [
        (r#"collection("ward \"4B\"")"#, "ward \"4B\""),
        (r#"collection("caf\u00e9")"#, "café"),
        (r#"collection("sort_by")"#, "sort_by"),
    ] {
        let plan = pipeline(text).map_err(|error| error.to_string())?;
        assert_eq!(
            plan.source,
            Source::Collection {
                name: expected.to_owned(),
                arguments: Vec::new(),
            }
        );
        assert!(plan.stages.is_empty());
    }
    Ok(())
}

#[test]
fn malformed_collection_arguments_are_named_and_positioned() {
    let cases = [
        ("patients()", "top-level field"),
        (r#"patients(.ward: "4B")"#, "top-level field"),
        (r#"patients(ward "4B")"#, "separates its field"),
        ("patients(ward: )", "JSON value"),
        ("patients(ward: unknown)", "JSON value"),
        ("patients(ward: 1, status: true)", "JSON value"),
        ("patients(ward: 1 and)", "JSON value"),
        ("patients(ward: 1 and status:)", "JSON value"),
        ("patients(ward: 1 and status: 2", "closed by `)`"),
        ("patients(ward: [1, 2)", "JSON value"),
        (r#"patients(ward: "unclosed)"#, "JSON value"),
        ("patients(ward: $ward)", "JSON value"),
        ("patients(ward: 1)garbage", "collection is followed"),
    ];
    for (text, expected) in cases {
        let error = pipeline(text).err();
        assert!(
            error.is_some_and(|error| {
                let span = error.span();
                error.code() == "query_parse"
                    && error.to_string().contains(expected)
                    && span.start <= span.end
                    && text.get(span.start..span.end).is_some()
            }),
            "{text}: expected {expected}"
        );
    }
}

#[test]
fn empty_nul_unquoted_and_unclosed_explicit_names_are_refused() {
    for text in [
        r#"collection("")"#,
        r#"collection("\u0000")"#,
        "collection(name)",
        r#"collection("unclosed)"#,
        r#"collection("name""#,
    ] {
        assert!(pipeline(text).is_err(), "{text}");
    }
}

#[test]
fn invalid_explicit_names_mark_the_written_name() {
    for text in [r#"collection("")"#, r#"collection("\u0000")"#] {
        let error = pipeline(text).err();
        assert!(
            error.is_some_and(|error| {
                error.span().start == 11 && error.span().end == text.len() - 1
            }),
            "{text}"
        );
    }
}

#[test]
fn collection_argument_errors_use_byte_positions_after_unicode() {
    let text = "\u{2003}patients(ward: \"café\" and status: nope)";
    let error = pipeline(text).err();
    let start = text.find("nope");
    assert!(error.is_some_and(|error| {
        Some(error.span().start) == start && error.span().end == start.map_or(0, |start| start + 4)
    }));
}