yog 0.0.1

yog: a balls-oriented session manager for lernie loops (egui frontend)
Documentation
//! Test-only spawn discipline shared by every test module in this binary.
//!
//! Serializes script-write-then-spawn pairs across tests. Without this, a
//! concurrent posix_spawn in another thread inherits the write fd held by
//! `fs::write` in this thread; that fd is CLOEXEC but only closes once the
//! peer's own exec completes. If this thread's exec on the script it just
//! wrote lands while the peer child still holds the inherited write fd,
//! Linux returns ETXTBSY. Holding one lock across write + spawn in every
//! test eliminates the overlap window — it must be a single static for the
//! whole binary: per-module locks do not exclude each other's threads.

use crate::cli_outbound::Binary;
use crate::config_edit::FileIo;
use crate::ui_state::Clock;
use crate::world::toolgate::ToolProbe;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex, PoisonError};
use std::time::{Duration, Instant};

pub(crate) static SPAWN_LOCK: Mutex<()> = Mutex::new(());

/// Acquire `SPAWN_LOCK` poison-immune: a panicking test frees the guard with its
/// `()` intact, so recover rather than cascade-poison peer tests. Recovery stays
/// on one line — a split reads as uncovered under `ignore-panics` (the same
/// discipline as `state::lock_watchset`).
pub(crate) fn spawn_guard() -> std::sync::MutexGuard<'static, ()> {
    SPAWN_LOCK.lock().unwrap_or_else(PoisonError::into_inner)
}

/// Deterministic [`Clock`] over a shared instant the test advances by hand, so
/// every debounce/sweep branch (§7.2) is exercised without sleeping. Handles
/// cloned via [`FakeClock::handle`] (or `Clone`) share one instant — advancing
/// any handle moves the clock every holder sees (a model and the test both read
/// it, and an [`AppModel`](crate::AppModel) hands one clone to its schedule and
/// one to its `ui.json` debounce).
#[derive(Clone)]
pub(crate) struct FakeClock {
    at: Arc<Mutex<Instant>>,
}

impl FakeClock {
    pub(crate) fn new() -> Self {
        Self {
            at: Arc::new(Mutex::new(Instant::now())),
        }
    }

    /// A second handle sharing this clock's instant.
    pub(crate) fn handle(&self) -> Self {
        Self {
            at: Arc::clone(&self.at),
        }
    }

    /// This clock as a shared trait object for the `Arc<dyn Clock>` seam
    /// ([`AppModel`](crate::AppModel), `UiState`, `Schedule`). Shares the
    /// instant, so advancing the original still moves the clock the model holds.
    pub(crate) fn arc(&self) -> Arc<dyn Clock> {
        Arc::new(self.handle())
    }

    pub(crate) fn advance(&self, delta: Duration) {
        *self
            .at
            .lock()
            .unwrap_or_else(std::sync::PoisonError::into_inner) += delta;
    }
}

impl Clock for FakeClock {
    fn now(&self) -> Instant {
        *self
            .at
            .lock()
            .unwrap_or_else(std::sync::PoisonError::into_inner)
    }
}

/// A [`ToolProbe`] stub for [`AppModel`](crate::AppModel) tests: every driven
/// verb's probe exits 0 — so the phase-1 capability gate (§16.6 W5) classifies
/// every tool `Ok` and permits every verb, letting these tests exercise other
/// axes. The gate's own classification is table-tested in `crate::world::toolgate`.
pub(crate) struct OkProbe;

impl ToolProbe for OkProbe {
    fn status(&self, _binary: Binary, _args: &[&str]) -> std::io::Result<i32> {
        Ok(0)
    }
}

/// In-memory [`FileIo`] for editor and pipeline tests: a flat path→bytes map.
/// `fail_write` forces the write step to error (the `Io` Apply arm). Shared by
/// the brazen, lernie-global and pipeline test modules — one fake, one
/// behavior, so the write pipeline is exercised the same way everywhere.
#[derive(Default)]
pub(crate) struct FakeFs {
    pub(crate) files: Mutex<HashMap<PathBuf, Vec<u8>>>,
    pub(crate) fail_write: bool,
}

impl FakeFs {
    /// The backing map, locked (poison-immune — a lock a panicking peer test
    /// poisoned still yields the map, never a second panic).
    pub(crate) fn map(&self) -> std::sync::MutexGuard<'_, HashMap<PathBuf, Vec<u8>>> {
        self.files
            .lock()
            .unwrap_or_else(std::sync::PoisonError::into_inner)
    }

    /// A fake pre-populated with one file.
    pub(crate) fn seed(path: &Path, bytes: &[u8]) -> Self {
        let me = Self::default();
        me.map().insert(path.to_path_buf(), bytes.to_vec());
        me
    }

    /// The current bytes at `path`, if any.
    pub(crate) fn get(&self, path: &Path) -> Option<Vec<u8>> {
        self.map().get(path).cloned()
    }
}

impl FileIo for FakeFs {
    fn read(&self, path: &Path) -> std::io::Result<Option<Vec<u8>>> {
        Ok(self.get(path))
    }
    fn write(&self, path: &Path, bytes: &[u8]) -> std::io::Result<()> {
        if self.fail_write {
            return Err(std::io::Error::other("boom"));
        }
        self.map().insert(path.to_path_buf(), bytes.to_vec());
        Ok(())
    }
    fn rename(&self, from: &Path, to: &Path) -> std::io::Result<()> {
        let bytes = self.map().remove(from).unwrap_or_default();
        self.map().insert(to.to_path_buf(), bytes);
        Ok(())
    }
    fn remove(&self, path: &Path) -> std::io::Result<()> {
        self.map().remove(path);
        Ok(())
    }
    fn exists(&self, path: &Path) -> bool {
        self.map().contains_key(path)
    }
    fn list_dir(&self, dir: &Path) -> std::io::Result<Vec<PathBuf>> {
        Ok(self
            .map()
            .keys()
            .filter(|p| p.parent() == Some(dir))
            .cloned()
            .collect())
    }
}

/// Fork + exec `cmd` while holding [`SPAWN_LOCK`], releasing it once the
/// child has exec'd. The single fork-site discipline every test subprocess
/// routes through: the `git_tree` fixture's `run_git` and the production
/// `git_tree::cmd::git` under `cfg(test)`. Because `Command::spawn` returns
/// only after the child has exec'd (CLOEXEC then closes every inherited fd),
/// no fork is ever in flight while a recorder-script test holds a
/// not-yet-closed write fd, so that fd can't leak into a to-be-exec'd script
/// (the ETXTBSY race). The lock is released before the child is waited on, so
/// the subprocesses still run concurrently.
pub(crate) fn spawn_locked(
    cmd: &mut std::process::Command,
) -> std::io::Result<std::process::Child> {
    let _g = spawn_guard();
    cmd.spawn()
}