use std::path::{Path, PathBuf};
use std::process::Command;
use warble::Severity;
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: Some(Severity::Structural),
max_downstream: Some(5),
protected: vec![],
}
}
#[test]
fn headless_loud_fails_but_interactive_dispatches_the_mutation_lifecycle() {
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");
let headless_out = tempfile::tempdir().expect("tempdir");
let headless_result = emit_claude_code_with_models(
&ir,
headless_out.path(),
"claude-code:headless",
RenderFlavor::Programmatic,
&ModelConfig::default(),
);
let err = headless_result.expect_err("headless dispatch of a +Mutating component must fail");
let message = err.to_string();
assert!(
message.contains("human_approval"),
"headless failure must name the missing capability; message was: {message}"
);
let interactive_out = tempfile::tempdir().expect("tempdir");
emit_claude_code_with_models(
&ir,
interactive_out.path(),
"claude-code:interactive",
RenderFlavor::Programmatic,
&ModelConfig::default(),
)
.expect("interactive dispatch of a +Mutating component must succeed");
let agent_md = std::fs::read_to_string(
interactive_out
.path()
.join(".claude/agents/edit_pipeline.md"),
)
.expect("edit_pipeline.md is emitted");
let agent_md_lower = agent_md.to_lowercase();
for marker in ["dry-run", "blast", "approval", "rollback", "diff"] {
assert!(
agent_md_lower.contains(marker),
"edit_pipeline.md must document the mutation lifecycle marker '{marker}'"
);
}
let settings: serde_json::Value = serde_json::from_str(
&std::fs::read_to_string(interactive_out.path().join(".claude/settings.json"))
.expect("settings.json is emitted"),
)
.expect("settings.json parses");
let allow: Vec<String> = settings["permissions"]["allow"]
.as_array()
.expect("permissions.allow is an array")
.iter()
.map(|v| v.as_str().expect("allow entry is a string").to_string())
.collect();
for tool in ["Edit", "Write", "Bash(warble:*)"] {
assert!(
allow.iter().any(|t| t == tool),
"settings.json permissions.allow must grant '{tool}'; allow was: {allow:?}"
);
}
}
#[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", "structural"])
.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"], "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 radius = blast_radius_for_project(&project_dir, "model:orders")
.expect("model:orders resolves against the bound jaffle-wren project");
let (decision, reason) = gate::decide(&radius, &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 radius = blast_radius_for_project(&mutate_agent_dir(), seed)?;
let (decision, _reason) = gate::decide(&radius, 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");
}