haematite-cli 0.21.7

The haematite command: serve a store, its door and Studio, answer MCP, query with jq, and host the store's assistant seat
Documentation
use crate::support::{Fixture, TestResult};
use serde_json::{Value, json};
use std::fs;
use std::os::unix::fs::PermissionsExt;

fn backups(fixture: &Fixture) -> Result<Vec<std::path::PathBuf>, Box<dyn std::error::Error>> {
    let mut result = Vec::new();
    for entry in fs::read_dir(fixture.root())? {
        let entry = entry?;
        if entry
            .file_name()
            .to_string_lossy()
            .starts_with("door.json.before-give-up-after-")
        {
            result.push(entry.path());
        }
    }
    Ok(result)
}

#[test]
fn r5_missing_count_is_upgraded_backed_up_and_logged_before_start() -> TestResult {
    let mut fixture = Fixture::new()?;
    let mut child = fixture.child(
        "old-child",
        "import sys\nprint('fixture ready old', file=sys.stderr, flush=True)\nsys.stdin.read()",
    );
    child
        .as_object_mut()
        .ok_or("child is not an object")?
        .remove("give_up_after");
    fixture.config["children"] = json!([child]);
    let mut door = fixture.start()?;
    let upgraded = door.until("door_config_upgraded: give_up_after=3 written for old-child;")?;
    door.until("haematite door: listening on ")?;
    let old = backups(&fixture)?;
    assert_eq!(old.len(), 1);
    assert_eq!(fs::read(&old[0])?, serde_json::to_vec(&fixture.config)?);
    assert_eq!(fs::metadata(&old[0])?.permissions().mode() & 0o777, 0o600);
    assert!(upgraded.contains(&old[0].display().to_string()));
    let mut expected = fixture.config.clone();
    expected["children"][0]["give_up_after"] = json!(3);
    let actual: Value = serde_json::from_slice(&fs::read(fixture.root().join("door.json"))?)?;
    assert_eq!(actual, expected);
    Ok(())
}

#[test]
fn r5_no_children_and_explicit_counts_preserve_every_byte() -> TestResult {
    for children in [false, true] {
        let mut fixture = Fixture::new()?;
        if children {
            fixture.config["children"] = json!([fixture.child("explicit", "import sys\nprint('fixture ready explicit', file=sys.stderr, flush=True)\nsys.stdin.read()")]);
        }
        let mut door = fixture.start()?;
        door.until("haematite door: listening on ")?;
        assert_eq!(
            fs::read(fixture.root().join("door.json"))?,
            serde_json::to_vec(&fixture.config)?
        );
        assert!(backups(&fixture)?.is_empty());
        assert!(
            !door
                .lines
                .iter()
                .any(|line| line.contains("door_config_upgraded"))
        );
    }
    Ok(())
}

#[test]
fn r5_unwritable_upgrade_is_named_before_any_child_starts() -> TestResult {
    let mut fixture = Fixture::new()?;
    let mut child = fixture.child("old-child", "import sys\nwith open(sys.argv[1], 'a') as f: f.write('started\\n')\nprint('fixture ready', file=sys.stderr, flush=True)\nsys.stdin.read()");
    child
        .as_object_mut()
        .ok_or("child is not an object")?
        .remove("give_up_after");
    fixture.config["children"] = json!([child]);
    let mut door = fixture.start_readonly()?;
    let failure = door.until("door_config_upgrade_failed:")?;
    assert!(failure.contains("door.json"), "{failure}");
    assert!(!fixture.root().join("records").exists());
    assert_eq!(
        fs::read(fixture.root().join("door.json"))?,
        serde_json::to_vec(&fixture.config)?
    );
    assert!(backups(&fixture)?.is_empty());
    Ok(())
}