use crate::error::{EngineError, Result};
use crate::git_ops::GitRepo;
use crate::types::{PatternRoute, RoutingConfig, TaskClassRoute};
use serde::Deserialize;
pub const ROUTING_RULES_PATH: &str = ".kranz/routing-rules.json";
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
struct RoutingRulesFile {
#[serde(default)]
task_class_rules: Vec<TaskClassRoute>,
#[serde(default)]
pattern_rules: Vec<PatternRoute>,
}
pub fn parse_routing_rules(bytes: &[u8]) -> std::result::Result<RoutingConfig, String> {
let file: RoutingRulesFile = serde_json::from_slice(bytes)
.map_err(|e| format!("invalid JSON in {ROUTING_RULES_PATH}: {e}"))?;
let routing = RoutingConfig {
task_class_rules: file.task_class_rules,
pattern_rules: file.pattern_rules,
};
if routing.is_empty() {
return Err(format!(
"{ROUTING_RULES_PATH}: the rules table is empty: a present file IS the routing \
table, so an empty one would silently demote routing to the legacy task-class \
floor — delete the file to keep the layered config, or declare at least one rule"
));
}
crate::routing::validate_table(&routing)
.map_err(|violation| format!("{ROUTING_RULES_PATH}: {violation}"))?;
Ok(routing)
}
pub fn load_routing_rules_at_ref(repo: &GitRepo, ref_name: &str) -> Result<Option<RoutingConfig>> {
match repo.show_file(ref_name, ROUTING_RULES_PATH)? {
None => Ok(None),
Some(bytes) => parse_routing_rules(&bytes).map(Some).map_err(|violation| {
EngineError::Config(format!(
"routing rules {ROUTING_RULES_PATH} at {ref_name} is invalid (owner: repo-setup): {violation}"
))
}),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::ExecutorTier;
fn git_repo() -> Option<(tempfile::TempDir, GitRepo)> {
let dir = tempfile::tempdir().unwrap();
let init = std::process::Command::new("git")
.args(["init", "-b", "main"])
.current_dir(dir.path())
.output()
.ok()?;
if !init.status.success() {
return None;
}
for args in [
&["config", "user.name", "test"][..],
&["config", "user.email", "test@example.com"][..],
] {
assert!(std::process::Command::new("git")
.args(args)
.current_dir(dir.path())
.output()
.unwrap()
.status
.success());
}
std::fs::write(dir.path().join("README.md"), "seed\n").unwrap();
for args in [&["add", "-A"][..], &["commit", "-m", "seed"][..]] {
assert!(std::process::Command::new("git")
.args(args)
.current_dir(dir.path())
.output()
.unwrap()
.status
.success());
}
let root = std::fs::canonicalize(dir.path()).unwrap();
Some((dir, GitRepo::open(&root).unwrap()))
}
fn commit_rules(repo_root: &std::path::Path, branch: &str, bytes: &str) {
let run = |args: &[&str]| {
assert!(std::process::Command::new("git")
.args(args)
.current_dir(repo_root)
.output()
.unwrap()
.status
.success());
};
run(&["checkout", branch]);
let path = repo_root.join(ROUTING_RULES_PATH);
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(&path, bytes).unwrap();
run(&["add", "-A"]);
run(&["commit", "-m", "rules"]);
run(&["checkout", "main"]);
}
const VALID: &str = r#"{
"taskClassRules": [
{"taskClass": "execution-class", "tier": "local"}
],
"patternRules": [
{"pattern": "docs-*", "tier": "frontier"},
{"pattern": "*", "tier": "frontier"}
]
}"#;
#[test]
fn routing_rules_config_parses_both_rule_forms() {
let routing = parse_routing_rules(VALID.as_bytes()).expect("a valid file parses");
assert_eq!(routing.task_class_rules.len(), 1);
assert_eq!(routing.task_class_rules[0].task_class, "execution-class");
assert_eq!(routing.task_class_rules[0].tier, ExecutorTier::Local);
assert_eq!(routing.pattern_rules.len(), 2);
assert_eq!(routing.pattern_rules[0].pattern, "docs-*");
assert!(!routing.is_empty());
}
#[test]
fn routing_rules_config_invalid_rule_fails_closed_naming_index_and_field() {
let err = parse_routing_rules(b"not json").unwrap_err();
assert!(err.contains(ROUTING_RULES_PATH), "{err}");
let err = parse_routing_rules(
br#"{"taskClassRules": [{"taskClass": "ok-class", "tier": "local"},
{"taskClass": " ", "tier": "frontier"}]}"#,
)
.unwrap_err();
assert!(err.contains(ROUTING_RULES_PATH), "{err}");
assert!(err.contains("taskClassRules[1].taskClass"), "{err}");
let err = parse_routing_rules(
br#"{"patternRules": [{"pattern": "docs-*", "tier": "local"},
{"pattern": " DOCS-* ", "tier": "frontier"}]}"#,
)
.unwrap_err();
assert!(err.contains("patternRules[1].pattern"), "{err}");
assert!(err.contains("duplicates rule 0"), "{err}");
let err = parse_routing_rules(br#"{"taskclassRules": []}"#).unwrap_err();
assert!(err.contains(ROUTING_RULES_PATH), "{err}");
}
#[test]
fn routing_rules_config_schema_rejects_model_id_shaped_tiers() {
for shape in [
br#"{"taskClassRules": [{"taskClass": "execution-class", "tier": "claude-opus-4-1"}]}"#
.as_slice(),
br#"{"patternRules": [{"pattern": "*", "tier": "gpt-5-codex"}]}"#.as_slice(),
br#"{"patternRules": [{"pattern": "*", "tier": "sonnet"}]}"#.as_slice(),
] {
let err = parse_routing_rules(shape).unwrap_err();
assert!(
err.contains("unknown variant") || err.contains("tier"),
"a model-id-shaped tier must fail the schema: {err}"
);
}
}
#[test]
fn routing_rules_config_reads_committed_base_bytes_only() {
let Some((dir, repo)) = git_repo() else {
crate::test_capability::skip(
crate::test_capability::capability::GIT,
"git is not on PATH",
);
return;
};
commit_rules(dir.path(), "main", VALID);
let routing = load_routing_rules_at_ref(&repo, "main")
.expect("load must not error")
.expect("committed rules load");
assert_eq!(routing.task_class_rules.len(), 1);
std::fs::write(dir.path().join(ROUTING_RULES_PATH), "not json").unwrap();
assert!(
load_routing_rules_at_ref(&repo, "main").unwrap().is_some(),
"the committed bytes govern, not the dirty working tree"
);
assert!(load_routing_rules_at_ref(&repo, "HEAD~1")
.unwrap()
.is_none());
}
#[test]
fn routing_rules_config_mission_branch_edit_is_ignored_at_read() {
let Some((dir, repo)) = git_repo() else {
crate::test_capability::skip(
crate::test_capability::capability::GIT,
"git is not on PATH",
);
return;
};
commit_rules(dir.path(), "main", VALID);
let run = |args: &[&str]| {
assert!(std::process::Command::new("git")
.args(args)
.current_dir(dir.path())
.output()
.unwrap()
.status
.success());
};
run(&["checkout", "-b", "kranz/mission-x"]);
commit_rules(
dir.path(),
"kranz/mission-x",
r#"{"patternRules": [{"pattern": "*", "tier": "local"}]}"#,
);
let routing = load_routing_rules_at_ref(&repo, "main")
.unwrap()
.expect("base rules load");
assert!(
routing
.pattern_rules
.iter()
.all(|r| r.pattern != "*" || r.tier == ExecutorTier::Frontier),
"the mission branch's weakened copy must never reach the base read"
);
let mission = load_routing_rules_at_ref(&repo, "kranz/mission-x")
.unwrap()
.unwrap();
assert_ne!(
routing, mission,
"fixture: the branches' copies must differ"
);
}
#[test]
fn routing_rules_config_invalid_at_ref_fails_closed_owner_repo_setup() {
let Some((dir, repo)) = git_repo() else {
crate::test_capability::skip(
crate::test_capability::capability::GIT,
"git is not on PATH",
);
return;
};
commit_rules(
dir.path(),
"main",
r#"{"taskClassRules": [{"taskClass": "", "tier": "local"}]}"#,
);
let err = load_routing_rules_at_ref(&repo, "main").unwrap_err();
let text = format!("{err}");
assert!(text.contains(ROUTING_RULES_PATH), "{text}");
assert!(text.contains("owner: repo-setup"), "{text}");
assert!(text.contains("taskClassRules[0].taskClass"), "{text}");
}
#[test]
fn routing_rules_config_empty_table_fails_closed_at_parse() {
for shape in [
b"{}".as_slice(),
br#"{"taskClassRules": [], "patternRules": []}"#.as_slice(),
br#"{"taskClassRules": []}"#.as_slice(),
br#"{"patternRules": []}"#.as_slice(),
] {
let err = parse_routing_rules(shape).unwrap_err();
assert!(err.contains(ROUTING_RULES_PATH), "{err}");
assert!(err.contains("empty"), "{err}");
}
assert!(parse_routing_rules(VALID.as_bytes()).is_ok());
}
#[test]
fn routing_rules_config_empty_table_at_ref_fails_closed_missing_stays_none() {
let Some((dir, repo)) = git_repo() else {
crate::test_capability::skip(
crate::test_capability::capability::GIT,
"git is not on PATH",
);
return;
};
commit_rules(dir.path(), "main", "{}");
let err = load_routing_rules_at_ref(&repo, "main").unwrap_err();
let text = format!("{err}");
assert!(text.contains(ROUTING_RULES_PATH), "{text}");
assert!(text.contains("owner: repo-setup"), "{text}");
assert!(text.contains("empty"), "{text}");
assert!(load_routing_rules_at_ref(&repo, "HEAD~1")
.unwrap()
.is_none());
}
#[test]
fn routing_rules_config_file_surface_carries_no_model_id_literals() {
let source = include_str!("routing_rules.rs");
let logic = source
.split("#[cfg(test)]")
.next()
.expect("the test module marker exists");
for needle in [
"\"opus\"",
"\"sonnet\"",
"\"haiku\"",
"\"fable\"",
"gpt-",
"glm-",
"kimi-code",
"claude-",
"fireworks",
"qwen",
"mistral",
"deepseek",
] {
assert!(
!logic.contains(needle),
"model-id literal {needle:?} must never appear in the routing \
rules file surface — route capability classes (ExecutorTier), \
not models"
);
}
}
}