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
//! `haematite observe-get`: one committed value read by key from a data directory,
//! as a read-only observer beside a live writer.
//!
//! The read goes through [`ReadOnlyDatabase`], which takes no lock, spawns no
//! shard actor and creates nothing on disk, so a running service is never
//! disturbed. It sees what the writer has committed; a write still in the
//! writer's buffer is not visible.

use std::io::Write as _;
use std::path::PathBuf;
use std::process::ExitCode;

use haematite::db::ReadOnlyDatabase;

/// The command's usage line, also printed on a usage error.
pub const USAGE: &str =
    "usage: haematite observe-get <data_dir> (--key <text> | --key-hex <hex>) [--hex]";

/// How the value is written to standard output.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Output {
    /// The value's bytes as stored.
    Raw,
    /// The value as lowercase hexadecimal and a newline.
    Hex,
}

#[derive(Debug, PartialEq, Eq)]
struct Request {
    data_dir: PathBuf,
    key: Vec<u8>,
    output: Output,
}

/// Exit 0 with the value on standard output; 1 when the key has no committed
/// value; 2 when the request is malformed or the directory cannot be read.
#[must_use]
pub fn run(args: impl Iterator<Item = String>) -> ExitCode {
    let request = match parse(args) {
        Ok(request) => request,
        Err(message) => {
            eprintln!("{message}\n{USAGE}");
            return ExitCode::from(2);
        }
    };
    let observer = match ReadOnlyDatabase::open(&request.data_dir) {
        Ok(observer) => observer,
        Err(error) => {
            eprintln!("refused: {}: {error}", request.data_dir.display());
            return ExitCode::from(2);
        }
    };
    match observer.get(&request.key) {
        Ok(Some(value)) => match write(&value, request.output) {
            Ok(()) => ExitCode::SUCCESS,
            Err(error) => {
                eprintln!("refused: standard output: {error}");
                ExitCode::from(2)
            }
        },
        Ok(None) => {
            eprintln!("absent: no committed value at that key");
            ExitCode::from(1)
        }
        Err(error) => {
            eprintln!("refused: {error}");
            ExitCode::from(2)
        }
    }
}

fn write(value: &[u8], output: Output) -> std::io::Result<()> {
    let mut stdout = std::io::stdout().lock();
    match output {
        Output::Raw => stdout.write_all(value)?,
        Output::Hex => {
            for byte in value {
                write!(stdout, "{byte:02x}")?;
            }
            writeln!(stdout)?;
        }
    }
    stdout.flush()
}

fn parse(mut args: impl Iterator<Item = String>) -> Result<Request, String> {
    let data_dir = args.next().ok_or("observe-get needs a data directory")?;
    let mut key = None;
    let mut output = Output::Raw;
    while let Some(flag) = args.next() {
        match flag.as_str() {
            "--key" | "--key-hex" if key.is_some() => {
                return Err("observe-get takes one key".to_owned());
            }
            "--key" => {
                key = Some(args.next().ok_or("--key needs a value")?.into_bytes());
            }
            "--key-hex" => {
                key = Some(from_hex(&args.next().ok_or("--key-hex needs a value")?)?);
            }
            "--hex" if output == Output::Hex => {
                return Err("--hex is repeated".to_owned());
            }
            "--hex" => output = Output::Hex,
            other => return Err(format!("unknown argument {other:?}")),
        }
    }
    Ok(Request {
        data_dir: PathBuf::from(data_dir),
        key: key.ok_or("observe-get needs --key or --key-hex")?,
        output,
    })
}

fn from_hex(text: &str) -> Result<Vec<u8>, String> {
    if text.is_empty() || !text.len().is_multiple_of(2) {
        return Err(format!(
            "--key-hex {text:?} is not an even number of hex digits"
        ));
    }
    text.as_bytes()
        .chunks(2)
        .map(|pair| {
            std::str::from_utf8(pair)
                .ok()
                .and_then(|digits| u8::from_str_radix(digits, 16).ok())
                .ok_or_else(|| format!("--key-hex {text:?} holds a non-hex digit"))
        })
        .collect()
}

#[cfg(test)]
#[path = "observe_tests.rs"]
mod tests;