haematite-cli 0.21.0

The haematite command: serve a store, its door and Studio, answer MCP, query with jq, and host the store's assistant seat
pub mod skill;

use schemars::{JsonSchema, Schema, generate::SchemaSettings};
use serde::Serialize;

use super::types::{
    CreateBranchInput, CreateBranchOutput, DefineCollectionInput, DefineCollectionOutput,
    DeleteRecordInput, DeleteRecordOutput, DescribeStoreInput, DescribeStoreOutput,
    LinkRecordsInput, LinkRecordsOutput, MergeBranchInput, MergeBranchOutput, PutRecordsInput,
    PutRecordsOutput, ReadHistoryInput, ReadHistoryOutput, ReviewBranchInput, ReviewBranchOutput,
    RunQueryInput, RunQueryOutput, UnlinkRecordsInput, UnlinkRecordsOutput,
};

#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ToolSchema {
    pub name: &'static str,
    pub description: &'static str,
    pub input_schema: Schema,
    pub output_schema: Schema,
}

fn definition<Input: JsonSchema, Output: JsonSchema>(
    name: &'static str,
    description: &'static str,
) -> ToolSchema {
    ToolSchema {
        name,
        description,
        input_schema: SchemaSettings::draft2020_12()
            .for_deserialize()
            .into_generator()
            .into_root_schema_for::<Input>(),
        output_schema: SchemaSettings::draft2020_12()
            .for_serialize()
            .into_generator()
            .into_root_schema_for::<Output>(),
    }
}

#[must_use]
pub fn tools() -> [ToolSchema; 11] {
    [
        definition::<DescribeStoreInput, DescribeStoreOutput>(
            "describe_store",
            "Describe one captured store version in bounded pages.",
        ),
        definition::<RunQueryInput, RunQueryOutput>(
            "run_query",
            "Read one bounded page from a query.",
        ),
        definition::<ReadHistoryInput, ReadHistoryOutput>(
            "read_history",
            "Read retained branch snapshots or a record at a captured version.",
        ),
        definition::<DefineCollectionInput, DefineCollectionOutput>(
            "define_collection",
            "Define a collection and its shape on a branch.",
        ),
        definition::<PutRecordsInput, PutRecordsOutput>(
            "put_records",
            "Put one or more records on a branch atomically.",
        ),
        definition::<DeleteRecordInput, DeleteRecordOutput>(
            "delete_record",
            "Delete a record and its incident edges and vectors.",
        ),
        definition::<LinkRecordsInput, LinkRecordsOutput>(
            "link_records",
            "Link two records on a branch.",
        ),
        definition::<UnlinkRecordsInput, UnlinkRecordsOutput>(
            "unlink_records",
            "Remove a link between two records on a branch.",
        ),
        definition::<CreateBranchInput, CreateBranchOutput>(
            "create_branch",
            "Fork a branch from a retained version.",
        ),
        definition::<ReviewBranchInput, ReviewBranchOutput>(
            "review_branch",
            "Read bounded branch differences, preparation states and dangling findings.",
        ),
        definition::<MergeBranchInput, MergeBranchOutput>(
            "merge_branch",
            "Merge a reviewed branch, at the version its review read, into the branch it was forked from.",
        ),
    ]
}

#[cfg(test)]
mod tests {
    use std::collections::BTreeSet;
    use std::error::Error;

    use serde_json::{Value, json};

    use super::{SchemaSettings, tools};
    use crate::mcp::types::{Version, tests::inputs};

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

    #[test]
    fn registry_has_exactly_the_eleven_tools_and_both_schemas() -> TestResult {
        let definitions = tools();
        let expected = [
            "describe_store",
            "run_query",
            "read_history",
            "define_collection",
            "put_records",
            "delete_record",
            "link_records",
            "unlink_records",
            "create_branch",
            "review_branch",
            "merge_branch",
        ];
        assert_eq!(definitions.len(), 11);
        assert_eq!(
            definitions.iter().map(|tool| tool.name).collect::<Vec<_>>(),
            expected
        );
        assert_eq!(
            definitions
                .iter()
                .map(|tool| tool.name)
                .collect::<BTreeSet<_>>()
                .len(),
            11
        );
        for tool in definitions {
            for schema in [&tool.input_schema, &tool.output_schema] {
                let value = serde_json::to_value(schema)?;
                assert_eq!(value["type"], "object", "{}", tool.name);
                assert!(
                    !value["properties"]
                        .as_object()
                        .ok_or("schema has no properties")?
                        .is_empty()
                );
                assert!(value["required"].as_array().is_some(), "{}", tool.name);
                let validator = jsonschema::validator_for(&value)?;
                assert!(!validator.is_valid(&json!("untyped blob")), "{}", tool.name);
            }
        }
        Ok(())
    }

    #[test]
    fn all_generated_input_schemas_match_the_typed_fixtures() -> TestResult {
        let fixtures = inputs();
        for (tool, (name, input)) in tools().into_iter().zip(fixtures) {
            assert_eq!(tool.name, name);
            let schema = serde_json::to_value(tool.input_schema)?;
            let validator = jsonschema::validator_for(&schema)?;
            let mut value: Value = serde_json::from_str(&input)?;
            assert!(validator.is_valid(&value), "{name}: {schema}");
            value
                .as_object_mut()
                .ok_or("input is not an object")?
                .insert("unknown".to_owned(), json!(true));
            assert!(!validator.is_valid(&value), "{name}");
        }
        Ok(())
    }

    #[test]
    fn version_schema_matches_its_string_wire_form() -> TestResult {
        let schema = SchemaSettings::draft2020_12()
            .into_generator()
            .into_root_schema_for::<Version>();
        let validator = jsonschema::validator_for(&serde_json::to_value(schema)?)?;
        assert!(validator.is_valid(&json!({"generation":"1","sequence":"2"})));
        assert!(!validator.is_valid(&json!({"generation":1,"sequence":2})));
        Ok(())
    }

    fn output_fixtures() -> Vec<String> {
        let selection = r#"{"branch":"feature","version":{"generation":"1","sequence":"2"}}"#;
        let page = r#"{"entries":[],"next_after":null}"#;
        let counts = r#"{"collections":0,"documents":0,"edges":0,"vectors":0}"#;
        let receipt =
            r#"{"branch":"feature","version":{"generation":"1","sequence":"2"},"request_id":"r1"}"#;
        vec![
            format!(
                r#"{{"selection":{selection},"branches":{page},"collections":{page},"relations":{page},"vector_fields":{page},"counts":{counts}}}"#
            ),
            format!(r#"{{"selection":{selection},"rows":{page}}}"#),
            format!(r#"{{"selection":{selection},"history":{page},"record":null}}"#),
            receipt.to_owned(),
            receipt.to_owned(),
            receipt.to_owned(),
            receipt.to_owned(),
            receipt.to_owned(),
            receipt.to_owned(),
            format!(
                r#"{{"selection":{selection},"from":{selection},"target":{selection},"diff":{page},"counts":{{"partial":{{"changes":0,"conflicts":0}},"next_after":null}},"conflicts":{page},"dangling":{page},"base_moved":null}}"#
            ),
            receipt.to_owned(),
        ]
    }

    fn roundtrip<T: serde::de::DeserializeOwned + serde::Serialize>(
        text: &str,
    ) -> Result<Value, Box<dyn Error>> {
        let output: T = serde_json::from_str(text)?;
        Ok(serde_json::from_str(&serde_json::to_string(&output)?)?)
    }

    #[test]
    fn every_generated_output_schema_matches_its_typed_output() -> TestResult {
        use crate::mcp::types::{
            CreateBranchOutput, DefineCollectionOutput, DeleteRecordOutput, DescribeStoreOutput,
            LinkRecordsOutput, MergeBranchOutput, PutRecordsOutput, ReadHistoryOutput,
            ReviewBranchOutput, RunQueryOutput, UnlinkRecordsOutput,
        };

        let fixtures = output_fixtures();
        let values = [
            roundtrip::<DescribeStoreOutput>(&fixtures[0])?,
            roundtrip::<RunQueryOutput>(&fixtures[1])?,
            roundtrip::<ReadHistoryOutput>(&fixtures[2])?,
            roundtrip::<DefineCollectionOutput>(&fixtures[3])?,
            roundtrip::<PutRecordsOutput>(&fixtures[4])?,
            roundtrip::<DeleteRecordOutput>(&fixtures[5])?,
            roundtrip::<LinkRecordsOutput>(&fixtures[6])?,
            roundtrip::<UnlinkRecordsOutput>(&fixtures[7])?,
            roundtrip::<CreateBranchOutput>(&fixtures[8])?,
            roundtrip::<ReviewBranchOutput>(&fixtures[9])?,
            roundtrip::<MergeBranchOutput>(&fixtures[10])?,
        ];
        assert_eq!(values.len(), 11);
        for (tool, value) in tools().into_iter().zip(values) {
            let schema = serde_json::to_value(tool.output_schema)?;
            let validator = jsonschema::validator_for(&schema)?;
            assert!(validator.is_valid(&value), "{}: {schema}", tool.name);
        }
        Ok(())
    }

    #[test]
    fn a_shape_is_required_and_null_explicitly_removes_it() -> TestResult {
        use crate::mcp::types::DefineCollectionInput;

        let prefix = r#""request_id":"r1","branch":"feature","expected":{"generation":"1","sequence":"2"},"collection":"records""#;
        let definitions = tools();
        let tool = definitions
            .iter()
            .find(|tool| tool.name == "define_collection")
            .ok_or("collection tool absent")?;
        let validator = jsonschema::validator_for(&serde_json::to_value(&tool.input_schema)?)?;
        for shape in ["null", "true", "false", r#"{"type":"object"}"#] {
            let text = format!(r#"{{{prefix},"schema":{shape}}}"#);
            let _: DefineCollectionInput = serde_json::from_str(&text)?;
            assert!(
                validator.is_valid(&serde_json::from_str::<Value>(&text)?),
                "{shape}"
            );
        }
        let missing = format!("{{{prefix}}}");
        assert!(serde_json::from_str::<DefineCollectionInput>(&missing).is_err());
        assert!(!validator.is_valid(&serde_json::from_str::<Value>(&missing)?));
        Ok(())
    }

    #[test]
    fn describe_counts_are_complete_and_never_advertise_a_cursor() -> TestResult {
        let cursors = serde_json::to_value(crate::mcp::types::DescribeCursors::default())?;
        assert!(cursors.get("counts").is_none());
        let tool = tools()
            .into_iter()
            .find(|tool| tool.name == "describe_store")
            .ok_or("describe schema missing")?;
        let validator = jsonschema::validator_for(tool.output_schema.as_value())?;
        let selection = json!({"branch":"main","version":{"generation":"1","sequence":"2"}});
        let page = json!({"entries":[],"next_after":null});
        let output = json!({
            "selection":selection,
            "branches":page,
            "collections":page,
            "relations":page,
            "vector_fields":page,
            "counts":{"collections":0,"documents":0,"edges":0,"vectors":0}
        });
        assert!(validator.is_valid(&output));
        let output: crate::mcp::types::DescribeStoreOutput = serde_json::from_value(output)?;
        let encoded = serde_json::to_value(output)?;
        assert!(encoded["counts"].get("partial").is_none());
        assert!(encoded["counts"].get("next_after").is_none());
        Ok(())
    }

    #[test]
    fn page_schemas_and_typed_limits_refuse_rows_above_u32_max() -> TestResult {
        for (tool, (_, fixture)) in tools().into_iter().zip(inputs()) {
            if ![
                "describe_store",
                "run_query",
                "read_history",
                "review_branch",
            ]
            .contains(&tool.name)
            {
                continue;
            }
            let validator = jsonschema::validator_for(tool.input_schema.as_value())?;
            let mut input: Value = serde_json::from_str(&fixture)?;
            input["page"]["rows"] = json!(u32::MAX);
            assert!(validator.is_valid(&input), "{} rejects u32::MAX", tool.name);
            input["page"]["rows"] = json!(u64::from(u32::MAX) + 1);
            assert!(
                !validator.is_valid(&input),
                "{} accepts rows above u32::MAX",
                tool.name
            );
        }
        assert!(
            serde_json::from_value::<crate::mcp::types::PageLimits>(
                json!({"rows":u32::MAX,"bytes":4096})
            )
            .is_ok()
        );
        assert!(
            serde_json::from_value::<crate::mcp::types::PageLimits>(
                json!({"rows":u64::from(u32::MAX)+1,"bytes":4096})
            )
            .is_err()
        );
        Ok(())
    }

    #[test]
    fn review_counts_do_not_advertise_a_dangling_presence_boolean() -> TestResult {
        let counts: crate::mcp::types::ReviewCounts =
            serde_json::from_value(json!({"changes":0,"conflicts":0}))?;
        assert_eq!(
            serde_json::to_value(counts)?,
            json!({"changes":0,"conflicts":0})
        );
        assert!(
            serde_json::from_value::<crate::mcp::types::ReviewCounts>(
                json!({"changes":0,"conflicts":0,"dangling":false})
            )
            .is_err()
        );
        let tool = tools()
            .into_iter()
            .find(|tool| tool.name == "review_branch")
            .ok_or("review schema missing")?;
        let validator = jsonschema::validator_for(tool.output_schema.as_value())?;
        let mut output: Value =
            serde_json::from_str(output_fixtures().get(9).ok_or("review fixture missing")?)?;
        output["counts"]["partial"] = json!({"changes":0,"conflicts":0});
        assert!(validator.is_valid(&output));
        output["counts"]["partial"]["dangling"] = json!(false);
        assert!(!validator.is_valid(&output));
        Ok(())
    }
}