kglite 0.17.10

Pure-Rust embedded Cypher knowledge graph engine with in-memory, mmap, and disk storage, and agent-facing schema introspection
Documentation
use super::*;
use serde_json::json;

fn query(parameters: Value, cypher: &str) -> Value {
    json!({
        "description": "A stored read operation.",
        "parameters": parameters,
        "cypher": cypher,
    })
}

fn catalog(query_value: Value) -> Value {
    json!({
        "code_review": {
            "description": "Code review operations.",
            "queries": {"direct_callers": query_value}
        }
    })
}

fn string_parameter() -> Value {
    json!({
        "type": "object",
        "properties": {"qualified_name": {"type": "string"}},
        "required": ["qualified_name"],
        "additionalProperties": false
    })
}

#[test]
fn absent_and_empty_catalogs_are_disabled() {
    let absent = RecipeCatalog::from_manifest_value(None).unwrap();
    assert!(absent.is_empty());
    assert_eq!(absent.discovery_summary(), None);

    let empty = RecipeCatalog::from_manifest_value(Some(&json!({}))).unwrap();
    assert!(empty.is_empty());
    assert_eq!(empty.discovery_summary(), None);
}

#[test]
fn catalog_is_immutable_and_summarized_deterministically() {
    let raw = catalog(query(
        string_parameter(),
        "MATCH (n:Function) WHERE n.qualified_name = $qualified_name RETURN n.name",
    ));
    let parsed = RecipeCatalog::from_manifest_value(Some(&raw)).unwrap();
    assert_eq!(
        parsed.discovery_summary(),
        Some(CatalogSummary {
            recipe_count: 1,
            query_count: 1
        })
    );
    let recipe = parsed.get("code_review").unwrap();
    assert_eq!(recipe.name, "code_review");
    assert_eq!(recipe.queries().len(), 1);
    assert_eq!(recipe.get("direct_callers").unwrap().name, "direct_callers");
    assert_eq!(parsed.recipes().len(), 1);
}

#[test]
fn rejects_invalid_identifiers_empty_fields_and_unknown_config() {
    let invalid_identifier = json!({
        "bad-name": {
            "description": "Recipe.",
            "queries": {"q": query(json!({
                "type": "object", "properties": {}, "required": [],
                "additionalProperties": false
            }), "RETURN 1")}
        }
    });
    assert!(
        RecipeCatalog::from_manifest_value(Some(&invalid_identifier))
            .unwrap_err()
            .to_string()
            .contains("identifier")
    );

    let empty = json!({"r": {"description": " ", "queries": {}}});
    let error = RecipeCatalog::from_manifest_value(Some(&empty)).unwrap_err();
    assert!(format!("{error:#}").contains("description"));

    let unknown = json!({"r": {"description": "R", "queries": {}, "workflow": []}});
    let error = RecipeCatalog::from_manifest_value(Some(&unknown)).unwrap_err();
    assert!(format!("{error:#}").contains("unsupported recipe keys"));
}

#[test]
fn requires_exact_parameter_property_and_required_sets() {
    let missing_property = catalog(query(
        json!({
            "type": "object", "properties": {}, "required": [],
            "additionalProperties": false
        }),
        "RETURN $qualified_name",
    ));
    let error = RecipeCatalog::from_manifest_value(Some(&missing_property)).unwrap_err();
    assert!(format!("{error:#}").contains("parameter properties"));

    let optional_property = catalog(query(
        json!({
            "type": "object",
            "properties": {"qualified_name": {"type": "string"}},
            "required": [],
            "additionalProperties": false
        }),
        "RETURN $qualified_name",
    ));
    let error = RecipeCatalog::from_manifest_value(Some(&optional_property)).unwrap_err();
    assert!(format!("{error:#}").contains("required must list every"));
}

#[test]
fn tokenizer_ignores_parameter_lookalikes_in_strings_and_comments() {
    let raw = catalog(query(
        string_parameter(),
        "// $comment\nRETURN '$literal' AS text, $qualified_name AS name",
    ));
    RecipeCatalog::from_manifest_value(Some(&raw)).unwrap();
}

#[test]
fn rejects_mutations_and_banned_read_modes() {
    let no_parameters = json!({
        "type": "object", "properties": {}, "required": [],
        "additionalProperties": false
    });
    let cases = [
        ("CREATE (:Thing)", "read-only"),
        ("EXPLAIN RETURN 1", "EXPLAIN"),
        ("PROFILE RETURN 1", "PROFILE"),
        ("RETURN 1 FORMAT CSV", "FORMAT CSV"),
        ("LOAD CSV FROM 'rows.csv' AS row RETURN row", "LOAD CSV"),
    ];
    for (cypher, expected) in cases {
        let raw = catalog(query(no_parameters.clone(), cypher));
        let error = RecipeCatalog::from_manifest_value(Some(&raw)).unwrap_err();
        let chain = format!("{error:#}");
        assert!(chain.contains(expected), "{cypher:?}: {chain}");
    }
}

#[test]
fn third_party_queries_without_order_by_remain_valid() {
    let raw = catalog(query(
        string_parameter(),
        "MATCH (n) WHERE n.name = $qualified_name RETURN n.name",
    ));
    RecipeCatalog::from_manifest_value(Some(&raw)).unwrap();
}

#[test]
fn payload_cap_limit_is_rejected_but_other_semantic_limits_are_valid() {
    let no_parameters = json!({
        "type": "object", "properties": {}, "required": [],
        "additionalProperties": false
    });
    let cap = catalog(query(no_parameters.clone(), "RETURN 1 LIMIT 200"));
    let error = RecipeCatalog::from_manifest_value(Some(&cap)).unwrap_err();
    assert!(format!("{error:#}").contains("reserved for the recipe result payload cap"));

    let semantic = catalog(query(no_parameters.clone(), "RETURN 1 LIMIT 20"));
    RecipeCatalog::from_manifest_value(Some(&semantic)).unwrap();

    // The rule is equality with the cap, not a ceiling: a query asking for
    // more than the cap is accepted and the server reports the overflow,
    // which is the half VAULT.md ยง8 now spells out because the P16 probe
    // read the refusal of 200 as "no LIMIT may exceed 200" and rewrote its
    // queries.
    let above = catalog(query(no_parameters, "RETURN 1 LIMIT 201"));
    RecipeCatalog::from_manifest_value(Some(&above)).unwrap();
}