dbmd-cli 0.7.1

The `dbmd` command-line tool for db.md, the open standard for databases in plain files. A thin wrapper over dbmd-core: validate, search, query, graph, write, index, and log over a db.md store. Zero AI dependencies.
// SPDX-License-Identifier: Apache-2.0

//! `dbmd subscribe` — follow a brain's signed feed head.
//!
//! The client polls the brain's feed cursor and locally verifies the head's
//! identity, SHA-256, and Ed25519 signature before emitting an advance. A
//! path-scoped grant receives movement only because a signed full manifest
//! would disclose out-of-scope paths. `--once` reads the current head and exits — the composable
//! building block; the loop is a convenience over it.
//!
//! Output contract: one event per line. Under `--json` each line is a single
//! compact JSON object (NDJSON — a stream, unlike the one-shot commands'
//! pretty bodies) so a consumer can parse line-by-line. Transient transport
//! errors do not kill the loop (they warn on stderr and the poll retries);
//! an HTTP error does (a revoked grant or a deleted brain is a real state
//! change, not noise).

use std::path::Path;

use dbmd_core::linkmd::{self, LinkError};

use crate::cli::SubscribeArgs;
use crate::context::Context;
use crate::error::{CliError, CliResult, ExitCode};
use crate::sanitize::sanitize;

/// Run `dbmd subscribe`.
pub fn run(ctx: &Context, args: &SubscribeArgs) -> CliResult {
    if args.interval == 0 {
        return Err(CliError::new(
            ExitCode::Runtime,
            "BAD_INTERVAL",
            "--interval must be at least 1 second",
        ));
    }

    let brain = args.brain.trim().trim_start_matches('@');
    let cfg = linkmd::hub_config(args.hub.as_deref(), Path::new(&args.dir))?;

    let head = linkmd::head(&cfg, brain)?;
    let mut baseline = args.since.unwrap_or(head.seq);

    // First observation: always reported, so `--once` is a usable head read
    // and a loop consumer knows its starting point.
    emit(ctx, &head, baseline);
    if args.once {
        return Ok(());
    }
    baseline = baseline.max(head.seq);

    loop {
        std::thread::sleep(std::time::Duration::from_secs(args.interval));
        match linkmd::head(&cfg, brain) {
            Ok(h) => {
                if h.seq > baseline {
                    emit(ctx, &h, baseline);
                    baseline = h.seq;
                }
            }
            // A hub blip must not kill a follower; a real HTTP answer must.
            Err(LinkError::Transport { hub, message }) => {
                eprintln!("dbmd: subscribe: hub unreachable at {hub} ({message}); retrying");
            }
            Err(e) => return Err(e.into()),
        }
    }
}

/// One event line. Text: `seq <prev> -> <seq>`; JSON: a compact object. The
/// brain id in the text form is hub-authored → terminal-sanitized (the JSON
/// stream stays verbatim).
fn emit(ctx: &Context, head: &linkmd::Head, prev: u64) {
    if ctx.json {
        let mut v = serde_json::to_value(head).unwrap_or_default();
        v["prev"] = serde_json::json!(prev);
        // Compact, one object per line: this is a stream, not a document.
        println!("{v}");
    } else if head.seq == prev {
        println!("{} at feed seq {}", sanitize(&head.brain), head.seq);
    } else {
        println!(
            "{} advanced: feed seq {} -> {}",
            sanitize(&head.brain),
            prev,
            head.seq
        );
    }
}