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::error::Error;
use std::path::Path;
use std::time::Duration;

use haematite::db::DatabaseConfig;
use haematite::store::NodeCacheBudget;
use serde_json::json;

use super::{branch_rename, parse};
use crate::serve::protocol::{Change, CollectionWrite, Fork, Mutation, Selection, Version};
use crate::serve::storage::{Captured, Storage};

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

const WAIT: Duration = Duration::from_secs(5);

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

fn head(storage: &Storage, branch: &str) -> Result<Option<Version>, Box<dyn Error>> {
    Ok(storage.catalogue().head(branch)?)
}

fn captured(storage: &Storage, branch: &str) -> Result<Captured, Box<dyn Error>> {
    let version = head(storage, branch)?.ok_or("branch absent")?;
    Ok(storage.capture(&Selection {
        branch: branch.to_owned(),
        version: Some(version),
    })?)
}

fn roots(captured: &Captured) -> Vec<String> {
    captured
        .shards
        .iter()
        .map(|shard| format!("{}:{}", shard.shard_id, shard.head))
        .collect()
}

/// A two-shard store whose branch `old-name` differs from main by one
/// collection, and the service configuration naming it.
fn store(directory: &Path) -> Result<String, Box<dyn Error>> {
    let data = directory.join("database");
    let mut storage = Storage::create(
        DatabaseConfig {
            data_dir: data.clone(),
            shard_count: 2,
            distributed: None,
            executor_threads: Some(1),
            node_cache_budget: Some(NodeCacheBudget::Unlimited),
            retention: haematite::Retention::KeepAll,
        },
        NodeCacheBudget::Unlimited,
        WAIT,
    )?;
    let main = head(&storage, "main")?.ok_or("no main")?;
    storage.mutate(&Mutation {
        request_id: "fork".to_owned(),
        branch: "main".to_owned(),
        expected: main,
        change: Change::Fork(Fork {
            name: "old-name".to_owned(),
            from: main,
        }),
    })?;
    let old = head(&storage, "old-name")?.ok_or("no fork")?;
    storage.mutate(&Mutation {
        request_id: "wards".to_owned(),
        branch: "old-name".to_owned(),
        expected: old,
        change: Change::Collection(CollectionWrite {
            collection: "wards".to_owned(),
            schema: None,
        }),
    })?;
    drop(storage);
    let config = directory.join("serve.json");
    std::fs::write(
        &config,
        serde_json::to_vec(&json!({
            "data_dir": data,
            "socket": directory.join("serve.sock"),
            "branch_cache_bytes_per_shard": 1_048_576,
            "max_frame": 65_536,
            "max_clients": 1,
            "max_page": 8,
            "timeout_ms": 1000
        }))?,
    )?;
    Ok(config.to_str().ok_or("path is not UTF-8")?.to_owned())
}

fn reopened(directory: &Path) -> Result<Storage, Box<dyn Error>> {
    Ok(Storage::open(
        &directory.join("database"),
        NodeCacheBudget::Unlimited,
        WAIT,
    )?)
}

#[test]
fn a_rename_moves_the_head_and_discards_the_old_name() -> TestResult {
    let directory = tempfile::tempdir()?;
    let config = store(directory.path())?;
    let before = roots(&captured(&reopened(directory.path())?, "old-name")?);
    let rename = [
        "--config", &config, "--from", "old-name", "--to", "new-name", "--apply",
    ];
    branch_rename(args(&rename))?;
    let storage = reopened(directory.path())?;
    assert_eq!(
        head(&storage, "old-name")?,
        None,
        "the old name is discarded"
    );
    let renamed = captured(&storage, "new-name")?;
    let anchors: Vec<String> = renamed
        .shards
        .iter()
        .map(|shard| format!("{}:{}", shard.shard_id, shard.fork_anchor))
        .collect();
    assert_eq!(anchors, before, "the new name was forked from the old head");
    assert_ne!(
        roots(&captured(&storage, "main")?),
        before,
        "a control: main differs"
    );
    drop(storage);
    // A second run finds nothing left to move and says so by name.
    let again = branch_rename(args(&rename));
    assert!(
        again
            .as_ref()
            .is_err_and(|error| error.starts_with("branch_absent")),
        "{again:?}"
    );
    Ok(())
}

#[test]
fn a_check_changes_nothing() -> TestResult {
    let directory = tempfile::tempdir()?;
    let config = store(directory.path())?;
    let storage = reopened(directory.path())?;
    let before = (
        head(&storage, "old-name")?,
        roots(&captured(&storage, "old-name")?),
    );
    drop(storage);
    branch_rename(args(&[
        "--config", &config, "--from", "old-name", "--to", "new-name", "--check",
    ]))?;
    let storage = reopened(directory.path())?;
    assert_eq!(
        (
            head(&storage, "old-name")?,
            roots(&captured(&storage, "old-name")?)
        ),
        before
    );
    assert_eq!(head(&storage, "new-name")?, None);
    Ok(())
}

#[test]
fn an_unplaceable_absent_or_taken_name_is_refused_and_nothing_moves() -> TestResult {
    let directory = tempfile::tempdir()?;
    let config = store(directory.path())?;
    for (from, to, expected) in [
        ("old-name", "import/x", "branch_name_unplaceable"),
        ("nowhere", "new-name", "branch_absent"),
        ("old-name", "main", "branch_exists"),
    ] {
        let refused = branch_rename(args(&[
            "--config", &config, "--from", from, "--to", to, "--apply",
        ]));
        assert!(
            refused
                .as_ref()
                .is_err_and(|error| error.starts_with(expected)),
            "{from} to {to}: {refused:?}"
        );
    }
    let storage = reopened(directory.path())?;
    assert!(head(&storage, "old-name")?.is_some());
    assert_eq!(head(&storage, "import/x")?, None);
    Ok(())
}

#[test]
fn malformed_arguments_are_refused() {
    for words in [
        &["--config", "c", "--from", "a", "--to", "b"][..],
        &["--config", "c", "--from", "a", "--to", "a", "--check"][..],
        &[
            "--config", "c", "--from", "a", "--from", "b", "--to", "c", "--check",
        ][..],
        &[
            "--config", "c", "--from", "a", "--to", "b", "--check", "--apply",
        ][..],
        &[
            "--config", "c", "--from", "a", "--to", "b", "--check", "--json",
        ][..],
        &["--config", "c", "--from"][..],
    ] {
        assert!(parse(args(words)).is_err(), "{words:?}");
    }
}