haematite-cli 0.21.7

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

use serde_json::{Value, json};

use super::{ServiceRefusal, dispatch, writes_tests::Fixture};
use crate::mcp::{rpc, types};

type TestResult = Result<(), Box<dyn std::error::Error>>;

fn call(arguments: &Value) -> Result<rpc::ToolCall, serde_json::Error> {
    Ok(rpc::ToolCall {
        metadata: None,
        name: "describe_store".to_owned(),
        arguments: serde_json::value::to_raw_value(arguments)?,
    })
}

#[test]
fn describe_store_projects_real_served_metadata_at_one_captured_version() -> TestResult {
    let mut fixture = Fixture::start()?;
    fixture.seed()?;
    let expected = fixture.current()?;
    let before = fixture.requests();
    let input = call(
        &json!({"selection":{"branch":"main","version":null},"page":{"rows":64,"bytes":65536},"after":{}}),
    )?;
    let result = dispatch(&mut fixture.sender, &input)?;
    assert_eq!(fixture.requests(), before + 5);
    assert!(!result.is_error, "{}", result.structured_content.get());
    let output: types::DescribeStoreOutput = serde_json::from_str(result.structured_content.get())?;
    assert_eq!(output.selection.branch, "main");
    assert_eq!(output.selection.version.0, expected);
    assert_eq!(
        output
            .collections
            .entries
            .first()
            .map(|entry| entry.name.as_str()),
        Some("records")
    );
    assert_eq!(
        output
            .relations
            .entries
            .first()
            .map(|entry| entry.relation.as_str()),
        Some("links")
    );
    assert!(output.vector_fields.entries.is_empty());
    assert_eq!(output.counts.collections, 1);
    assert_eq!(output.counts.documents, 2);
    assert_eq!(output.counts.edges, 0);
    assert_eq!(output.counts.vectors, 0);
    let schema = crate::mcp::schema::tools()
        .into_iter()
        .find(|tool| tool.name == "describe_store")
        .ok_or_else(|| io::Error::other("description schema missing"))?;
    let validator = jsonschema::validator_for(schema.output_schema.as_value())?;
    let body: Value = serde_json::from_str(result.structured_content.get())?;
    validator
        .validate(&body)
        .map_err(|error| io::Error::other(error.to_string()))?;
    fixture.finish()
}

#[test]
fn describe_store_refuses_unbound_cursor_and_exceeded_output_bytes() -> TestResult {
    let mut fixture = Fixture::start()?;
    fixture.seed()?;
    let unbound = call(
        &json!({"selection":{"branch":"main","version":null},"page":{"rows":64,"bytes":65536},"after":{"relations":"not-a-cursor"}}),
    )?;
    let result = dispatch(&mut fixture.sender, &unbound)?;
    assert!(result.is_error);
    let refusal: ServiceRefusal = serde_json::from_str(result.structured_content.get())?;
    assert_eq!(refusal.code, "mcp_version_required");
    let short = call(
        &json!({"selection":{"branch":"main","version":null},"page":{"rows":64,"bytes":1},"after":{}}),
    )?;
    let result = dispatch(&mut fixture.sender, &short)?;
    assert!(result.is_error);
    let refusal: ServiceRefusal = serde_json::from_str(result.structured_content.get())?;
    assert_eq!(refusal.code, "mcp_page_bytes_exceeded");
    fixture.finish()
}

fn collection(fixture: &mut Fixture, name: &str) -> TestResult {
    let input = rpc::ToolCall {
        metadata: None,
        name: "define_collection".to_owned(),
        arguments: serde_json::value::to_raw_value(&json!({
            "request_id":format!("collection-{name}"),
            "branch":"main",
            "expected":fixture.current()?,
            "collection":name,
            "schema":null
        }))?,
    };
    let result = dispatch(&mut fixture.sender, &input)?;
    assert!(!result.is_error, "{}", result.structured_content.get());
    Ok(())
}

#[test]
fn describe_store_continuation_keeps_the_captured_collection_version() -> TestResult {
    let mut fixture = Fixture::start()?;
    fixture.seed()?;
    collection(&mut fixture, "aux")?;
    let first = call(
        &json!({"selection":{"branch":"main","version":null},"page":{"rows":1,"bytes":65536},"after":{}}),
    )?;
    let answer = dispatch(&mut fixture.sender, &first)?;
    assert!(!answer.is_error, "{}", answer.structured_content.get());
    let first: types::DescribeStoreOutput = serde_json::from_str(answer.structured_content.get())?;
    assert_eq!(first.collections.entries.len(), 1);
    assert_eq!(first.collections.entries[0].name, "aux");
    let cursor = first
        .collections
        .next_after
        .ok_or_else(|| io::Error::other("collection cursor missing"))?;
    collection(&mut fixture, "later")?;
    let next = call(
        &json!({"selection":{"branch":"main","version":first.selection.version},"page":{"rows":1,"bytes":65536},"after":{"collections":cursor}}),
    )?;
    let answer = dispatch(&mut fixture.sender, &next)?;
    assert!(!answer.is_error, "{}", answer.structured_content.get());
    let next: types::DescribeStoreOutput = serde_json::from_str(answer.structured_content.get())?;
    assert_eq!(next.selection.version.0, first.selection.version.0);
    assert_eq!(next.collections.entries.len(), 1);
    assert_eq!(next.collections.entries[0].name, "records");
    assert_eq!(next.collections.next_after, None);
    assert_eq!(next.counts.collections, 2);
    fixture.finish()
}