use std::path::Path;
use serde_json::Value;
use crate::project_context::{RepoLawAction, RepoLawRule, load_repo_law_rules};
use crate::tools::apply_patch::{NormalizedApplyPatchInput, normalize_apply_patch_input};
const WRITE_TOOLS: &[&str] = &["write_file", "edit_file", "apply_patch", "fim_edit"];
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum RepoLawPlanDecision {
ForcePrompt(String),
Block(String),
}
pub(crate) fn repo_law_plan_decision(
workspace: &Path,
tool_name: &str,
tool_input: &Value,
) -> Option<RepoLawPlanDecision> {
if !WRITE_TOOLS.contains(&tool_name) {
return None;
}
let targets = write_target_paths(workspace, tool_input);
if targets.is_empty() {
return None;
}
let rules = load_repo_law_rules(workspace);
if rules.is_empty() {
return None;
}
let mut hold: Option<(&RepoLawRule, &str)> = None;
for rule in &rules {
for target in &targets {
if rule.globs.is_match(target) {
let stronger = matches!(rule.action, RepoLawAction::Block) || hold.is_none();
let already_blocking = hold
.as_ref()
.is_some_and(|(held, _)| matches!(held.action, RepoLawAction::Block));
if stronger && !already_blocking {
hold = Some((rule, target.as_str()));
}
}
}
}
let (rule, target) = hold?;
let protects = rule.patterns.join(", ");
let reason = format!(
"Repo law holds this write: \"{}\" protects {protects} (matched {target}, .codewhale/constitution.json)",
rule.text
);
Some(match rule.action {
RepoLawAction::Ask => RepoLawPlanDecision::ForcePrompt(reason),
RepoLawAction::Block => RepoLawPlanDecision::Block(reason),
})
}
fn write_target_paths(workspace: &Path, input: &Value) -> Vec<String> {
let mut targets = Vec::new();
for key in ["path", "target", "destination", "file_path"] {
if let Some(path) = input.get(key).and_then(Value::as_str) {
push_normalized(&mut targets, workspace, path);
}
}
match normalize_apply_patch_input(input) {
Ok(NormalizedApplyPatchInput::Replacement { entries, .. }) => {
for change in entries {
if let Some(path) = change.get("path").and_then(Value::as_str) {
push_normalized(&mut targets, workspace, path);
}
}
}
Ok(NormalizedApplyPatchInput::Patch(patch)) => {
let mut pending_old: Option<String> = None;
for line in patch.lines() {
if let Some(rest) = line.strip_prefix("*** Update File: ") {
push_normalized(&mut targets, workspace, rest.trim());
} else if let Some(rest) = line.strip_prefix("*** Add File: ") {
push_normalized(&mut targets, workspace, rest.trim());
} else if let Some(rest) = line.strip_prefix("*** Delete File: ") {
push_normalized(&mut targets, workspace, rest.trim());
} else if let Some(rest) = line.strip_prefix("--- ") {
pending_old = diff_header_path(rest);
if let Some(ref p) = pending_old {
push_normalized(&mut targets, workspace, p);
}
} else if let Some(rest) = line.strip_prefix("+++ ") {
match diff_header_path(rest) {
Some(new_path) => push_normalized(&mut targets, workspace, &new_path),
None => {
if let Some(old) = pending_old.take() {
push_normalized(&mut targets, workspace, &old);
}
}
}
}
}
}
Err(_) => {}
}
targets.sort();
targets.dedup();
targets
}
fn diff_header_path(rest: &str) -> Option<String> {
let path = rest.split('\t').next().unwrap_or(rest).trim();
if path.is_empty() || path == "/dev/null" {
return None;
}
let stripped = path
.strip_prefix("a/")
.or_else(|| path.strip_prefix("b/"))
.unwrap_or(path);
Some(stripped.to_string())
}
fn push_normalized(targets: &mut Vec<String>, workspace: &Path, raw: &str) {
let trimmed = raw.trim().replace('\\', "/");
if trimmed.is_empty() {
return;
}
let path = Path::new(&trimmed);
let relative = path.strip_prefix(workspace).unwrap_or(path);
let mut parts: Vec<String> = Vec::new();
for component in relative.to_string_lossy().split('/') {
match component {
"" | "." => {}
".." => {
if parts.pop().is_none() {
parts.push("..".to_string());
}
}
other => parts.push(other.to_string()),
}
}
let normalized = parts.join("/");
if !normalized.is_empty() {
targets.push(normalized);
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
use tempfile::TempDir;
fn write_law(workspace: &Path, body: &str) {
let dir = workspace.join(".codewhale");
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("constitution.json"), body).unwrap();
}
const LAW: &str = r#"{
"authority": ["AGENTS.md"],
"protected_invariants": [
"Keep DeepSeek support first-class.",
{ "text": "The wire format is frozen", "paths": ["crates/protocol/**"], "action": "block" },
{ "text": "Release notes need human review", "paths": ["CHANGELOG.md"] }
]
}"#;
#[test]
fn advisory_only_law_never_holds() {
let tmp = TempDir::new().unwrap();
write_law(
tmp.path(),
r#"{"protected_invariants": ["Prose only, no paths."]}"#,
);
assert_eq!(
repo_law_plan_decision(
tmp.path(),
"write_file",
&json!({"path": "src/main.rs", "content": "x"}),
),
None
);
}
#[test]
fn block_action_denies_protected_write() {
let tmp = TempDir::new().unwrap();
write_law(tmp.path(), LAW);
let decision = repo_law_plan_decision(
tmp.path(),
"write_file",
&json!({"path": "crates/protocol/wire.rs", "content": "x"}),
);
let Some(RepoLawPlanDecision::Block(reason)) = decision else {
panic!("expected block, got {decision:?}");
};
assert!(reason.contains("The wire format is frozen"), "{reason}");
assert!(reason.contains("crates/protocol/wire.rs"), "{reason}");
assert!(reason.contains(".codewhale/constitution.json"), "{reason}");
}
#[test]
fn ask_action_force_prompts_and_names_the_law() {
let tmp = TempDir::new().unwrap();
write_law(tmp.path(), LAW);
let decision = repo_law_plan_decision(
tmp.path(),
"edit_file",
&json!({"path": "CHANGELOG.md", "old": "a", "new": "b"}),
);
let Some(RepoLawPlanDecision::ForcePrompt(reason)) = decision else {
panic!("expected force prompt, got {decision:?}");
};
assert!(
reason.contains("Release notes need human review"),
"{reason}"
);
}
#[test]
fn unprotected_writes_and_non_write_tools_pass() {
let tmp = TempDir::new().unwrap();
write_law(tmp.path(), LAW);
assert_eq!(
repo_law_plan_decision(
tmp.path(),
"write_file",
&json!({"path": "src/main.rs", "content": "x"}),
),
None
);
assert_eq!(
repo_law_plan_decision(
tmp.path(),
"read_file",
&json!({"path": "crates/protocol/wire.rs"}),
),
None
);
}
#[test]
fn apply_patch_targets_are_extracted_from_all_shapes() {
let tmp = TempDir::new().unwrap();
write_law(tmp.path(), LAW);
let decision = repo_law_plan_decision(
tmp.path(),
"apply_patch",
&json!({"replace": [{"path": "crates/protocol/msg.rs"}]}),
);
assert!(matches!(decision, Some(RepoLawPlanDecision::Block(_))));
let decision = repo_law_plan_decision(
tmp.path(),
"apply_patch",
&json!({"changes": [{"path": "crates/protocol/msg.rs"}]}),
);
assert!(matches!(decision, Some(RepoLawPlanDecision::Block(_))));
let decision = repo_law_plan_decision(
tmp.path(),
"apply_patch",
&json!({"patch": "--- a/crates/protocol/msg.rs\n+++ b/crates/protocol/msg.rs\n@@\n"}),
);
assert!(matches!(decision, Some(RepoLawPlanDecision::Block(_))));
let decision = repo_law_plan_decision(
tmp.path(),
"apply_patch",
&json!({"patch": "*** Begin Patch\n*** Update File: crates/protocol/msg.rs\n*** End Patch\n"}),
);
assert!(matches!(decision, Some(RepoLawPlanDecision::Block(_))));
}
#[test]
fn block_outranks_ask_when_both_match() {
let tmp = TempDir::new().unwrap();
write_law(
tmp.path(),
r#"{"protected_invariants": [
{ "text": "ask first", "paths": ["docs/**"] },
{ "text": "never", "paths": ["docs/frozen/**"], "action": "block" }
]}"#,
);
let decision = repo_law_plan_decision(
tmp.path(),
"write_file",
&json!({"path": "docs/frozen/spec.md", "content": "x"}),
);
assert!(matches!(decision, Some(RepoLawPlanDecision::Block(_))));
}
#[test]
fn absolute_and_dot_prefixed_paths_normalize_to_workspace_relative() {
let tmp = TempDir::new().unwrap();
write_law(tmp.path(), LAW);
let absolute = tmp.path().join("crates/protocol/wire.rs");
let decision = repo_law_plan_decision(
tmp.path(),
"write_file",
&json!({"path": absolute.to_string_lossy(), "content": "x"}),
);
assert!(matches!(decision, Some(RepoLawPlanDecision::Block(_))));
let decision = repo_law_plan_decision(
tmp.path(),
"write_file",
&json!({"path": "./CHANGELOG.md", "content": "x"}),
);
assert!(matches!(
decision,
Some(RepoLawPlanDecision::ForcePrompt(_))
));
}
#[test]
fn malformed_law_and_bad_globs_degrade_to_no_holds() {
let tmp = TempDir::new().unwrap();
write_law(tmp.path(), "{ not json");
assert_eq!(
repo_law_plan_decision(
tmp.path(),
"write_file",
&json!({"path": "crates/protocol/wire.rs", "content": "x"}),
),
None
);
write_law(
tmp.path(),
r#"{"protected_invariants": [
{ "text": "broken glob", "paths": ["crates/[invalid"] }
]}"#,
);
assert_eq!(
repo_law_plan_decision(
tmp.path(),
"write_file",
&json!({"path": "crates/protocol/wire.rs", "content": "x"}),
),
None
);
}
#[test]
fn interior_dot_and_parent_segments_cannot_evade_a_block() {
let tmp = TempDir::new().unwrap();
write_law(tmp.path(), LAW);
for path in [
"crates/./protocol/wire.rs",
"crates/../crates/protocol/wire.rs",
"x/../crates/protocol/wire.rs",
"./crates/protocol/wire.rs",
] {
let decision = repo_law_plan_decision(
tmp.path(),
"write_file",
&json!({ "path": path, "content": "x" }),
);
assert!(
matches!(decision, Some(RepoLawPlanDecision::Block(_))),
"{path} must be held, got {decision:?}"
);
}
}
#[test]
fn fim_edit_is_gated_like_other_write_tools() {
let tmp = TempDir::new().unwrap();
write_law(tmp.path(), LAW);
let decision = repo_law_plan_decision(
tmp.path(),
"fim_edit",
&json!({ "path": "crates/protocol/wire.rs", "prefix": "a", "suffix": "b" }),
);
assert!(
matches!(decision, Some(RepoLawPlanDecision::Block(_))),
"{decision:?}"
);
}
#[test]
fn apply_patch_header_variants_are_all_extracted() {
let tmp = TempDir::new().unwrap();
write_law(tmp.path(), LAW);
let d = repo_law_plan_decision(
tmp.path(),
"apply_patch",
&json!({ "patch": "--- crates/protocol/wire.rs\n+++ crates/protocol/wire.rs\n@@\n" }),
);
assert!(
matches!(d, Some(RepoLawPlanDecision::Block(_))),
"no-prefix: {d:?}"
);
let d = repo_law_plan_decision(
tmp.path(),
"apply_patch",
&json!({ "patch": "--- a/crates/protocol/wire.rs\n+++ /dev/null\n@@ -1 +0,0 @@\n-x\n" }),
);
assert!(
matches!(d, Some(RepoLawPlanDecision::Block(_))),
"deletion: {d:?}"
);
let d = repo_law_plan_decision(
tmp.path(),
"apply_patch",
&json!({ "patch": "--- a/x\t2026-01-01\n+++ b/crates/protocol/wire.rs\t2026-01-01 10:00:00\n@@\n" }),
);
assert!(
matches!(d, Some(RepoLawPlanDecision::Block(_))),
"tab-timestamp: {d:?}"
);
}
#[test]
fn no_law_file_means_no_holds() {
let tmp = TempDir::new().unwrap();
assert_eq!(
repo_law_plan_decision(
tmp.path(),
"write_file",
&json!({"path": "anything.rs", "content": "x"}),
),
None
);
}
}