yog 0.0.1

yog: a balls-oriented session manager for lernie loops (egui frontend)
Documentation
//! Tests for the short verbs and their dispatch/logging core.
//!
//! This module holds the shared [`World`] recorder fixture and the per-verb
//! argv/cwd tests; [`dispatch`] exercises the [`super::dispatch`] primitives
//! (run_logged, the synthetic spawn-failure line, the yog-step encoding, the
//! failure view-models) over the same fixture — split per §12's line budget.

use super::*;
use crate::opslog;
use crate::test_support::{OkProbe, spawn_guard};
use crate::world::toolgate::{self, Refusal, ToolProbe, ToolState};
use std::fs;
use std::os::unix::fs::PermissionsExt;
use std::path::PathBuf;
use std::sync::MutexGuard;
use tempfile::{TempDir, tempdir};

mod dispatch;
mod edit;

/// A permissive gate: every verb permits, so the dispatch proceeds to the spawn —
/// the axis these argv tests exercise. The gate's own classification is table-
/// tested in `crate::world::toolgate`.
fn ok_gate() -> ToolchainState {
    toolgate::probe(&OkProbe)
}

/// A stale toolchain whose `lernie prime` and `bl create` verbs fail — so a
/// `Mismatch` refuses the mutating dispatch (the gate working, §16.6 W5).
struct Stale;
impl ToolProbe for Stale {
    fn status(&self, binary: Binary, args: &[&str]) -> std::io::Result<i32> {
        let bad = (binary == Binary::Lernie && args.first() == Some(&"prime"))
            || (binary == Binary::Bl && args.first() == Some(&"create"));
        Ok(i32::from(bad) * 2)
    }
}

#[test]
fn a_mismatch_gate_refuses_the_dispatch_before_spawning() {
    let (w, _g) = World::new("bl", OK_BODY);
    let gate = toolgate::probe(&Stale);
    let err = close(&gate, &w.cli, w.state.path(), "TS", &w.cwd, "bl-7", "me").unwrap_err();
    // The typed refusal rides back — it names the tool + the missing verb + fix.
    let refusal = err.get_ref().unwrap().downcast_ref::<Refusal>().unwrap();
    assert_eq!(refusal.tool, "bl");
    assert!(matches!(&refusal.state, ToolState::Mismatch { verb, .. } if verb == "create"));
    // No spawn: the only ops row is the gate refusal (a `bl close` line would be a
    // second entry), so `run_logged` was never reached.
    let e = w.logged();
    assert_eq!(e.argv, ["yog-step", "gate"]);
    assert!(e.stderr.contains("bl: missing verb `create`"));
}

/// A fake `bl`/`lernie` under `SPAWN_LOCK` (the crate-wide spawn discipline);
/// the guard is held across the spawn each verb then performs.
fn recorder(dir: &Path, name: &str, body: &str) -> (PathBuf, MutexGuard<'static, ()>) {
    let guard = spawn_guard();
    let path = dir.join(name);
    fs::write(&path, body).unwrap();
    let mut perms = fs::metadata(&path).unwrap().permissions();
    perms.set_mode(0o755);
    fs::set_permissions(&path, perms).unwrap();
    (path, guard)
}

/// A recorder that prints `OUT` to stdout and `ERR` to stderr, exit 0.
const OK_BODY: &str = "#!/bin/sh\nprintf 'OUT\\n'\nprintf 'ERR\\n' 1>&2\nexit 0\n";

/// A hermetic verb world: a dir holding the fake binary, plus a state root for
/// `ops.jsonl`. `ws`/`project` reuse the (existing) dir so `run_in`'s chdir
/// succeeds.
struct World {
    _dir: TempDir,
    state: TempDir,
    cli: Cli,
    cwd: PathBuf,
}

impl World {
    fn new(name: &str, body: &str) -> (Self, MutexGuard<'static, ()>) {
        let dir = tempdir().unwrap();
        let (bin, guard) = recorder(dir.path(), name, body);
        let cwd = dir.path().to_path_buf();
        (
            Self {
                _dir: dir,
                state: tempdir().unwrap(),
                cli: Cli::new(bin),
                cwd,
            },
            guard,
        )
    }

    /// The single logged entry (each test issues exactly one verb).
    fn logged(&self) -> crate::opslog::OpEntry {
        let mut entries = opslog::tail(self.state.path(), 8);
        assert_eq!(entries.len(), 1, "exactly one op logged");
        entries.pop().unwrap()
    }
}

/// The verb args each dispatcher builds (argv past the binary), asserted via the
/// logged entry so the exact argv AND cwd are proven at once.
fn args_of(e: &crate::opslog::OpEntry) -> Vec<String> {
    e.argv[1..].to_vec()
}

#[test]
fn message_builds_argv_and_runs_in_the_workspace() {
    let (w, _g) = World::new("lernie", OK_BODY);
    let ws = &w.cwd;
    message(
        &ok_gate(),
        &w.cli,
        w.state.path(),
        "TS",
        ws,
        "a-1",
        "hi there",
    )
    .unwrap();
    let e = w.logged();
    assert_eq!(
        args_of(&e),
        vec!["message", &ws.display().to_string(), "a-1", "hi there"]
    );
    assert_eq!(e.cwd, ws.display().to_string());
}

#[test]
fn stop_omits_and_includes_the_children_flag() {
    let (w, _g) = World::new("lernie", OK_BODY);
    let ws = w.cwd.clone();
    stop(&ok_gate(), &w.cli, w.state.path(), "TS", &ws, "a-1", false).unwrap();
    assert_eq!(
        args_of(&w.logged()),
        vec!["stop", &ws.display().to_string(), "a-1"]
    );
    stop(&ok_gate(), &w.cli, w.state.path(), "TS", &ws, "a-1", true).unwrap();
    let e = opslog::tail(w.state.path(), 8).pop().unwrap();
    assert_eq!(
        args_of(&e),
        vec!["stop", &ws.display().to_string(), "a-1", "--stop-children"]
    );
}

#[test]
fn scan_builds_argv() {
    let (w, _g) = World::new("lernie", OK_BODY);
    let ws = &w.cwd;
    scan(&ok_gate(), &w.cli, w.state.path(), "TS", ws).unwrap();
    assert_eq!(
        args_of(&w.logged()),
        vec!["scan", &ws.display().to_string()]
    );
}

#[test]
fn close_and_unclaim_build_argv_in_the_project() {
    let (w, _g) = World::new("bl", OK_BODY);
    let proj = &w.cwd;
    close(
        &ok_gate(),
        &w.cli,
        w.state.path(),
        "TS",
        proj,
        "bl-7",
        "filtered",
    )
    .unwrap();
    let e = w.logged();
    assert_eq!(args_of(&e), vec!["close", "bl-7", "--as", "filtered"]);
    assert_eq!(e.cwd, proj.display().to_string());

    unclaim(
        &ok_gate(),
        &w.cli,
        w.state.path(),
        "TS",
        proj,
        "bl-7",
        "filtered",
    )
    .unwrap();
    let e = opslog::tail(w.state.path(), 8).pop().unwrap();
    assert_eq!(args_of(&e), vec!["unclaim", "bl-7", "--as", "filtered"]);
}

#[test]
fn assign_claims_the_ball_for_the_target_workspace() {
    let (w, _g) = World::new("bl", OK_BODY);
    let proj = &w.cwd;
    assign(
        &ok_gate(),
        &w.cli,
        w.state.path(),
        "TS",
        proj,
        "bl-7",
        "cobalt-gecko",
    )
    .unwrap();
    let e = w.logged();
    assert_eq!(args_of(&e), vec!["claim", "bl-7", "--as", "cobalt-gecko"]);
    assert_eq!(e.cwd, proj.display().to_string());
}

#[test]
fn reassign_unclaims_from_then_claims_to_both_logged() {
    let (w, _g) = World::new("bl", OK_BODY);
    let proj = &w.cwd;
    reassign(
        &ok_gate(),
        &w.cli,
        (w.state.path(), "TS"),
        proj,
        "bl-7",
        "amber-toad",
        "cobalt-gecko",
    )
    .unwrap();
    // §8.2 "short, piped ×2, both logged": unclaim `--as from`, then claim `--as to`.
    let ops = opslog::tail(w.state.path(), 8);
    assert_eq!(ops.len(), 2, "both spawns logged");
    assert_eq!(
        args_of(&ops[0]),
        vec!["unclaim", "bl-7", "--as", "amber-toad"]
    );
    assert_eq!(
        args_of(&ops[1]),
        vec!["claim", "bl-7", "--as", "cobalt-gecko"]
    );
}