mermaid-cli 0.19.0

Open-source AI pair programmer with agentic capabilities. Local-first with Ollama, native tool calling, and beautiful TUI.
Documentation
//! Integration coverage for the `mermaid run --format ndjson` event stream
//! and the session-addressable headless flow (`--resume <id>` / `--continue`).
//!
//! Structural and model-independent: whether the underlying model call succeeds
//! or (as here) fails fast on a missing key, the stream must open with
//! `session_started` and close with `result`, one JSON object per line — and
//! the emitted session id must point at a real conversation file that a second
//! `--resume <id>` run appends to. This guards the public SDK contract
//! end-to-end through the real binary.

use std::path::{Path, PathBuf};
use std::process::Command;
use std::time::{SystemTime, UNIX_EPOCH};

fn sandbox_dir() -> PathBuf {
    let nonce = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .expect("system time before epoch")
        .as_nanos();
    let dir = std::env::temp_dir().join(format!("mermaid-ndjson-{}-{}", std::process::id(), nonce));
    std::fs::create_dir_all(&dir).expect("create sandbox dir");
    dir
}

/// Run the real binary keylessly (the anthropic provider errors fast on the
/// missing key — no network), fully sandboxed: config/data under `home`, and
/// the working directory under `home/work` so the run's `.mermaid/` session
/// store never touches the repo checkout.
fn run_sandboxed(home: &Path, extra_args: &[&str]) -> std::process::Output {
    let work = home.join("work");
    std::fs::create_dir_all(&work).expect("create workdir");
    let mut args = vec!["--model", "anthropic/pty-exit-test"];
    args.extend_from_slice(extra_args);
    Command::new(env!("CARGO_BIN_EXE_mermaid"))
        .args(&args)
        .current_dir(&work)
        .env("HOME", home)
        .env("XDG_CONFIG_HOME", home.join("config"))
        .env("XDG_DATA_HOME", home.join("data"))
        .env_remove("ANTHROPIC_API_KEY")
        .output()
        .expect("spawn mermaid")
}

/// Parse the non-empty NDJSON lines of a run's stdout.
fn ndjson_lines(stdout: &str) -> Vec<serde_json::Value> {
    stdout
        .lines()
        .filter(|l| !l.trim().is_empty())
        .map(|line| {
            serde_json::from_str(line)
                .unwrap_or_else(|e| panic!("stream line is not JSON ({e}): {line:?}"))
        })
        .collect()
}

#[test]
fn run_ndjson_stream_opens_with_session_started_and_ends_with_result() {
    let home = sandbox_dir();
    let output = run_sandboxed(&home, &["run", "--format", "ndjson", "hi"]);

    let stdout = String::from_utf8_lossy(&output.stdout);
    let lines = ndjson_lines(&stdout);
    assert!(
        lines.len() >= 2,
        "expected at least session_started + result, got: {stdout:?}"
    );
    for value in &lines {
        assert!(
            value.get("type").and_then(|t| t.as_str()).is_some(),
            "stream line missing a `type` tag: {value:?}"
        );
    }

    let first = &lines[0];
    assert_eq!(
        first["type"], "session_started",
        "first line must open the stream"
    );
    assert_eq!(
        first["protocol_version"], 1,
        "protocol version must be pinned"
    );

    let last = &lines[lines.len() - 1];
    assert_eq!(
        last["type"], "result",
        "last line must be the terminal result"
    );

    let _ = std::fs::remove_dir_all(&home);
}

#[test]
fn run_emits_session_id_that_resumes_the_same_session() {
    let home = sandbox_dir();

    // First run: capture the emitted session id and confirm the file exists.
    let output = run_sandboxed(&home, &["run", "--format", "ndjson", "hi"]);
    let stdout = String::from_utf8_lossy(&output.stdout);
    let lines = ndjson_lines(&stdout);
    let session_id = lines[0]["session_id"]
        .as_str()
        .expect("session_started carries session_id")
        .to_string();
    // The id has the store's shape (YYYYMMDD_HHMMSS_mmm, 19 chars).
    assert_eq!(session_id.len(), 19, "unexpected id shape: {session_id}");
    // The terminal result repeats it.
    assert_eq!(
        lines[lines.len() - 1]["session_id"].as_str(),
        Some(session_id.as_str())
    );
    // Even this errored run persisted the session file (the emitted id must
    // never dangle).
    let conversation = home
        .join("work")
        .join(".mermaid")
        .join("conversations")
        .join(format!("{session_id}.json"));
    assert!(
        conversation.exists(),
        "session file missing: {}",
        conversation.display()
    );
    let first_len = std::fs::metadata(&conversation).expect("stat").len();

    // Second run resumes the SAME id and appends to the same file.
    let output = run_sandboxed(
        &home,
        &[
            "--resume",
            &session_id,
            "run",
            "--format",
            "ndjson",
            "again",
        ],
    );
    let stdout = String::from_utf8_lossy(&output.stdout);
    let lines = ndjson_lines(&stdout);
    assert_eq!(
        lines[0]["session_id"].as_str(),
        Some(session_id.as_str()),
        "resumed run must keep the session id"
    );
    let second_len = std::fs::metadata(&conversation).expect("stat").len();
    assert!(
        second_len > first_len,
        "resumed run must append to the session file ({first_len} -> {second_len})"
    );

    let _ = std::fs::remove_dir_all(&home);
}

#[test]
fn headless_resume_error_paths_are_clear() {
    let home = sandbox_dir();

    // A named-but-missing session id fails hard, naming the id.
    let output = run_sandboxed(&home, &["--resume", "19990101_000000_000", "run", "x"]);
    assert!(
        !output.status.success(),
        "missing session id must fail the run"
    );
    let stderr = String::from_utf8_lossy(&output.stderr);
    assert!(
        stderr.contains("19990101_000000_000"),
        "error must name the id: {stderr}"
    );

    // Bare --resume before `run` — clap's greedy value parsing swallows `run`
    // as the session id (a documented quirk of `--resume [SESSION_ID]`), so
    // this fails at parse or at load. Either way: non-zero, never a silent
    // fresh session.
    let output = run_sandboxed(&home, &["--resume", "run", "x"]);
    assert!(!output.status.success(), "bare --resume must fail headless");

    // --continue with no saved session must not silently start fresh.
    let output = run_sandboxed(&home, &["--continue", "run", "x"]);
    assert!(
        !output.status.success(),
        "--continue with no session must fail"
    );
    let stderr = String::from_utf8_lossy(&output.stderr);
    assert!(
        stderr.contains("no saved session"),
        "error must explain: {stderr}"
    );

    let _ = std::fs::remove_dir_all(&home);
}