haematite-cli 0.21.1

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::{Read, Write};
use std::net::TcpListener;
use std::sync::mpsc;

use serde_json::{Value, json};

use super::{SCHEMA, operation_id, parse, read_credential, run};

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

const CREDENTIAL: &str = "lys-app.haematite.never-shown";

fn args(words: &[&str]) -> impl Iterator<Item = String> {
    words
        .iter()
        .map(|word| (*word).to_owned())
        .collect::<Vec<_>>()
        .into_iter()
}

/// A double of Lys's schema route answering `held` to the GET and, when
/// asked, `{"applied": true}` to the PUT; each request's method and body go
/// to the returned channel.
fn double(held: Value) -> Result<(String, mpsc::Receiver<(String, String)>), std::io::Error> {
    let listener = TcpListener::bind("127.0.0.1:0")?;
    let origin = format!("http://{}/", listener.local_addr()?);
    let (sent, seen) = mpsc::channel();
    std::thread::spawn(move || {
        for stream in listener.incoming() {
            let Ok(mut stream) = stream else { return };
            let mut bytes = Vec::new();
            let (method, body) = loop {
                let mut buffer = [0; 4096];
                let Ok(count) = stream.read(&mut buffer) else {
                    return;
                };
                if count == 0 {
                    return;
                }
                bytes.extend_from_slice(buffer.get(..count).unwrap_or_default());
                let text = String::from_utf8_lossy(&bytes).into_owned();
                let Some((request_head, body)) = text.split_once("\r\n\r\n") else {
                    continue;
                };
                let length = request_head
                    .lines()
                    .find_map(|line| {
                        let (name, value) = line.split_once(':')?;
                        name.eq_ignore_ascii_case("content-length")
                            .then(|| value.trim().parse::<usize>().ok())?
                    })
                    .unwrap_or(0);
                if body.len() >= length {
                    let method = request_head
                        .split(' ')
                        .next()
                        .unwrap_or_default()
                        .to_owned();
                    break (method, body.to_owned());
                }
            };
            let answer = if method == "PUT" {
                json!({"applied": true})
            } else {
                held.clone()
            };
            if sent.send((method, body)).is_err() {
                return;
            }
            let answer = answer.to_string();
            let response = format!(
                "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{answer}",
                answer.len()
            );
            if stream.write_all(response.as_bytes()).is_err() {
                return;
            }
        }
    });
    Ok((origin, seen))
}

fn credential(directory: &std::path::Path) -> Result<String, std::io::Error> {
    let path = directory.join("credential");
    std::fs::write(&path, format!("{CREDENTIAL}\n"))?;
    Ok(path.display().to_string())
}

#[test]
fn a_registered_schema_equal_to_the_file_passes_the_check() -> TestResult {
    let directory = tempfile::tempdir()?;
    let schema: Value = serde_json::from_str(SCHEMA)?;
    let (issuer, seen) = double(json!({"app": "haematite", "version": 4, "schema": schema}))?;
    let path = credential(directory.path())?;
    run(args(&[
        "--issuer",
        &issuer,
        "--credential-file",
        &path,
        "--check",
    ]))?;
    let (method, _) = seen.recv()?;
    assert_eq!(method, "GET");
    assert!(seen.try_recv().is_err(), "a check wrote to Lys");
    Ok(())
}

/// Lys stores a schema with each list of actions sorted and the empty
/// members filled in (installed Lys, 10 Oct 2026); that is the same schema.
#[test]
fn the_schema_as_lys_stores_it_passes_the_check() -> TestResult {
    let directory = tempfile::tempdir()?;
    let mut schema: Value = serde_json::from_str(SCHEMA)?;
    let kinds = schema["kinds"]
        .as_object_mut()
        .ok_or("schema.json has no kinds")?;
    for kind in kinds.values_mut() {
        if let Some(actions) = kind["actions"].as_array_mut() {
            actions.sort_by(|left, right| left.as_str().cmp(&right.as_str()));
            actions.reverse();
        }
        if kind.get("relations").is_none() {
            kind["relations"] = json!({});
        }
        kind["parents"] = kind.get("parents").cloned().unwrap_or_else(|| json!([]));
    }
    let (issuer, seen) = double(json!({"app": "haematite", "version": 2, "schema": schema}))?;
    let path = credential(directory.path())?;
    run(args(&[
        "--issuer",
        &issuer,
        "--credential-file",
        &path,
        "--check",
    ]))?;
    let (method, _) = seen.recv()?;
    assert_eq!(method, "GET");
    assert!(seen.try_recv().is_err(), "a check wrote to Lys");
    Ok(())
}

/// Sorting and empty members are Lys's storage form; an action missing or
/// added on either side is a real difference and still drifts.
#[test]
fn an_action_missing_or_added_still_drifts() -> TestResult {
    let schema: Value = serde_json::from_str(SCHEMA)?;
    let mut missing = schema.clone();
    missing["kinds"]["haematite.database"]["actions"]
        .as_array_mut()
        .ok_or("the database kind has no actions")?
        .pop();
    let mut added = schema;
    added["kinds"]["haematite.database"]["actions"]
        .as_array_mut()
        .ok_or("the database kind has no actions")?
        .push(json!("drop"));
    for registered in [missing, added] {
        let directory = tempfile::tempdir()?;
        let (issuer, seen) =
            double(json!({"app": "haematite", "version": 2, "schema": registered}))?;
        let path = credential(directory.path())?;
        let refused = run(args(&[
            "--issuer",
            &issuer,
            "--credential-file",
            &path,
            "--check",
        ]));
        let Err(words) = refused else {
            return Err("a schema with a different action passed the check".into());
        };
        assert!(words.starts_with("schema_drift:"), "{words}");
        assert_eq!(seen.recv()?.0, "GET");
        assert!(seen.try_recv().is_err(), "a check wrote to Lys");
    }
    Ok(())
}

#[test]
fn a_drifted_schema_fails_the_check_by_name_and_writes_nothing() -> TestResult {
    let directory = tempfile::tempdir()?;
    let (issuer, seen) = double(json!({"app": "haematite", "version": 4, "schema": {}}))?;
    let path = credential(directory.path())?;
    let refused = run(args(&[
        "--issuer",
        &issuer,
        "--credential-file",
        &path,
        "--check",
    ]))
    .err()
    .ok_or("a drifted schema passed the check")?;
    assert!(refused.starts_with("schema_drift"), "{refused}");
    assert!(
        !refused.contains(CREDENTIAL),
        "the credential was shown: {refused}"
    );
    let (method, _) = seen.recv()?;
    assert_eq!(method, "GET");
    assert!(seen.try_recv().is_err(), "a check wrote to Lys");
    Ok(())
}

#[test]
fn a_registration_replaces_the_held_version_with_the_file() -> TestResult {
    let directory = tempfile::tempdir()?;
    let (issuer, seen) = double(json!({"app": "haematite", "version": 4, "schema": {}}))?;
    let path = credential(directory.path())?;
    run(args(&[
        "--issuer",
        &issuer,
        "--credential-file",
        &path,
        "--register",
    ]))?;
    let (first, _) = seen.recv()?;
    assert_eq!(first, "GET");
    let (method, body) = seen.recv()?;
    assert_eq!(method, "PUT");
    let body: Value = serde_json::from_str(&body)?;
    assert_eq!(body["replaces"], 4);
    assert_eq!(body["schema"], serde_json::from_str::<Value>(SCHEMA)?);
    assert_eq!(body["operation"], operation_id());
    Ok(())
}

#[test]
fn the_operation_id_is_lys_shaped_and_the_same_for_the_same_file() {
    let id = operation_id();
    assert!(id.starts_with("op-"));
    assert_eq!(id.len(), 35);
    assert!(id.bytes().skip(3).all(|byte| byte.is_ascii_hexdigit()));
    assert_eq!(id, operation_id());
}

#[test]
fn the_arguments_are_each_given_once_and_the_credential_path_is_absolute() {
    for refused in [
        &["--issuer", "http://lys", "--credential-file", "/c"][..],
        &[
            "--issuer",
            "http://lys",
            "--credential-file",
            "c",
            "--check",
        ],
        &[
            "--issuer",
            "http://lys",
            "--credential-file",
            "/c",
            "--check",
            "--register",
        ],
        &["--credential-file", "/c", "--check"],
    ] {
        assert!(parse(args(refused)).is_err(), "{refused:?} was taken");
    }
}

#[test]
fn a_credential_file_holds_haematites_app_credential_on_one_line() -> TestResult {
    let directory = tempfile::tempdir()?;
    let path = directory.path().join("credential");
    std::fs::write(&path, format!("{CREDENTIAL}\n"))?;
    assert_eq!(read_credential(&path)?, CREDENTIAL);
    for wrong in ["lys-app.cambium.x\n", "lys-app.haematite.x\nsecond\n", ""] {
        std::fs::write(&path, wrong)?;
        let refused = read_credential(&path)
            .err()
            .ok_or("a wrong credential was taken")?;
        assert!(
            !refused.contains("lys-app"),
            "the credential was shown: {refused}"
        );
    }
    Ok(())
}

/// The gate's offline check: a recorded answer holding schema.json passes
/// with no issuer, no credential and no network (10 Oct 2026 row).
#[test]
fn a_recorded_schema_equal_to_the_file_passes_offline() -> TestResult {
    let directory = tempfile::tempdir()?;
    let path = directory.path().join("recorded.json");
    let schema: Value = serde_json::from_str(SCHEMA)?;
    std::fs::write(&path, json!({ "version": 7, "schema": schema }).to_string())?;
    let path = path.to_str().ok_or("path is not text")?;
    run(args(&["--recorded", path]))?;
    Ok(())
}

/// Fails while the recorded check passes anything: a recorded schema that
/// lacks a kind schema.json names is drift, by name.
#[test]
fn a_recorded_schema_that_differs_is_drift() -> TestResult {
    let directory = tempfile::tempdir()?;
    let path = directory.path().join("recorded.json");
    std::fs::write(&path, json!({ "version": 7, "schema": {} }).to_string())?;
    let path = path.to_str().ok_or("path is not text")?;
    let refused = run(args(&["--recorded", path]))
        .err()
        .ok_or("drift passed")?;
    assert!(
        refused.starts_with("schema_drift: the recorded haematite schema version 7"),
        "{refused}"
    );
    let missing = run(args(&["--recorded", "/nonexistent/recorded.json"]))
        .err()
        .ok_or("a missing file passed")?;
    assert!(missing.contains("could not be read"), "{missing}");
    Ok(())
}