use std::fs;
use std::process::Command;
use tempfile::TempDir;
fn combined_config(hat_name: &str, completion_promise: &str, specs_dir: &str) -> String {
format!(
r#"cli:
backend: claude
event_loop:
max_iterations: 10
completion_promise: "{completion_promise}"
prompt: "placeholder"
core:
specs_dir: "{specs_dir}"
hats:
{hat_name}:
name: "{hat_name}"
description: "A hat inside the combined config"
triggers: ["{hat_name}.start"]
publishes: ["{hat_name}.done"]
default_publishes: "{hat_name}.done"
instructions: |
Do the thing for {hat_name}.
"#,
hat_name = hat_name,
completion_promise = completion_promise,
specs_dir = specs_dir,
)
}
fn hats_only_config(hat_name: &str, completion_promise: Option<&str>) -> String {
let event_loop_block = match completion_promise {
Some(promise) => format!(
r#"event_loop:
completion_promise: "{}"
"#,
promise
),
None => String::new(),
};
format!(
r#"{event_loop_block}hats:
{hat_name}:
name: "{hat_name}"
description: "A hat from the dedicated hats file"
triggers: ["{hat_name}.start"]
publishes: ["{hat_name}.done"]
default_publishes: "{hat_name}.done"
instructions: |
Do the thing for {hat_name}.
"#,
event_loop_block = event_loop_block,
hat_name = hat_name,
)
}
#[test]
fn test_combined_config_dry_run_succeeds() {
let dir = TempDir::new().expect("temp dir");
let config_path = dir.path().join("ralph.yml");
fs::write(
&config_path,
combined_config("mybuilder", "LOOP_COMPLETE", "./specs/"),
)
.expect("write combined config");
let out = Command::new(env!("CARGO_BIN_EXE_ralph"))
.args([
"--color",
"never",
"--config",
config_path.to_str().unwrap(),
"run",
"--dry-run",
"--skip-preflight",
"--prompt",
"hello combined",
"--backend",
"claude",
"--no-tui",
])
.current_dir(dir.path())
.env("NO_COLOR", "1")
.output()
.expect("execute ralph");
let stdout = String::from_utf8_lossy(&out.stdout);
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
out.status.success(),
"expected success; stderr: {stderr}\nstdout: {stdout}"
);
assert!(
stdout.contains("Dry run mode"),
"expected 'Dry run mode' in stdout; got: {stdout}"
);
}
#[test]
fn test_hats_file_overrides_combined_config_hats() {
let dir = TempDir::new().expect("temp dir");
let config_path = dir.path().join("ralph.yml");
let hats_path = dir.path().join("hats.yml");
fs::write(
&config_path,
combined_config("mybuilder", "LOOP_COMPLETE", "./specs/"),
)
.expect("write combined config");
fs::write(&hats_path, hats_only_config("myreviewer", None)).expect("write hats file");
let out = Command::new(env!("CARGO_BIN_EXE_ralph"))
.args([
"--color",
"never",
"--config",
config_path.to_str().unwrap(),
"--hats",
hats_path.to_str().unwrap(),
"hats",
"list",
"--format",
"json",
])
.current_dir(dir.path())
.env("NO_COLOR", "1")
.output()
.expect("execute ralph");
let stdout = String::from_utf8_lossy(&out.stdout);
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
out.status.success(),
"expected success; stderr: {stderr}\nstdout: {stdout}"
);
let json_start = stdout.find('[').expect("no JSON array start in stdout");
let json_end = stdout.rfind(']').expect("no JSON array end in stdout");
let json_str = &stdout[json_start..=json_end];
let hats: serde_json::Value =
serde_json::from_str(json_str).expect("expected valid JSON from 'hats list --format json'");
let names: Vec<&str> = hats
.as_array()
.expect("hats JSON should be an array")
.iter()
.filter_map(|h| h["name"].as_str())
.collect();
assert!(
names.contains(&"myreviewer"),
"expected 'myreviewer' from hats file; got: {names:?}"
);
assert!(
!names.contains(&"mybuilder"),
"expected 'mybuilder' to be absent (replaced by hats file); got: {names:?}"
);
}
#[test]
fn test_hats_file_event_loop_completion_promise_overrides_combined_config() {
let dir = TempDir::new().expect("temp dir");
let config_path = dir.path().join("ralph.yml");
let hats_path = dir.path().join("hats.yml");
fs::write(
&config_path,
combined_config("mybuilder", "COMBINED_DONE", "./specs/"),
)
.expect("write combined config");
fs::write(
&hats_path,
hats_only_config("myreviewer", Some("HATS_DONE")),
)
.expect("write hats file");
let out = Command::new(env!("CARGO_BIN_EXE_ralph"))
.args([
"--color",
"never",
"--config",
config_path.to_str().unwrap(),
"--hats",
hats_path.to_str().unwrap(),
"run",
"--dry-run",
"--skip-preflight",
"--prompt",
"test promise override",
"--backend",
"claude",
"--no-tui",
])
.current_dir(dir.path())
.env("NO_COLOR", "1")
.output()
.expect("execute ralph");
let stdout = String::from_utf8_lossy(&out.stdout);
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
out.status.success(),
"expected success; stderr: {stderr}\nstdout: {stdout}"
);
assert!(
stdout.contains("Completion promise: HATS_DONE"),
"expected 'Completion promise: HATS_DONE'; got stdout: {stdout}"
);
assert!(
!stdout.contains("COMBINED_DONE"),
"expected COMBINED_DONE to be absent (overridden by hats file); got: {stdout}"
);
}
#[test]
fn test_hats_file_does_not_override_runtime_limits_from_combined_config() {
let dir = TempDir::new().expect("temp dir");
let config_path = dir.path().join("ralph.yml");
let hats_path = dir.path().join("hats.yml");
fs::write(
&config_path,
r#"cli:
backend: claude
event_loop:
max_iterations: 10
max_runtime_seconds: 28800
completion_promise: "CORE_DONE"
prompt: "placeholder"
core:
specs_dir: "./specs/"
hats:
from_core:
name: "from_core"
description: "core hat"
triggers: ["design.start"]
publishes: ["CORE_DONE"]
default_publishes: "CORE_DONE"
instructions: |
Core hat
"#,
)
.expect("write combined config");
fs::write(
&hats_path,
r#"event_loop:
starting_event: "design.start"
completion_promise: "HATS_DONE"
max_iterations: 150
max_runtime_seconds: 14400
hats:
from_hats:
name: "from_hats"
description: "hats override"
triggers: ["design.start"]
publishes: ["HATS_DONE"]
default_publishes: "HATS_DONE"
instructions: |
Hats hat
"#,
)
.expect("write hats file");
let out = Command::new(env!("CARGO_BIN_EXE_ralph"))
.args([
"--color",
"never",
"--config",
config_path.to_str().unwrap(),
"--hats",
hats_path.to_str().unwrap(),
"run",
"--dry-run",
"--skip-preflight",
"--prompt",
"test runtime precedence",
"--backend",
"claude",
"--no-tui",
])
.current_dir(dir.path())
.env("NO_COLOR", "1")
.output()
.expect("execute ralph");
let stdout = String::from_utf8_lossy(&out.stdout);
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
out.status.success(),
"expected success; stderr: {stderr}\nstdout: {stdout}"
);
assert!(
stdout.contains("Completion promise: HATS_DONE"),
"expected completion promise from hats; got stdout: {stdout}"
);
assert!(
stdout.contains("Max iterations: 10"),
"expected max_iterations from core config; got stdout: {stdout}"
);
assert!(
stdout.contains("Max runtime: 28800s"),
"expected max_runtime_seconds from core config; got stdout: {stdout}"
);
}
#[test]
fn test_core_specs_dir_cli_override_applies_last() {
let dir = TempDir::new().expect("temp dir");
let config_path = dir.path().join("ralph.yml");
let hats_path = dir.path().join("hats.yml");
fs::write(
&config_path,
combined_config("mybuilder", "LOOP_COMPLETE", "./original-specs/"),
)
.expect("write combined config");
fs::write(&hats_path, hats_only_config("myreviewer", None)).expect("write hats file");
let out = Command::new(env!("CARGO_BIN_EXE_ralph"))
.args([
"--color",
"never",
"--config",
config_path.to_str().unwrap(),
"--config",
"core.specs_dir=./custom-specs/",
"--hats",
hats_path.to_str().unwrap(),
"run",
"--dry-run",
"--skip-preflight",
"--prompt",
"test specs_dir override",
"--backend",
"claude",
"--no-tui",
])
.current_dir(dir.path())
.env("NO_COLOR", "1")
.output()
.expect("execute ralph");
let stdout = String::from_utf8_lossy(&out.stdout);
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
out.status.success(),
"expected success; stderr: {stderr}\nstdout: {stdout}"
);
assert!(
stdout.contains("Specs dir: ./custom-specs/"),
"expected 'Specs dir: ./custom-specs/'; got stdout: {stdout}"
);
assert!(
!stdout.contains("./original-specs/"),
"expected original specs_dir to be absent; got: {stdout}"
);
}
#[test]
fn test_builtin_hats_source_overrides_combined_config_hats() {
let dir = TempDir::new().expect("temp dir");
let config_path = dir.path().join("ralph.yml");
fs::write(
&config_path,
combined_config("myplanner", "LOOP_COMPLETE", "./specs/"),
)
.expect("write combined config");
let out = Command::new(env!("CARGO_BIN_EXE_ralph"))
.args([
"--color",
"never",
"--config",
config_path.to_str().unwrap(),
"--hats",
"builtin:code-assist",
"hats",
"list",
"--format",
"json",
])
.current_dir(dir.path())
.env("NO_COLOR", "1")
.output()
.expect("execute ralph");
let stdout = String::from_utf8_lossy(&out.stdout);
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
out.status.success(),
"expected success; stderr: {stderr}\nstdout: {stdout}"
);
let json_start = stdout.find('[').expect("no JSON array start in stdout");
let json_end = stdout.rfind(']').expect("no JSON array end in stdout");
let json_str = &stdout[json_start..=json_end];
let hats: serde_json::Value =
serde_json::from_str(json_str).expect("expected valid JSON from 'hats list --format json'");
let names: Vec<&str> = hats
.as_array()
.expect("hats JSON should be an array")
.iter()
.filter_map(|h| h["name"].as_str())
.collect();
assert!(
names.iter().any(|name| name.contains("Planner")),
"expected planner hat from builtin:code-assist; got: {names:?}"
);
assert!(
names.iter().any(|name| name.contains("Builder")),
"expected builder hat from builtin:code-assist; got: {names:?}"
);
assert!(
!names.contains(&"myplanner"),
"expected 'myplanner' to be absent (replaced by builtin:code-assist); got: {names:?}"
);
}