1use crate::error::{EngineError, Result};
61use crate::git_ops::GitRepo;
62use crate::types::{PatternRoute, RoutingConfig, TaskClassRoute};
63use serde::Deserialize;
64
65pub const ROUTING_RULES_PATH: &str = ".kranz/routing-rules.json";
66
67#[derive(Debug, Deserialize)]
74#[serde(rename_all = "camelCase", deny_unknown_fields)]
75struct RoutingRulesFile {
76 #[serde(default)]
77 task_class_rules: Vec<TaskClassRoute>,
78 #[serde(default)]
79 pattern_rules: Vec<PatternRoute>,
80}
81
82pub fn parse_routing_rules(bytes: &[u8]) -> std::result::Result<RoutingConfig, String> {
91 let file: RoutingRulesFile = serde_json::from_slice(bytes)
92 .map_err(|e| format!("invalid JSON in {ROUTING_RULES_PATH}: {e}"))?;
93 let routing = RoutingConfig {
94 task_class_rules: file.task_class_rules,
95 pattern_rules: file.pattern_rules,
96 };
97 if routing.is_empty() {
98 return Err(format!(
99 "{ROUTING_RULES_PATH}: the rules table is empty: a present file IS the routing \
100 table, so an empty one would silently demote routing to the legacy task-class \
101 floor — delete the file to keep the layered config, or declare at least one rule"
102 ));
103 }
104 crate::routing::validate_table(&routing)
105 .map_err(|violation| format!("{ROUTING_RULES_PATH}: {violation}"))?;
106 Ok(routing)
107}
108
109pub fn load_routing_rules_at_ref(repo: &GitRepo, ref_name: &str) -> Result<Option<RoutingConfig>> {
117 match repo.show_file(ref_name, ROUTING_RULES_PATH)? {
118 None => Ok(None),
119 Some(bytes) => parse_routing_rules(&bytes).map(Some).map_err(|violation| {
120 EngineError::Config(format!(
121 "routing rules {ROUTING_RULES_PATH} at {ref_name} is invalid (owner: repo-setup): {violation}"
122 ))
123 }),
124 }
125}
126
127#[cfg(test)]
128mod tests {
129 use super::*;
130 use crate::types::ExecutorTier;
131
132 fn git_repo() -> Option<(tempfile::TempDir, GitRepo)> {
133 let dir = tempfile::tempdir().unwrap();
135 let init = std::process::Command::new("git")
136 .args(["init", "-b", "main"])
137 .current_dir(dir.path())
138 .output()
139 .ok()?;
140 if !init.status.success() {
141 return None;
142 }
143 for args in [
144 &["config", "user.name", "test"][..],
145 &["config", "user.email", "test@example.com"][..],
146 ] {
147 assert!(std::process::Command::new("git")
148 .args(args)
149 .current_dir(dir.path())
150 .output()
151 .unwrap()
152 .status
153 .success());
154 }
155 std::fs::write(dir.path().join("README.md"), "seed\n").unwrap();
156 for args in [&["add", "-A"][..], &["commit", "-m", "seed"][..]] {
157 assert!(std::process::Command::new("git")
158 .args(args)
159 .current_dir(dir.path())
160 .output()
161 .unwrap()
162 .status
163 .success());
164 }
165 let root = std::fs::canonicalize(dir.path()).unwrap();
166 Some((dir, GitRepo::open(&root).unwrap()))
167 }
168
169 fn commit_rules(repo_root: &std::path::Path, branch: &str, bytes: &str) {
170 let run = |args: &[&str]| {
171 assert!(std::process::Command::new("git")
172 .args(args)
173 .current_dir(repo_root)
174 .output()
175 .unwrap()
176 .status
177 .success());
178 };
179 run(&["checkout", branch]);
180 let path = repo_root.join(ROUTING_RULES_PATH);
181 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
182 std::fs::write(&path, bytes).unwrap();
183 run(&["add", "-A"]);
184 run(&["commit", "-m", "rules"]);
185 run(&["checkout", "main"]);
186 }
187
188 const VALID: &str = r#"{
189 "taskClassRules": [
190 {"taskClass": "execution-class", "tier": "local"}
191 ],
192 "patternRules": [
193 {"pattern": "docs-*", "tier": "frontier"},
194 {"pattern": "*", "tier": "frontier"}
195 ]
196 }"#;
197
198 #[test]
199 fn routing_rules_config_parses_both_rule_forms() {
200 let routing = parse_routing_rules(VALID.as_bytes()).expect("a valid file parses");
201 assert_eq!(routing.task_class_rules.len(), 1);
202 assert_eq!(routing.task_class_rules[0].task_class, "execution-class");
203 assert_eq!(routing.task_class_rules[0].tier, ExecutorTier::Local);
204 assert_eq!(routing.pattern_rules.len(), 2);
205 assert_eq!(routing.pattern_rules[0].pattern, "docs-*");
206 assert!(!routing.is_empty());
207 }
208
209 #[test]
210 fn routing_rules_config_invalid_rule_fails_closed_naming_index_and_field() {
211 let err = parse_routing_rules(b"not json").unwrap_err();
213 assert!(err.contains(ROUTING_RULES_PATH), "{err}");
214
215 let err = parse_routing_rules(
217 br#"{"taskClassRules": [{"taskClass": "ok-class", "tier": "local"},
218 {"taskClass": " ", "tier": "frontier"}]}"#,
219 )
220 .unwrap_err();
221 assert!(err.contains(ROUTING_RULES_PATH), "{err}");
222 assert!(err.contains("taskClassRules[1].taskClass"), "{err}");
223
224 let err = parse_routing_rules(
226 br#"{"patternRules": [{"pattern": "docs-*", "tier": "local"},
227 {"pattern": " DOCS-* ", "tier": "frontier"}]}"#,
228 )
229 .unwrap_err();
230 assert!(err.contains("patternRules[1].pattern"), "{err}");
231 assert!(err.contains("duplicates rule 0"), "{err}");
232
233 let err = parse_routing_rules(br#"{"taskclassRules": []}"#).unwrap_err();
235 assert!(err.contains(ROUTING_RULES_PATH), "{err}");
236 }
237
238 #[test]
242 fn routing_rules_config_schema_rejects_model_id_shaped_tiers() {
243 for shape in [
244 br#"{"taskClassRules": [{"taskClass": "execution-class", "tier": "claude-opus-4-1"}]}"#
245 .as_slice(),
246 br#"{"patternRules": [{"pattern": "*", "tier": "gpt-5-codex"}]}"#.as_slice(),
247 br#"{"patternRules": [{"pattern": "*", "tier": "sonnet"}]}"#.as_slice(),
248 ] {
249 let err = parse_routing_rules(shape).unwrap_err();
250 assert!(
251 err.contains("unknown variant") || err.contains("tier"),
252 "a model-id-shaped tier must fail the schema: {err}"
253 );
254 }
255 }
256
257 #[test]
258 fn routing_rules_config_reads_committed_base_bytes_only() {
259 let Some((dir, repo)) = git_repo() else {
260 crate::test_capability::skip(
261 crate::test_capability::capability::GIT,
262 "git is not on PATH",
263 );
264 return;
265 };
266 commit_rules(dir.path(), "main", VALID);
267
268 let routing = load_routing_rules_at_ref(&repo, "main")
270 .expect("load must not error")
271 .expect("committed rules load");
272 assert_eq!(routing.task_class_rules.len(), 1);
273
274 std::fs::write(dir.path().join(ROUTING_RULES_PATH), "not json").unwrap();
277 assert!(
278 load_routing_rules_at_ref(&repo, "main").unwrap().is_some(),
279 "the committed bytes govern, not the dirty working tree"
280 );
281
282 assert!(load_routing_rules_at_ref(&repo, "HEAD~1")
284 .unwrap()
285 .is_none());
286 }
287
288 #[test]
289 fn routing_rules_config_mission_branch_edit_is_ignored_at_read() {
290 let Some((dir, repo)) = git_repo() else {
291 crate::test_capability::skip(
292 crate::test_capability::capability::GIT,
293 "git is not on PATH",
294 );
295 return;
296 };
297 commit_rules(dir.path(), "main", VALID);
298 let run = |args: &[&str]| {
300 assert!(std::process::Command::new("git")
301 .args(args)
302 .current_dir(dir.path())
303 .output()
304 .unwrap()
305 .status
306 .success());
307 };
308 run(&["checkout", "-b", "kranz/mission-x"]);
309 commit_rules(
310 dir.path(),
311 "kranz/mission-x",
312 r#"{"patternRules": [{"pattern": "*", "tier": "local"}]}"#,
313 );
314
315 let routing = load_routing_rules_at_ref(&repo, "main")
316 .unwrap()
317 .expect("base rules load");
318 assert!(
319 routing
320 .pattern_rules
321 .iter()
322 .all(|r| r.pattern != "*" || r.tier == ExecutorTier::Frontier),
323 "the mission branch's weakened copy must never reach the base read"
324 );
325 let mission = load_routing_rules_at_ref(&repo, "kranz/mission-x")
328 .unwrap()
329 .unwrap();
330 assert_ne!(
331 routing, mission,
332 "fixture: the branches' copies must differ"
333 );
334 }
335
336 #[test]
337 fn routing_rules_config_invalid_at_ref_fails_closed_owner_repo_setup() {
338 let Some((dir, repo)) = git_repo() else {
339 crate::test_capability::skip(
340 crate::test_capability::capability::GIT,
341 "git is not on PATH",
342 );
343 return;
344 };
345 commit_rules(
346 dir.path(),
347 "main",
348 r#"{"taskClassRules": [{"taskClass": "", "tier": "local"}]}"#,
349 );
350 let err = load_routing_rules_at_ref(&repo, "main").unwrap_err();
351 let text = format!("{err}");
352 assert!(text.contains(ROUTING_RULES_PATH), "{text}");
353 assert!(text.contains("owner: repo-setup"), "{text}");
354 assert!(text.contains("taskClassRules[0].taskClass"), "{text}");
355 }
356
357 #[test]
362 fn routing_rules_config_empty_table_fails_closed_at_parse() {
363 for shape in [
364 b"{}".as_slice(),
365 br#"{"taskClassRules": [], "patternRules": []}"#.as_slice(),
366 br#"{"taskClassRules": []}"#.as_slice(),
367 br#"{"patternRules": []}"#.as_slice(),
368 ] {
369 let err = parse_routing_rules(shape).unwrap_err();
370 assert!(err.contains(ROUTING_RULES_PATH), "{err}");
371 assert!(err.contains("empty"), "{err}");
372 }
373 assert!(parse_routing_rules(VALID.as_bytes()).is_ok());
375 }
376
377 #[test]
382 fn routing_rules_config_empty_table_at_ref_fails_closed_missing_stays_none() {
383 let Some((dir, repo)) = git_repo() else {
384 crate::test_capability::skip(
385 crate::test_capability::capability::GIT,
386 "git is not on PATH",
387 );
388 return;
389 };
390 commit_rules(dir.path(), "main", "{}");
391 let err = load_routing_rules_at_ref(&repo, "main").unwrap_err();
392 let text = format!("{err}");
393 assert!(text.contains(ROUTING_RULES_PATH), "{text}");
394 assert!(text.contains("owner: repo-setup"), "{text}");
395 assert!(text.contains("empty"), "{text}");
396
397 assert!(load_routing_rules_at_ref(&repo, "HEAD~1")
399 .unwrap()
400 .is_none());
401 }
402
403 #[test]
409 fn routing_rules_config_file_surface_carries_no_model_id_literals() {
410 let source = include_str!("routing_rules.rs");
411 let logic = source
412 .split("#[cfg(test)]")
413 .next()
414 .expect("the test module marker exists");
415 for needle in [
416 "\"opus\"",
417 "\"sonnet\"",
418 "\"haiku\"",
419 "\"fable\"",
420 "gpt-",
421 "glm-",
422 "kimi-code",
423 "claude-",
424 "fireworks",
425 "qwen",
426 "mistral",
427 "deepseek",
428 ] {
429 assert!(
430 !logic.contains(needle),
431 "model-id literal {needle:?} must never appear in the routing \
432 rules file surface — route capability classes (ExecutorTier), \
433 not models"
434 );
435 }
436 }
437}