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
//! STOR-003 H1a: a store the service creates reclaims under the default bound.

use std::error::Error;
use std::num::NonZeroUsize;

use haematite::db::DEFAULT_RECLAIM_MARK_BYTES;
use haematite::store::NodeCacheBudget;

use super::super::storage::Storage;
use super::{Creation, creation_config};

/// Fails while the service creates its stores under `KeepAll`: nothing would
/// ever be given back. The store it creates, opened again, reclaims under the
/// default marking bound.
#[test]
fn a_store_the_service_creates_reclaims_under_the_default_bound() -> Result<(), Box<dyn Error>> {
    let directory = tempfile::tempdir()?;
    let data_dir = directory.path().join("store");
    let creation = Creation {
        shards: NonZeroUsize::new(2).ok_or("two shards")?,
        cache_bytes_total: NonZeroUsize::new(1 << 20).ok_or("a cache")?,
        executor_threads: NonZeroUsize::new(1).ok_or("one thread")?,
    };
    let config = creation_config(data_dir.clone(), &creation);
    assert_eq!(config.retention, haematite::Retention::reclaim(0));
    let created = Storage::create(
        config,
        NodeCacheBudget::Bytes(creation.cache_bytes_total),
        fixture_long_wait_interval(),
    )
    .map_err(|failure| format!("{failure:?}"))?;
    drop(created);
    let reopened = haematite::Database::open_path_writer(
        &data_dir,
        haematite::RuntimeOptions {
            long_wait_interval: fixture_long_wait_interval(),
            reclaim_start: haematite::ReclaimStart::AtOpen,
        },
    )?;
    assert_eq!(
        reopened.reclaim_mark_bytes(),
        Some(DEFAULT_RECLAIM_MARK_BYTES)
    );
    Ok(())
}

mod socket_preflight {
    use std::error::Error;
    use std::fs;
    use std::os::unix::fs::PermissionsExt;
    use std::path::PathBuf;

    use serde_json::{Value, json};

    use super::super::run;

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

    struct Fixture {
        directory: tempfile::TempDir,
        data: PathBuf,
        socket: PathBuf,
    }

    impl Fixture {
        fn new() -> Result<Self, Box<dyn Error>> {
            let directory = tempfile::tempdir()?;
            fs::set_permissions(directory.path(), fs::Permissions::from_mode(0o700))?;
            let data = directory.path().join("database");
            let socket = directory.path().join("socket/haematite.sock");
            Ok(Self {
                directory,
                data,
                socket,
            })
        }

        fn private_parent(&self) -> TestResult {
            let parent = self.socket.parent().ok_or("missing socket parent")?;
            fs::create_dir(parent)?;
            fs::set_permissions(parent, fs::Permissions::from_mode(0o700))?;
            Ok(())
        }

        fn config(&self) -> Value {
            json!({
                "data_dir": self.data,
                "socket": self.socket,
                "branch_cache_bytes_per_shard": 1_048_576,
                "max_frame": 65_536,
                "max_clients": 1,
                "max_page": 8,
                "timeout_ms": 1000,
                "create": {
                    "shards": 1,
                    "cache_bytes_total": 1_048_576,
                    "executor_threads": 1
                }
            })
        }

        fn refusal(&self, config: &Value) -> Result<String, Box<dyn Error>> {
            let path = self.directory.path().join("serve.json");
            fs::write(&path, serde_json::to_vec(config)?)?;
            let args = [
                "--config".to_owned(),
                path.to_str().ok_or("non-UTF-8 fixture path")?.to_owned(),
                "--create".to_owned(),
            ];
            run(args.into_iter())
                .err()
                .ok_or_else(|| "invalid startup configuration was accepted".into())
        }

        fn untouched(&self) -> TestResult {
            assert!(
                !self.data.try_exists()?,
                "startup refusal created the store"
            );
            assert!(
                !self.socket.try_exists()?,
                "startup refusal created a socket"
            );
            let parent = self.socket.parent().ok_or("missing socket parent")?;
            if parent.is_dir() {
                assert!(
                    !parent.join("haematite.sock.lock").try_exists()?,
                    "startup refusal created the ownership anchor"
                );
            }
            Ok(())
        }
    }

    fn private_directory_refusal() -> &'static str {
        "bind service socket: socket directory must be private (mode 0700)"
    }

    #[test]
    fn create_refuses_non_private_socket_directory_before_creating_database() -> TestResult {
        let fixture = Fixture::new()?;
        fixture.private_parent()?;
        let parent = fixture.socket.parent().ok_or("missing socket parent")?;
        fs::set_permissions(parent, fs::Permissions::from_mode(0o755))?;
        assert_eq!(
            fixture.refusal(&fixture.config())?,
            private_directory_refusal()
        );
        fixture.untouched()?;
        assert_eq!(fs::metadata(parent)?.permissions().mode() & 0o777, 0o755);
        Ok(())
    }

    #[test]
    fn create_refuses_missing_socket_directory_before_creating_database() -> TestResult {
        let fixture = Fixture::new()?;
        let parent = fixture.socket.parent().ok_or("missing socket parent")?;
        let missing = fs::metadata(parent).err().ok_or("socket parent exists")?;
        assert_eq!(
            fixture.refusal(&fixture.config())?,
            format!("bind service socket: {missing}")
        );
        fixture.untouched()?;
        assert!(!parent.try_exists()?);
        Ok(())
    }

    #[test]
    fn create_refuses_non_directory_socket_parent_before_creating_database() -> TestResult {
        let fixture = Fixture::new()?;
        let parent = fixture.socket.parent().ok_or("missing socket parent")?;
        fs::write(parent, b"preserve")?;
        assert_eq!(
            fixture.refusal(&fixture.config())?,
            private_directory_refusal()
        );
        assert!(
            !fixture.data.try_exists()?,
            "startup refusal created the store"
        );
        assert_eq!(fs::read(parent)?, b"preserve");
        Ok(())
    }

    #[test]
    fn create_refuses_zero_listener_settings_before_creating_database() -> TestResult {
        for name in ["max_frame", "max_clients", "timeout_ms"] {
            let fixture = Fixture::new()?;
            fixture.private_parent()?;
            let mut config = fixture.config();
            config[name] = json!(0);
            let refusal = fixture.refusal(&config)?;
            assert!(refusal.starts_with("service config:"), "{name}: {refusal}");
            fixture.untouched()?;
        }
        Ok(())
    }

    #[test]
    fn a_missing_create_section_does_not_create_a_store_or_socket() -> TestResult {
        let fixture = Fixture::new()?;
        fixture.private_parent()?;
        let mut config = fixture.config();
        config
            .as_object_mut()
            .ok_or("configuration is not an object")?
            .remove("create");
        assert_eq!(
            fixture.refusal(&config)?,
            "--create needs a create section in the configuration"
        );
        fixture.untouched()?;
        Ok(())
    }
}

/// DR-12: `haematite release date` reads its four arguments, each once, the two
/// times as decimal nanoseconds.
#[test]
fn release_date_reads_its_four_arguments() -> Result<(), Box<dyn Error>> {
    let args = [
        "--config",
        "/c.json",
        "--branch",
        "work",
        "--generation",
        "7",
        "--at",
        "9",
    ];
    let (path, request) = super::parse_date(args.map(str::to_owned).into_iter())?;
    assert_eq!(path, std::path::PathBuf::from("/c.json"));
    assert_eq!(
        (request.branch.as_str(), request.generation, request.at),
        ("work", 7, 9)
    );
    Ok(())
}

/// A repeated, missing or non-decimal argument, or another verb, is a usage
/// error before any store is opened.
#[test]
fn release_date_refuses_a_repeated_missing_or_bad_argument() {
    let cases: [&[&str]; 4] = [
        &["--config", "/c.json", "--config", "/d.json"],
        &[
            "--config",
            "/c.json",
            "--branch",
            "work",
            "--generation",
            "7",
        ],
        &[
            "--config",
            "/c.json",
            "--branch",
            "work",
            "--generation",
            "seven",
            "--at",
            "9",
        ],
        &["--branch"],
    ];
    for case in cases {
        let args = case.iter().map(|arg| (*arg).to_owned());
        assert!(super::parse_date(args).is_err(), "{case:?}");
    }
    assert!(super::release(["stats".to_owned()].into_iter()).is_err());
}

const fn fixture_long_wait_interval() -> std::time::Duration {
    std::time::Duration::from_secs(1)
}