use std::path::{Path, PathBuf};
use std::process::Command;
use warble_claude_code::{emit_claude_code_with_models, ir::WarbleIr, ModelConfig, RenderFlavor};
use warble_cli::gate::{self, GateDecision, GateThreshold};
use warble_cli::{blast_radius_for_project, compile_project_to_ir};
fn mutate_agent_dir() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR"))
.join("..")
.join("examples/mutate-agent")
}
fn edit_pipeline_threshold() -> GateThreshold {
GateThreshold {
max_severity_rank: Some(2),
max_downstream: Some(5),
protected: vec![],
}
}
#[test]
fn edit_pipeline_loud_fails_on_both_targets_rather_than_splitting_the_approval_gate() {
let ir_value = compile_project_to_ir(&mutate_agent_dir()).expect("mutate-agent compiles to IR");
let ir: WarbleIr = serde_json::from_value(ir_value).expect("compiled IR deserializes");
for target in ["claude-code:headless", "claude-code:interactive"] {
let out_dir = tempfile::tempdir().expect("tempdir");
let result = emit_claude_code_with_models(
&ir,
out_dir.path(),
target,
RenderFlavor::Programmatic,
&ModelConfig::default(),
);
let err = result.expect_err(&format!(
"edit_pipeline ({target}) must loud-fail, not silently collapse its tiers nor \
unsafely split write authority to subagents"
));
let message = err.to_string();
assert!(message.contains("llm:per_step_tier"), "{message}");
assert!(message.contains("gated-tool"), "{message}");
assert!(message.contains("edit_pipeline"), "{message}");
assert!(
!out_dir.path().join(".claude/agents").exists(),
"a loud-fail on {target} must abort before writing any agent file"
);
}
}
#[test]
fn blast_gate_blocks_a_dangerous_change() {
let project_dir = mutate_agent_dir();
let output = Command::new(env!("CARGO_BIN_EXE_warble"))
.arg("blast-radius")
.arg(&project_dir)
.args(["--node", "model:orders"])
.args(["--max-severity-rank", "2"])
.output()
.expect("warble blast-radius runs");
assert_eq!(
output.status.code(),
Some(10),
"escalate must exit 10; stderr was: {}",
String::from_utf8_lossy(&output.stderr)
);
let json: serde_json::Value =
serde_json::from_slice(&output.stdout).expect("blast-radius prints JSON to stdout");
assert_eq!(json["decision"], "escalate");
assert_eq!(json["severity"]["rank"], 3);
assert_eq!(json["severity"]["name"], "semantic");
let downstream: Vec<String> = json["downstream"]
.as_array()
.expect("downstream is an array")
.iter()
.map(|v| v.as_str().unwrap().to_string())
.collect();
assert!(
downstream.contains(&"metric:revenue.total_revenue".to_string()),
"downstream was: {downstream:?}"
);
let output = Command::new(env!("CARGO_BIN_EXE_warble"))
.arg("blast-radius")
.arg(&project_dir)
.args(["--node", "model:orders"])
.args(["--protected", "metric:revenue.total_revenue"])
.output()
.expect("warble blast-radius runs");
assert_eq!(
output.status.code(),
Some(11),
"block must exit 11; stderr was: {}",
String::from_utf8_lossy(&output.stderr)
);
let json: serde_json::Value =
serde_json::from_slice(&output.stdout).expect("blast-radius prints JSON to stdout");
assert_eq!(json["decision"], "block");
let impact = blast_radius_for_project(&project_dir, "model:orders")
.expect("model:orders resolves against the bound jaffle-wren project");
let (decision, reason) =
gate::decide("model:orders", impact.as_ref(), &edit_pipeline_threshold());
assert_eq!(decision, GateDecision::Escalate, "reason was: {reason}");
}
#[derive(Debug, PartialEq, Eq)]
enum LifecycleOutcome {
Blocked,
RefusedUnapproved,
Applied,
}
fn approval_granted_via_mock_channel() -> bool {
std::env::var("WARBLE_APPROVAL").as_deref() == Ok("approve")
}
fn run_gated_lifecycle(
seed: &str,
pipeline_path: &Path,
repo_dir: &Path,
threshold: &GateThreshold,
) -> Result<LifecycleOutcome, String> {
let impact = blast_radius_for_project(&mutate_agent_dir(), seed)?;
let (decision, _reason) = gate::decide(seed, impact.as_ref(), threshold);
if decision == GateDecision::Block {
return Ok(LifecycleOutcome::Blocked);
}
if decision == GateDecision::Escalate && !approval_granted_via_mock_channel() {
return Ok(LifecycleOutcome::RefusedUnapproved);
}
let mut contents = std::fs::read_to_string(pipeline_path)
.map_err(|e| format!("failed to read {}: {e}", pipeline_path.display()))?;
contents.push_str(&format!("-- edited seed {seed}\n"));
std::fs::write(pipeline_path, &contents)
.map_err(|e| format!("failed to write {}: {e}", pipeline_path.display()))?;
git(repo_dir, &["add", "pipeline.sql"]);
git(
repo_dir,
&["commit", "-q", "-m", &format!("apply edit of {seed}")],
);
Ok(LifecycleOutcome::Applied)
}
fn git(dir: &Path, args: &[&str]) {
let output = Command::new("git")
.args(args)
.current_dir(dir)
.output()
.unwrap_or_else(|e| panic!("failed to spawn git {args:?}: {e}"));
assert!(
output.status.success(),
"git {args:?} failed: {}",
String::from_utf8_lossy(&output.stderr)
);
}
fn git_output(dir: &Path, args: &[&str]) -> String {
let output = Command::new("git")
.args(args)
.current_dir(dir)
.output()
.unwrap_or_else(|e| panic!("failed to spawn git {args:?}: {e}"));
assert!(
output.status.success(),
"git {args:?} failed: {}",
String::from_utf8_lossy(&output.stderr)
);
String::from_utf8_lossy(&output.stdout).trim().to_string()
}
#[test]
fn full_lifecycle_safe_change_applies_and_rolls_back_dangerous_change_is_refused() {
let repo = tempfile::tempdir().expect("tempdir");
let repo_dir = repo.path();
git(repo_dir, &["init", "-q"]);
git(
repo_dir,
&["config", "user.email", "warble-e2e@example.com"],
);
git(repo_dir, &["config", "user.name", "Warble E2E"]);
let pipeline_path = repo_dir.join("pipeline.sql");
let original_contents = "-- orders pipeline (checkpoint)\nselect * from orders;\n";
std::fs::write(&pipeline_path, original_contents).expect("write pipeline.sql");
git(repo_dir, &["add", "pipeline.sql"]);
git(repo_dir, &["commit", "-q", "-m", "checkpoint"]);
let checkpoint_sha = git_output(repo_dir, &["rev-parse", "HEAD"]);
let threshold = edit_pipeline_threshold();
std::env::remove_var("WARBLE_APPROVAL");
assert!(
!approval_granted_via_mock_channel(),
"an unset WARBLE_APPROVAL must fail closed (not approved)"
);
std::env::set_var("WARBLE_APPROVAL", "deny");
let outcome = run_gated_lifecycle("model:orders", &pipeline_path, repo_dir, &threshold)
.expect("lifecycle runs without error");
assert_eq!(
outcome,
LifecycleOutcome::RefusedUnapproved,
"an escalated change with approval denied must be refused, not applied"
);
assert_eq!(
std::fs::read(&pipeline_path).expect("read pipeline.sql"),
original_contents.as_bytes(),
"a refused dangerous change must leave pipeline.sql byte-for-byte unchanged"
);
assert_eq!(
git_output(repo_dir, &["rev-parse", "HEAD"]),
checkpoint_sha,
"a refused dangerous change must not create any new commit"
);
std::env::set_var("WARBLE_APPROVAL", "approve");
let outcome = run_gated_lifecycle(
"metric:revenue.total_revenue",
&pipeline_path,
repo_dir,
&threshold,
)
.expect("lifecycle runs without error");
assert_eq!(
outcome,
LifecycleOutcome::Applied,
"an allowed (empty-radius) change must be applied"
);
let changed_contents = std::fs::read(&pipeline_path).expect("read pipeline.sql after apply");
assert_ne!(
changed_contents,
original_contents.as_bytes(),
"an applied safe change must modify pipeline.sql"
);
let applied_sha = git_output(repo_dir, &["rev-parse", "HEAD"]);
assert_ne!(
applied_sha, checkpoint_sha,
"an applied safe change must create a new commit beyond the checkpoint"
);
git(repo_dir, &["reset", "--hard", &checkpoint_sha]);
assert_eq!(
std::fs::read(&pipeline_path).expect("read pipeline.sql after rollback"),
original_contents.as_bytes(),
"git reset --hard to the checkpoint must restore pipeline.sql exactly"
);
std::env::remove_var("WARBLE_APPROVAL");
}