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
use std::error::Error as StdError;
use std::fs;
use std::os::unix::fs::{PermissionsExt, symlink};
use std::path::Path;

use serde_json::{Value, json};
use tempfile::TempDir;

use super::install;

#[path = "plugin_tests.rs"]
mod plugin;

pub(super) type TestResult = Result<(), Box<dyn StdError>>;

pub(super) struct Fixture {
    pub(super) directory: TempDir,
    pub(super) binary: std::path::PathBuf,
    pub(super) config: std::path::PathBuf,
    pub(super) bundle: std::path::PathBuf,
}

impl Fixture {
    pub(super) fn new() -> Result<Self, Box<dyn StdError>> {
        let directory = TempDir::new()?;
        let root = directory.path().canonicalize()?;
        let binary = root.join("haematite-source");
        let worker = root.join("query-worker");
        executable(&binary, b"operator")?;
        executable(&worker, b"query")?;
        let config = root.join("serve.json");
        let value = json!({
            "data_dir": directory.path().join("data"),
            "socket": directory.path().join("service.sock"),
            "branch_cache_bytes_per_shard": 1_048_576,
            "max_frame": 1_048_576, "max_clients": 4, "max_page": 32, "timeout_ms": 30000,
            "query_time_ms": 100, "query_memory_bytes": 1_048_576,
            "query_outputs_per_record": 16, "query_scan_records": 32,
            "query_max_running": 2, "query_worker": worker,
        });
        fs::write(&config, serde_json::to_vec(&value)?)?;
        let bundle = root.join("bundle");
        Ok(Self {
            directory,
            binary,
            config,
            bundle,
        })
    }

    fn value(&self) -> Result<Value, Box<dyn StdError>> {
        Ok(serde_json::from_slice(&fs::read(&self.config)?)?)
    }

    fn set(&self, value: &Value) -> TestResult {
        fs::write(&self.config, serde_json::to_vec(value)?)?;
        Ok(())
    }
}

fn executable(path: &Path, bytes: &[u8]) -> TestResult {
    fs::write(path, bytes)?;
    fs::set_permissions(path, fs::Permissions::from_mode(0o700))?;
    Ok(())
}

#[test]
fn bundle_contains_real_workers_and_local_plugin_with_sha256s() -> TestResult {
    let fixture = Fixture::new()?;
    let report = install(&fixture.binary, &fixture.config, &fixture.bundle)?;
    assert!(!report.already_installed);
    assert_eq!(report.files, 9);
    assert_eq!(fs::read(fixture.bundle.join("bin/haematite"))?, b"operator");
    assert_eq!(fs::read(fixture.bundle.join("bin/haematite-jq"))?, b"query");
    let config: Value = serde_json::from_slice(&fs::read(fixture.bundle.join("serve.json"))?)?;
    assert_eq!(
        config["query_worker"],
        fixture
            .bundle
            .join("bin/haematite-jq")
            .to_str()
            .ok_or("path")?
    );
    let plugin: Value = serde_json::from_slice(&fs::read(
        fixture.bundle.join(".claude-plugin/plugin.json"),
    )?)?;
    assert_eq!(
        plugin["mcpServers"]["haematite"]["command"],
        "${CLAUDE_PLUGIN_ROOT}/bin/haematite"
    );
    assert_eq!(
        plugin["mcpServers"]["haematite"]["args"],
        json!(["mcp", "--config", "${CLAUDE_PLUGIN_ROOT}/serve.json"])
    );
    assert!(plugin.get("userConfig").is_none());
    let manifest: Value = serde_json::from_slice(&fs::read(fixture.bundle.join("bundle.json"))?)?;
    assert_eq!(
        manifest["files"]["bin/haematite"]["sha256"],
        "06e55b633481f7bb072957eabcf110c972e86691c3cfedabe088024bffe42f23"
    );
    assert_eq!(
        fs::metadata(fixture.bundle.join("serve.json"))?
            .permissions()
            .mode()
            & 0o777,
        0o600
    );
    assert!(!fixture.bundle.join("bin/haematite-embed-stand-in").exists());
    Ok(())
}

#[test]
fn identical_install_is_verified_and_does_not_rewrite_files() -> TestResult {
    use std::os::unix::fs::MetadataExt;
    let fixture = Fixture::new()?;
    install(&fixture.binary, &fixture.config, &fixture.bundle)?;
    let path = fixture.bundle.join("bundle.json");
    let before = fs::metadata(&path)?.ino();
    let report = install(&fixture.binary, &fixture.config, &fixture.bundle)?;
    assert!(report.already_installed);
    assert_eq!(fs::metadata(path)?.ino(), before);
    Ok(())
}

#[test]
fn incomplete_destination_is_refused_without_repair() -> TestResult {
    let fixture = Fixture::new()?;
    fs::create_dir(&fixture.bundle)?;
    fs::write(fixture.bundle.join("sentinel"), b"keep")?;
    let error = install(&fixture.binary, &fixture.config, &fixture.bundle)
        .err()
        .ok_or("accepted partial")?;
    assert_eq!(error.code, "bundle_manifest_invalid");
    assert_eq!(fs::read(fixture.bundle.join("sentinel"))?, b"keep");
    assert_eq!(fs::read_dir(&fixture.bundle)?.count(), 1);
    Ok(())
}

#[test]
fn removed_or_corrupt_payload_refuses_the_repeat() -> TestResult {
    for remove in [false, true] {
        let fixture = Fixture::new()?;
        install(&fixture.binary, &fixture.config, &fixture.bundle)?;
        let worker = fixture.bundle.join("bin/haematite-jq");
        if remove {
            fs::remove_file(&worker)?;
        } else {
            fs::write(&worker, b"wrong")?;
        }
        assert_eq!(
            install(&fixture.binary, &fixture.config, &fixture.bundle)
                .err()
                .ok_or("accepted corrupt")?
                .code,
            "bundle_payload_invalid"
        );
    }
    Ok(())
}

#[test]
fn changed_source_is_a_conflict_and_keeps_installed_bytes() -> TestResult {
    let fixture = Fixture::new()?;
    install(&fixture.binary, &fixture.config, &fixture.bundle)?;
    executable(&fixture.binary, b"new operator")?;
    assert_eq!(
        install(&fixture.binary, &fixture.config, &fixture.bundle)
            .err()
            .ok_or("accepted different")?
            .code,
        "bundle_conflict"
    );
    assert_eq!(fs::read(fixture.bundle.join("bin/haematite"))?, b"operator");
    Ok(())
}

#[test]
fn missing_worker_fails_before_destination_is_created() -> TestResult {
    let fixture = Fixture::new()?;
    let worker = fixture.value()?["query_worker"]
        .as_str()
        .ok_or("worker path")?
        .to_owned();
    fs::remove_file(worker)?;
    assert_eq!(
        install(&fixture.binary, &fixture.config, &fixture.bundle)
            .err()
            .ok_or("accepted missing")?
            .code,
        "bundle_source_invalid"
    );
    assert!(!fixture.bundle.exists());
    Ok(())
}

#[test]
fn configured_embedding_worker_is_copied_without_copying_the_model() -> TestResult {
    let fixture = Fixture::new()?;
    let worker = fixture.directory.path().join("embedding-source");
    executable(&worker, b"embed")?;
    let mut value = fixture.value()?;
    let fields = json!({
        "embed_worker": worker, "embed_model_dir": fixture.directory.path().join("model"),
        "embed_model_sha256": "a".repeat(64), "embed_memory_bytes": 1_048_576,
        "embed_threads": 1, "embed_batch_items": 4, "embed_input_bytes": 4096,
        "embed_tokens": 512, "embed_frame_bytes": 65536, "embed_ready_ms": 100,
        "embed_call_ms": 100, "embed_waiting": 4, "embed_request_items": 4,
        "embed_page_records": 32
    });
    for (key, field) in fields.as_object().ok_or("fields")? {
        value[key] = field.clone();
    }
    fixture.set(&value)?;
    let report = install(&fixture.binary, &fixture.config, &fixture.bundle)?;
    assert_eq!(report.files, 10);
    assert_eq!(
        fs::read(fixture.bundle.join("bin/haematite-embed"))?,
        b"embed"
    );
    assert!(!fixture.bundle.join("model").exists());
    Ok(())
}

#[test]
fn symlink_or_unexpected_entry_or_changed_mode_is_not_a_verified_bundle() -> TestResult {
    for kind in ["symlink", "extra", "mode"] {
        let fixture = Fixture::new()?;
        install(&fixture.binary, &fixture.config, &fixture.bundle)?;
        let worker = fixture.bundle.join("bin/haematite-jq");
        match kind {
            "symlink" => {
                fs::remove_file(&worker)?;
                symlink(&fixture.binary, &worker)?;
            }
            "extra" => {
                fs::write(fixture.bundle.join("extra"), b"unknown")?;
            }
            "mode" => fs::set_permissions(worker, fs::Permissions::from_mode(0o600))?,
            _ => return Err("unknown fixture".into()),
        }
        assert_eq!(
            install(&fixture.binary, &fixture.config, &fixture.bundle)
                .err()
                .ok_or("accepted drift")?
                .code,
            if kind == "extra" {
                "bundle_inventory_invalid"
            } else {
                "bundle_payload_invalid"
            }
        );
    }
    Ok(())
}

#[test]
fn symlink_destination_and_relative_data_paths_are_refused() -> TestResult {
    let fixture = Fixture::new()?;
    let other = fixture.directory.path().join("other");
    fs::create_dir(&other)?;
    symlink(&other, &fixture.bundle)?;
    assert_eq!(
        install(&fixture.binary, &fixture.config, &fixture.bundle)
            .err()
            .ok_or("accepted link")?
            .code,
        "bundle_destination_invalid"
    );
    fs::remove_file(&fixture.bundle)?;
    let mut value = fixture.value()?;
    value["data_dir"] = json!("relative");
    fixture.set(&value)?;
    assert_eq!(
        install(&fixture.binary, &fixture.config, &fixture.bundle)
            .err()
            .ok_or("accepted relative")?
            .code,
        "bundle_config_invalid"
    );
    assert!(!fixture.bundle.exists());
    Ok(())
}

#[test]
fn disabled_workers_are_omitted_and_an_incomplete_group_is_refused() -> TestResult {
    let fixture = Fixture::new()?;
    let mut value = fixture.value()?;
    let object = value.as_object_mut().ok_or("configuration object")?;
    for key in crate::serve::query::settings::NAMES {
        object.remove(key);
    }
    fixture.set(&value)?;
    let report = install(&fixture.binary, &fixture.config, &fixture.bundle)?;
    assert_eq!(report.files, 8);
    assert!(!fixture.bundle.join("bin/haematite-jq").exists());
    let fixture = Fixture::new()?;
    let mut value = fixture.value()?;
    value
        .as_object_mut()
        .ok_or("configuration object")?
        .remove("query_worker");
    fixture.set(&value)?;
    assert_eq!(
        install(&fixture.binary, &fixture.config, &fixture.bundle)
            .err()
            .ok_or("accepted partial settings")?
            .code,
        "bundle_config_invalid"
    );
    assert!(!fixture.bundle.exists());
    Ok(())
}

#[test]
fn test_only_worker_is_refused_before_any_bundle_write() -> TestResult {
    let fixture = Fixture::new()?;
    let worker = fixture.directory.path().join("haematite-embed-stand-in");
    executable(&worker, b"embed-stand-in")?;
    let mut value = fixture.value()?;
    value["query_worker"] = json!(worker);
    fixture.set(&value)?;
    assert_eq!(
        install(&fixture.binary, &fixture.config, &fixture.bundle)
            .err()
            .ok_or("accepted test-only worker")?
            .code,
        "bundle_test_worker_refused"
    );
    assert!(!fixture.bundle.exists());
    Ok(())
}

#[test]
fn a_renamed_stand_in_is_refused_by_content_before_any_write() -> TestResult {
    let fixture = Fixture::new()?;
    let worker = fixture.directory.path().join("renamed-worker");
    executable(&worker, b"binary-prefix embed-stand-in binary-suffix")?;
    let mut value = fixture.value()?;
    value["query_worker"] = json!(worker);
    fixture.set(&value)?;
    let error = install(&fixture.binary, &fixture.config, &fixture.bundle)
        .err()
        .ok_or("renamed stand-in accepted")?;
    assert_eq!(error.code, "bundle_test_worker_refused");
    assert!(error.partial.is_none());
    assert!(!fixture.bundle.exists());
    Ok(())
}

#[test]
fn changing_the_config_snapshot_requires_a_new_bundle_directory() -> TestResult {
    let fixture = Fixture::new()?;
    install(&fixture.binary, &fixture.config, &fixture.bundle)?;
    let old_config = fs::read(fixture.bundle.join("serve.json"))?;
    let mut value = fixture.value()?;
    value["socket"] = json!(fixture.directory.path().join("replacement.sock"));
    value["data_dir"] = json!(fixture.directory.path().join("replacement-data"));
    fixture.set(&value)?;
    let error = install(&fixture.binary, &fixture.config, &fixture.bundle)
        .err()
        .ok_or("new config overwrote bundle")?;
    assert_eq!(error.code, "bundle_conflict");
    assert_eq!(fs::read(fixture.bundle.join("serve.json"))?, old_config);
    let fresh = fixture.directory.path().join("new-bundle");
    install(&fixture.binary, &fixture.config, &fresh)?;
    let installed: Value = serde_json::from_slice(&fs::read(fresh.join("serve.json"))?)?;
    assert_eq!(installed["socket"], value["socket"]);
    assert_eq!(installed["data_dir"], value["data_dir"]);
    Ok(())
}

#[test]
fn an_empty_stage_inventory_is_absent_from_error_display() {
    let error = super::Error::new("bundle_arguments_invalid", "argument missing");
    assert_eq!(
        error.to_string(),
        "bundle_arguments_invalid: argument missing"
    );
    let partial = super::Error::new("bundle_write_failed", "payload")
        .with_partial(&["bin/haematite".to_owned()], false);
    assert_eq!(
        partial.to_string(),
        "bundle_write_failed: payload; bundle_partial: completed=[\"bin/haematite\"]; published=false"
    );
}

#[test]
fn a_nonempty_stage_inventory_remains_in_error_display() {
    let error = super::Error::new("bundle_arguments_invalid", "argument missing")
        .with_stale_stages(vec![".haem-stage-existing".to_owned()]);
    assert_eq!(
        error.to_string(),
        "bundle_arguments_invalid: argument missing; stale_stages=[\".haem-stage-existing\"]"
    );
}