haematite-cli 0.21.8

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, value::RawValue};

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

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

fn review(
    fixture: &mut Fixture,
    version: Option<types::Version>,
    cursors: &types::ReviewCursors,
) -> Result<types::ReviewBranchOutput, Box<dyn std::error::Error>> {
    let call = rpc::ToolCall {
        metadata: None,
        name: "review_branch".to_owned(),
        arguments: serde_json::value::to_raw_value(
            &json!({"selection":{"branch":"feature","version":version},"page":{"rows":64,"bytes":65536},"after":cursors}),
        )?,
    };
    let before = fixture.requests();
    let result = dispatch(&mut fixture.sender, &call)?;
    assert_eq!(fixture.requests(), before + 4);
    assert!(!result.is_error, "{}", result.structured_content.get());
    let schema = crate::mcp::schema::tools()
        .into_iter()
        .find(|tool| tool.name == "review_branch")
        .ok_or_else(|| io::Error::other("review 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()))?;
    Ok(serde_json::from_str(result.structured_content.get())?)
}

fn put(fixture: &Fixture, branch: &str, request: &str, number: u64) -> TestResult {
    fixture.mutate(
        branch,
        request,
        protocol::Change::PutMany(protocol::DocumentImport {
            collection: "records".to_owned(),
            documents: vec![protocol::ImportedDocument {
                document_id: "a".to_owned(),
                document: RawValue::from_string(format!("{{\"n\":{number}}}"))?,
            }],
        }),
    )?;
    Ok(())
}

#[test]
fn review_branch_preserves_relation_owners_and_native_presence() -> TestResult {
    let mut fixture = Fixture::start()?;
    fixture.seed()?;
    fixture.fork("feature")?;
    fixture.mutate(
        "feature",
        "new-relation",
        protocol::Change::RelationPut(protocol::Relation {
            relation: "more_links".to_owned(),
            from_collection: "records".to_owned(),
            to_collection: "records".to_owned(),
            cardinality: protocol::Cardinality::ManyToMany,
        }),
    )?;
    let output = review(&mut fixture, None, &types::ReviewCursors::default())?;
    assert!(output.diff.entries.iter().any(|line| matches!(&line.owner, types::ReviewOwner::Relation { relation } if relation == "more_links")));
    assert!(output.conflicts.entries.iter().any(|line| matches!(&line.owner, types::ReviewOwner::Relation { relation } if relation == "more_links") && matches!(line.state, types::ConflictState::SourceOnly)));
    assert!(
        serde_json::to_value(&output.counts.partial)?
            .get("dangling")
            .is_none()
    );
    assert_eq!(output.target.branch, "main");
    fixture.finish()
}

#[test]
fn review_branch_keeps_all_conflict_values_state_token_and_distance() -> TestResult {
    let mut fixture = Fixture::start()?;
    fixture.seed()?;
    fixture.fork("feature")?;
    put(&fixture, "feature", "source-put", 2)?;
    put(&fixture, "main", "target-put", 3)?;
    let output = review(&mut fixture, None, &types::ReviewCursors::default())?;
    let conflict = output
        .conflicts
        .entries
        .iter()
        .find(|line| matches!(line.state, types::ConflictState::Conflict))
        .ok_or_else(|| io::Error::other("conflict missing"))?;
    assert_eq!(
        serde_json::to_value(&conflict.ancestor)?,
        json!({"kind":"value","value":{"n":1}})
    );
    assert_eq!(
        serde_json::to_value(&conflict.target)?,
        json!({"kind":"value","value":{"n":3}})
    );
    assert_eq!(
        serde_json::to_value(&conflict.source)?,
        json!({"kind":"value","value":{"n":2}})
    );
    assert!(conflict.choice_token.is_some());
    assert!(
        matches!(&conflict.owner, types::ReviewOwner::Document { record } if record.collection == "records" && record.document_id == "a")
    );
    assert_eq!(output.counts.partial.conflicts, 1);
    assert_eq!(output.base_moved, Some(1));
    fixture.finish()
}

#[test]
fn review_branch_retains_conditional_dangling_findings() -> TestResult {
    let mut fixture = Fixture::start()?;
    fixture.seed()?;
    fixture.fork("feature")?;
    fixture.mutate(
        "feature",
        "source-delete",
        protocol::Change::Delete(protocol::DocumentDelete {
            collection: "records".to_owned(),
            document_id: "a".to_owned(),
        }),
    )?;
    put(&fixture, "main", "target-put", 3)?;
    fixture.mutate(
        "main",
        "target-link",
        protocol::Change::EdgePut(protocol::Edge {
            relation: "links".to_owned(),
            from: protocol::Node {
                collection: "records".to_owned(),
                document_id: "a".to_owned(),
            },
            to: protocol::Node {
                collection: "records".to_owned(),
                document_id: "b".to_owned(),
            },
            properties: json!({}),
        }),
    )?;
    let first = review(&mut fixture, None, &types::ReviewCursors::default())?;
    let selected = first.selection.version;
    let mut findings = first.dangling.entries;
    let mut after = first.dangling.next_after;
    let mut pages = 1;
    while let Some(cursor) = after {
        let output = review(
            &mut fixture,
            Some(selected),
            &types::ReviewCursors {
                dangling: Some(cursor),
                ..types::ReviewCursors::default()
            },
        )?;
        after = output.dangling.next_after;
        findings.extend(output.dangling.entries);
        pages += 1;
        assert!(
            pages <= 8,
            "bounded fixture did not complete its served cursor"
        );
    }
    let finding = findings
        .iter()
        .find(|line| matches!(line.state, types::DanglingState::DependsOnChoice))
        .ok_or_else(|| io::Error::other("conditional dangling finding missing"))?;
    assert_eq!(finding.relation, "links");
    assert_eq!(finding.from.document_id, "a");
    assert_eq!(finding.to.document_id, "b");
    assert!(matches!(finding.missing_end, types::MissingEnd::From));
    assert!(matches!(finding.removed_by, types::RemovedBy::Source));
    assert!(!finding.choice_token.is_empty());
    fixture.finish()
}

#[test]
fn every_registered_tool_reaches_a_served_method_without_not_ready() -> TestResult {
    let mut fixture = Fixture::start()?;
    fixture.seed()?;
    fixture.fork("feature")?;
    let inputs = types::tests::inputs();
    assert_eq!(inputs.len(), 11);
    for (name, arguments) in inputs {
        let call = rpc::ToolCall {
            metadata: None,
            name: name.to_owned(),
            arguments: RawValue::from_string(arguments)?,
        };
        let before = fixture.requests();
        let result = dispatch(&mut fixture.sender, &call)?;
        assert!(
            fixture.requests() > before,
            "{name} did not reach a served method"
        );
        if result.is_error {
            let error: ServiceRefusal = serde_json::from_str(result.structured_content.get())?;
            assert!(
                ![
                    "mcp_tool_not_ready",
                    "mcp_tool_unknown",
                    "mcp_arguments_invalid"
                ]
                .contains(&error.code.as_str()),
                "{name}: {}",
                error.code
            );
        }
    }
    fixture.finish()
}