opencrabs 0.3.79

The autonomous, self-improving AI agent. Single Rust binary. Every channel. Recommended: the 40MB prebuilt binary for macOS, Linux and Windows: https://github.com/adolfousier/opencrabs/releases
//! Per-project `ralph_loop.toml` resolution (#947).
//!
//! The machine-wide safety copy used to govern every session, so its
//! cargo-flavored verification table leaked into non-Rust projects: a Zig
//! plan task was gated on `cargo test` against a repo it had nothing to do
//! with. A project's own `ralph_loop.toml` at the session working dir now
//! wins outright; the safety copy is only the fallback for projects without
//! one.
//!
//! These tests pin that precedence, hot reload per resolved path (#852), and
//! the rule that a broken project file yields `None` instead of silently
//! falling back to the machine-wide config (a silent fallback would
//! resurrect the exact leak this resolves).
//!
//! Fixtures live in unique tempdirs, so tests run in parallel safely and
//! nothing ever writes to `~/.opencrabs/safety/`.

use crate::brain::tools::plan_tool::{ralph_config_path, ralph_loop_config};
use crate::brain::tools::toml_hot_reload::safety_path;
use std::fs;

/// A tempdir unique to this test: distinct paths mean distinct cache entries
/// in the process-wide resolver cache, so parallel tests cannot interfere.
fn scratch(name: &str) -> tempfile::TempDir {
    tempfile::Builder::new()
        .prefix(&format!("ralph947_{name}_"))
        .tempdir()
        .expect("tempdir")
}

#[test]
fn ralph_config_path_prefers_project_file() {
    let dir = scratch("prefer_project");
    let project_file = dir.path().join("ralph_loop.toml");
    fs::write(&project_file, "[forward]\nmax_iterations = 7\n").unwrap();

    assert_eq!(ralph_config_path(dir.path()), Some(project_file));
}

#[test]
fn ralph_config_path_falls_back_to_safety_copy() {
    let dir = scratch("fallback");
    // No project file: the machine-wide safety copy is the fallback.
    assert_eq!(
        ralph_config_path(dir.path()),
        safety_path("ralph_loop.toml")
    );
}

#[test]
fn project_ralph_file_overrides_machine_default() {
    let dir = scratch("override");
    fs::write(
        dir.path().join("ralph_loop.toml"),
        "[forward]\nmax_iterations = 7\n",
    )
    .unwrap();

    let config = ralph_loop_config(dir.path()).expect("project file present");
    // The machine-wide default is 20; the project file says 7 and must win
    // regardless of what (or whether) the global file exists.
    assert_eq!(config.forward.max_iterations, 7);
}

#[test]
fn project_ralph_file_hot_reloads_on_change() {
    let dir = scratch("hot_reload");
    let path = dir.path().join("ralph_loop.toml");
    fs::write(&path, "[forward]\nmax_iterations = 7\n").unwrap();
    assert_eq!(
        ralph_loop_config(dir.path())
            .expect("file present")
            .forward
            .max_iterations,
        7
    );

    // Different content AND different length, so the mtime+length stamp
    // changes even on filesystems with coarse mtime granularity.
    fs::write(&path, "[forward]\nmax_iterations = 42\n").unwrap();
    assert_eq!(
        ralph_loop_config(dir.path())
            .expect("file present")
            .forward
            .max_iterations,
        42
    );
}

#[test]
fn broken_project_file_yields_none_not_the_global_leak() {
    let dir = scratch("broken");
    fs::write(dir.path().join("ralph_loop.toml"), "not toml [[[\n").unwrap();

    // Authoritative-when-present means a parse failure is None for THIS
    // project, never a silent slide back onto the machine-wide config.
    assert!(ralph_loop_config(dir.path()).is_none());
}