1use std::collections::HashSet;
12
13use clawft_types::routing::{ModelTierConfig, PermissionLevelConfig, RoutingConfig};
14
15#[non_exhaustive]
19#[derive(Debug, Clone, PartialEq, Eq)]
20pub enum ValidationSeverity {
21 Error,
23 Warning,
26}
27
28#[derive(Debug, Clone)]
36pub struct ValidationError {
37 pub field: String,
39 pub message: String,
41 pub severity: ValidationSeverity,
43}
44
45impl std::fmt::Display for ValidationError {
46 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
47 let level = match self.severity {
48 ValidationSeverity::Error => "ERROR",
49 ValidationSeverity::Warning => "WARN",
50 };
51 write!(f, "[{}] {}: {}", level, self.field, self.message)
52 }
53}
54
55impl std::error::Error for ValidationError {}
56
57pub fn validate_routing_config(config: &RoutingConfig) -> Vec<ValidationError> {
67 let mut errors = Vec::new();
68
69 if config.mode == "static" {
71 return errors;
72 }
73
74 validate_mode(config, &mut errors);
75 validate_tiers(config, &mut errors);
76 validate_permissions(config, &mut errors);
77 validate_escalation(config, &mut errors);
78 validate_cost_budgets(config, &mut errors);
79 validate_rate_limiting(config, &mut errors);
80 validate_fallback_model(config, &mut errors);
81
82 errors
83}
84
85fn validate_mode(config: &RoutingConfig, errors: &mut Vec<ValidationError>) {
89 if config.mode != "static" && config.mode != "tiered" {
90 errors.push(ValidationError {
91 field: "routing.mode".into(),
92 message: format!(
93 "unknown mode '{}', expected 'static' or 'tiered'",
94 config.mode
95 ),
96 severity: ValidationSeverity::Error,
97 });
98 }
99}
100
101fn validate_tiers(config: &RoutingConfig, errors: &mut Vec<ValidationError>) {
105 if config.mode == "tiered" && config.tiers.is_empty() {
107 errors.push(ValidationError {
108 field: "routing.tiers".into(),
109 message: "tiered mode requires at least one tier definition".into(),
110 severity: ValidationSeverity::Error,
111 });
112 }
113
114 let mut seen_names: HashSet<&str> = HashSet::new();
116 for (i, tier) in config.tiers.iter().enumerate() {
117 if !seen_names.insert(&tier.name) {
118 errors.push(ValidationError {
119 field: format!("routing.tiers[{}].name", i),
120 message: format!("duplicate tier name '{}'", tier.name),
121 severity: ValidationSeverity::Error,
122 });
123 }
124
125 validate_single_tier(i, tier, errors);
126 }
127
128 for i in 0..config.tiers.len() {
131 for j in (i + 1)..config.tiers.len() {
132 let a = &config.tiers[i];
133 let b = &config.tiers[j];
134 let overlap_min = a.complexity_range[0].max(b.complexity_range[0]);
135 let overlap_max = a.complexity_range[1].min(b.complexity_range[1]);
136 if overlap_min < overlap_max {
137 errors.push(ValidationError {
138 field: format!("routing.tiers[{}]+[{}]", i, j),
139 message: format!(
140 "tiers '{}' and '{}' have overlapping complexity ranges \
141 [{:.2}, {:.2}]",
142 a.name, b.name, overlap_min, overlap_max
143 ),
144 severity: ValidationSeverity::Warning,
145 });
146 }
147 }
148 }
149}
150
151fn validate_single_tier(index: usize, tier: &ModelTierConfig, errors: &mut Vec<ValidationError>) {
153 let prefix = format!("routing.tiers[{}]", index);
154
155 if tier.models.is_empty() {
157 errors.push(ValidationError {
158 field: format!("{}.models", prefix),
159 message: format!(
160 "tier '{}' has no models -- it is defined but unusable",
161 tier.name
162 ),
163 severity: ValidationSeverity::Warning,
164 });
165 }
166
167 let [min, max] = tier.complexity_range;
169 if min > max {
170 errors.push(ValidationError {
171 field: format!("{}.complexity_range", prefix),
172 message: format!(
173 "complexity_range min ({}) > max ({}) for tier '{}'",
174 min, max, tier.name
175 ),
176 severity: ValidationSeverity::Error,
177 });
178 }
179
180 if !(0.0..=1.0).contains(&min) {
182 errors.push(ValidationError {
183 field: format!("{}.complexity_range[0]", prefix),
184 message: format!(
185 "complexity_range[0] = {} is outside [0.0, 1.0] for tier '{}'",
186 min, tier.name
187 ),
188 severity: ValidationSeverity::Error,
189 });
190 }
191 if !(0.0..=1.0).contains(&max) {
192 errors.push(ValidationError {
193 field: format!("{}.complexity_range[1]", prefix),
194 message: format!(
195 "complexity_range[1] = {} is outside [0.0, 1.0] for tier '{}'",
196 max, tier.name
197 ),
198 severity: ValidationSeverity::Error,
199 });
200 }
201
202 if tier.cost_per_1k_tokens < 0.0 {
204 errors.push(ValidationError {
205 field: format!("{}.cost_per_1k_tokens", prefix),
206 message: format!(
207 "cost_per_1k_tokens = {} must be non-negative for tier '{}'",
208 tier.cost_per_1k_tokens, tier.name
209 ),
210 severity: ValidationSeverity::Error,
211 });
212 }
213
214 if tier.max_context_tokens == 0 {
216 errors.push(ValidationError {
217 field: format!("{}.max_context_tokens", prefix),
218 message: format!("max_context_tokens must be > 0 for tier '{}'", tier.name),
219 severity: ValidationSeverity::Error,
220 });
221 }
222
223 for (j, model) in tier.models.iter().enumerate() {
225 if !model.contains('/') {
226 errors.push(ValidationError {
227 field: format!("{}.models[{}]", prefix, j),
228 message: format!(
229 "model '{}' does not contain '/' (expected 'provider/model' format)",
230 model
231 ),
232 severity: ValidationSeverity::Warning,
233 });
234 }
235 }
236}
237
238fn validate_permissions(config: &RoutingConfig, errors: &mut Vec<ValidationError>) {
242 let valid_tier_names: HashSet<&str> = config.tiers.iter().map(|t| t.name.as_str()).collect();
243
244 validate_permission_level(
246 &config.permissions.zero_trust,
247 "routing.permissions.zero_trust",
248 &valid_tier_names,
249 errors,
250 );
251 validate_permission_level(
252 &config.permissions.user,
253 "routing.permissions.user",
254 &valid_tier_names,
255 errors,
256 );
257 validate_permission_level(
258 &config.permissions.admin,
259 "routing.permissions.admin",
260 &valid_tier_names,
261 errors,
262 );
263
264 for (user_id, plc) in &config.permissions.users {
266 validate_permission_level(
267 plc,
268 &format!("routing.permissions.users.{}", user_id),
269 &valid_tier_names,
270 errors,
271 );
272 }
273
274 for (channel, plc) in &config.permissions.channels {
276 validate_permission_level(
277 plc,
278 &format!("routing.permissions.channels.{}", channel),
279 &valid_tier_names,
280 errors,
281 );
282 }
283}
284
285fn validate_permission_level(
287 plc: &PermissionLevelConfig,
288 field_prefix: &str,
289 valid_tiers: &HashSet<&str>,
290 errors: &mut Vec<ValidationError>,
291) {
292 if let Some(level) = plc.level
294 && level > 2 {
295 errors.push(ValidationError {
296 field: format!("{}.level", field_prefix),
297 message: format!(
298 "permission level {} is not recognized (expected 0, 1, or 2)",
299 level
300 ),
301 severity: ValidationSeverity::Warning,
302 });
303 }
304
305 if let Some(threshold) = plc.escalation_threshold
307 && (!(0.0..=1.0).contains(&threshold)) {
308 errors.push(ValidationError {
309 field: format!("{}.escalation_threshold", field_prefix),
310 message: format!("escalation_threshold {} must be in [0.0, 1.0]", threshold),
311 severity: ValidationSeverity::Error,
312 });
313 }
314
315 if let Some(daily) = plc.cost_budget_daily_usd
317 && daily < 0.0 {
318 errors.push(ValidationError {
319 field: format!("{}.cost_budget_daily_usd", field_prefix),
320 message: format!("cost_budget_daily_usd {} must be non-negative", daily),
321 severity: ValidationSeverity::Error,
322 });
323 }
324
325 if let Some(monthly) = plc.cost_budget_monthly_usd
327 && monthly < 0.0 {
328 errors.push(ValidationError {
329 field: format!("{}.cost_budget_monthly_usd", field_prefix),
330 message: format!("cost_budget_monthly_usd {} must be non-negative", monthly),
331 severity: ValidationSeverity::Error,
332 });
333 }
334
335 if let Some(ref max_tier) = plc.max_tier
337 && !valid_tiers.is_empty() && !valid_tiers.contains(max_tier.as_str()) {
338 errors.push(ValidationError {
339 field: format!("{}.max_tier", field_prefix),
340 message: format!(
341 "max_tier '{}' does not match any defined tier (available: {})",
342 max_tier,
343 valid_tiers.iter().copied().collect::<Vec<_>>().join(", ")
344 ),
345 severity: ValidationSeverity::Warning,
346 });
347 }
348
349 if let Some(ref tool_access) = plc.tool_access {
351 for entry in tool_access {
352 if entry.contains('*') && entry != "*" {
353 errors.push(ValidationError {
354 field: format!("{}.tool_access", field_prefix),
355 message: format!(
356 "glob-like pattern '{}' detected; ensure pattern matching is intended",
357 entry
358 ),
359 severity: ValidationSeverity::Warning,
360 });
361 }
362 }
363 }
364}
365
366fn validate_escalation(config: &RoutingConfig, errors: &mut Vec<ValidationError>) {
370 if config.escalation.enabled && config.escalation.max_escalation_tiers == 0 {
372 errors.push(ValidationError {
373 field: "routing.escalation.max_escalation_tiers".into(),
374 message: "max_escalation_tiers must be > 0 when escalation is enabled".into(),
375 severity: ValidationSeverity::Error,
376 });
377 }
378
379 if config.escalation.threshold < 0.0 || config.escalation.threshold > 1.0 {
381 errors.push(ValidationError {
382 field: "routing.escalation.threshold".into(),
383 message: format!(
384 "escalation threshold {} must be in [0.0, 1.0]",
385 config.escalation.threshold
386 ),
387 severity: ValidationSeverity::Error,
388 });
389 }
390
391 if config.escalation.max_escalation_tiers > config.tiers.len() as u32 {
393 errors.push(ValidationError {
394 field: "routing.escalation.max_escalation_tiers".into(),
395 message: format!(
396 "max_escalation_tiers ({}) exceeds the number of defined tiers ({})",
397 config.escalation.max_escalation_tiers,
398 config.tiers.len()
399 ),
400 severity: ValidationSeverity::Warning,
401 });
402 }
403}
404
405fn validate_cost_budgets(config: &RoutingConfig, errors: &mut Vec<ValidationError>) {
409 if config.cost_budgets.global_daily_limit_usd < 0.0 {
410 errors.push(ValidationError {
411 field: "routing.cost_budgets.global_daily_limit_usd".into(),
412 message: format!(
413 "global_daily_limit_usd {} must be non-negative",
414 config.cost_budgets.global_daily_limit_usd
415 ),
416 severity: ValidationSeverity::Error,
417 });
418 }
419
420 if config.cost_budgets.global_monthly_limit_usd < 0.0 {
421 errors.push(ValidationError {
422 field: "routing.cost_budgets.global_monthly_limit_usd".into(),
423 message: format!(
424 "global_monthly_limit_usd {} must be non-negative",
425 config.cost_budgets.global_monthly_limit_usd
426 ),
427 severity: ValidationSeverity::Error,
428 });
429 }
430
431 if config.cost_budgets.reset_hour_utc > 23 {
432 errors.push(ValidationError {
433 field: "routing.cost_budgets.reset_hour_utc".into(),
434 message: format!(
435 "reset_hour_utc {} must be 0-23",
436 config.cost_budgets.reset_hour_utc
437 ),
438 severity: ValidationSeverity::Error,
439 });
440 }
441}
442
443fn validate_rate_limiting(config: &RoutingConfig, errors: &mut Vec<ValidationError>) {
447 if config.rate_limiting.window_seconds == 0 {
448 errors.push(ValidationError {
449 field: "routing.rate_limiting.window_seconds".into(),
450 message: "window_seconds must be > 0".into(),
451 severity: ValidationSeverity::Error,
452 });
453 }
454
455 let valid_strategies = ["sliding_window", "fixed_window"];
456 if !valid_strategies.contains(&config.rate_limiting.strategy.as_str()) {
457 errors.push(ValidationError {
458 field: "routing.rate_limiting.strategy".into(),
459 message: format!(
460 "unknown strategy '{}', expected 'sliding_window' or 'fixed_window'",
461 config.rate_limiting.strategy
462 ),
463 severity: ValidationSeverity::Error,
464 });
465 }
466}
467
468fn validate_fallback_model(config: &RoutingConfig, errors: &mut Vec<ValidationError>) {
472 if let Some(ref model) = config.fallback_model
473 && !model.contains('/') {
474 errors.push(ValidationError {
475 field: "routing.fallback_model".into(),
476 message: format!(
477 "fallback_model '{}' should match 'provider/model' format",
478 model
479 ),
480 severity: ValidationSeverity::Warning,
481 });
482 }
483}
484
485const DEFAULT_MAX_GRANTABLE_LEVEL: u8 = 1;
492
493pub fn validate_workspace_ceiling(
507 global: &RoutingConfig,
508 workspace: &RoutingConfig,
509) -> Vec<ValidationError> {
510 let mut errors = Vec::new();
511 let max_grantable = DEFAULT_MAX_GRANTABLE_LEVEL;
512
513 check_level_ceiling(
515 &workspace.permissions.zero_trust,
516 "routing.permissions.zero_trust",
517 &global.permissions.zero_trust,
518 max_grantable,
519 &mut errors,
520 );
521 check_level_ceiling(
522 &workspace.permissions.user,
523 "routing.permissions.user",
524 &global.permissions.user,
525 max_grantable,
526 &mut errors,
527 );
528 check_level_ceiling(
529 &workspace.permissions.admin,
530 "routing.permissions.admin",
531 &global.permissions.admin,
532 max_grantable,
533 &mut errors,
534 );
535
536 for (user_id, ws_plc) in &workspace.permissions.users {
538 let global_plc = global
539 .permissions
540 .users
541 .get(user_id)
542 .unwrap_or(&global.permissions.user);
543 check_level_ceiling(
544 ws_plc,
545 &format!("routing.permissions.users.{}", user_id),
546 global_plc,
547 max_grantable,
548 &mut errors,
549 );
550 }
551
552 for (channel, ws_plc) in &workspace.permissions.channels {
554 let global_plc = global
555 .permissions
556 .channels
557 .get(channel)
558 .unwrap_or(&global.permissions.user);
559 check_level_ceiling(
560 ws_plc,
561 &format!("routing.permissions.channels.{}", channel),
562 global_plc,
563 max_grantable,
564 &mut errors,
565 );
566 }
567
568 errors
569}
570
571fn check_level_ceiling(
573 ws_plc: &PermissionLevelConfig,
574 field_prefix: &str,
575 global_plc: &PermissionLevelConfig,
576 max_grantable: u8,
577 errors: &mut Vec<ValidationError>,
578) {
579 if let Some(ws_level) = ws_plc.level
581 && ws_level > max_grantable {
582 errors.push(ValidationError {
583 field: format!("{}.level", field_prefix),
584 message: format!(
585 "workspace level {} exceeds max_grantable_level {}",
586 ws_level, max_grantable
587 ),
588 severity: ValidationSeverity::Error,
589 });
590 }
591
592 if let (Some(true), Some(false)) = (ws_plc.escalation_allowed, global_plc.escalation_allowed) {
594 errors.push(ValidationError {
595 field: format!("{}.escalation_allowed", field_prefix),
596 message: "workspace cannot enable escalation when global disables it".into(),
597 severity: ValidationSeverity::Error,
598 });
599 }
600
601 if let (Some(ws_tools), Some(global_tools)) =
603 (&ws_plc.tool_access, &global_plc.tool_access)
604 {
605 if !global_tools.iter().any(|s| s == "*") {
607 for ws_tool in ws_tools {
608 if ws_tool == "*" {
609 errors.push(ValidationError {
610 field: format!("{}.tool_access", field_prefix),
611 message: "workspace cannot grant wildcard tool access \
612 when global does not"
613 .into(),
614 severity: ValidationSeverity::Error,
615 });
616 } else if !global_tools.contains(ws_tool) {
617 errors.push(ValidationError {
618 field: format!("{}.tool_access", field_prefix),
619 message: format!("workspace tool '{}' not in global tool_access", ws_tool),
620 severity: ValidationSeverity::Error,
621 });
622 }
623 }
624 }
625 }
626
627 if let (Some(ws_rate), Some(global_rate)) = (ws_plc.rate_limit, global_plc.rate_limit)
629 && global_rate > 0 && (ws_rate > global_rate || ws_rate == 0) {
630 errors.push(ValidationError {
631 field: format!("{}.rate_limit", field_prefix),
632 message: format!(
633 "workspace rate_limit {} exceeds global ceiling {}",
634 ws_rate, global_rate
635 ),
636 severity: ValidationSeverity::Error,
637 });
638 }
639
640 if let (Some(ws_daily), Some(global_daily)) = (
642 ws_plc.cost_budget_daily_usd,
643 global_plc.cost_budget_daily_usd,
644 )
645 && global_daily > 0.0 && (ws_daily > global_daily || ws_daily == 0.0) {
646 errors.push(ValidationError {
647 field: format!("{}.cost_budget_daily_usd", field_prefix),
648 message: format!(
649 "workspace daily budget {} exceeds global ceiling {}",
650 ws_daily, global_daily
651 ),
652 severity: ValidationSeverity::Error,
653 });
654 }
655
656 if let (Some(ws_monthly), Some(global_monthly)) = (
658 ws_plc.cost_budget_monthly_usd,
659 global_plc.cost_budget_monthly_usd,
660 )
661 && global_monthly > 0.0 && (ws_monthly > global_monthly || ws_monthly == 0.0) {
662 errors.push(ValidationError {
663 field: format!("{}.cost_budget_monthly_usd", field_prefix),
664 message: format!(
665 "workspace monthly budget {} exceeds global ceiling {}",
666 ws_monthly, global_monthly
667 ),
668 severity: ValidationSeverity::Error,
669 });
670 }
671}
672
673pub fn default_tiers() -> Vec<ModelTierConfig> {
685 vec![
686 ModelTierConfig {
687 name: "free".into(),
688 models: vec![
689 "openrouter/meta-llama/llama-3.1-8b-instruct:free".into(),
690 "groq/llama-3.1-8b".into(),
691 ],
692 complexity_range: [0.0, 0.3],
693 cost_per_1k_tokens: 0.0,
694 max_context_tokens: 8192,
695 },
696 ModelTierConfig {
697 name: "standard".into(),
698 models: vec![
699 "anthropic/claude-haiku-3.5".into(),
700 "openai/gpt-4o-mini".into(),
701 "groq/llama-3.3-70b".into(),
702 ],
703 complexity_range: [0.0, 0.7],
704 cost_per_1k_tokens: 0.001,
705 max_context_tokens: 16384,
706 },
707 ModelTierConfig {
708 name: "premium".into(),
709 models: vec![
710 "anthropic/claude-sonnet-4-20250514".into(),
711 "openai/gpt-4o".into(),
712 ],
713 complexity_range: [0.3, 1.0],
714 cost_per_1k_tokens: 0.01,
715 max_context_tokens: 200_000,
716 },
717 ]
718}
719
720#[cfg(test)]
723mod tests {
724 use super::*;
725
726 use clawft_types::routing::{EscalationConfig, ModelTierConfig, RateLimitConfig};
727
728 fn valid_tiered_config() -> RoutingConfig {
730 RoutingConfig {
731 mode: "tiered".into(),
732 tiers: vec![
733 ModelTierConfig {
734 name: "free".into(),
735 models: vec!["groq/llama-3.1-8b".into()],
736 complexity_range: [0.0, 0.5],
737 cost_per_1k_tokens: 0.0,
738 max_context_tokens: 8192,
739 },
740 ModelTierConfig {
741 name: "standard".into(),
742 models: vec!["anthropic/claude-haiku-3.5".into()],
743 complexity_range: [0.3, 1.0],
744 cost_per_1k_tokens: 0.001,
745 max_context_tokens: 16384,
746 },
747 ],
748 ..RoutingConfig::default()
749 }
750 }
751
752 fn valid_static_config() -> RoutingConfig {
754 RoutingConfig::default()
755 }
756
757 fn count_severity(errors: &[ValidationError], severity: ValidationSeverity) -> usize {
759 errors.iter().filter(|e| e.severity == severity).count()
760 }
761
762 fn has_error(
764 errors: &[ValidationError],
765 field_prefix: &str,
766 severity: ValidationSeverity,
767 ) -> bool {
768 errors
769 .iter()
770 .any(|e| e.field.starts_with(field_prefix) && e.severity == severity)
771 }
772
773 #[test]
776 fn valid_static_config_passes() {
777 let config = valid_static_config();
778 let errors = validate_routing_config(&config);
779 assert!(errors.is_empty(), "static config should produce no errors");
780 }
781
782 #[test]
785 fn valid_tiered_config_passes() {
786 let config = valid_tiered_config();
787 let errors = validate_routing_config(&config);
788 let fatal = count_severity(&errors, ValidationSeverity::Error);
789 assert_eq!(
790 fatal, 0,
791 "valid tiered config should have no fatal errors, got: {:?}",
792 errors
793 );
794 }
795
796 #[test]
799 fn invalid_mode_rejected() {
800 let mut config = valid_tiered_config();
801 config.mode = "hybrid".into();
802 let errors = validate_routing_config(&config);
803 assert!(
804 has_error(&errors, "routing.mode", ValidationSeverity::Error),
805 "unknown mode should produce an error"
806 );
807 }
808
809 #[test]
812 fn tiered_with_no_tiers_gives_error() {
813 let config = RoutingConfig {
814 mode: "tiered".into(),
815 tiers: vec![],
816 ..RoutingConfig::default()
817 };
818 let errors = validate_routing_config(&config);
819 assert!(
820 has_error(&errors, "routing.tiers", ValidationSeverity::Error),
821 "tiered mode with no tiers should be an error"
822 );
823 }
824
825 #[test]
828 fn duplicate_tier_names_detected() {
829 let mut config = valid_tiered_config();
830 config.tiers[1].name = "free".into(); let errors = validate_routing_config(&config);
832 assert!(
833 has_error(&errors, "routing.tiers[1].name", ValidationSeverity::Error),
834 "duplicate tier names should produce an error"
835 );
836 }
837
838 #[test]
841 fn empty_tier_models_warning() {
842 let mut config = valid_tiered_config();
843 config.tiers[0].models.clear();
844 let errors = validate_routing_config(&config);
845 assert!(
846 has_error(
847 &errors,
848 "routing.tiers[0].models",
849 ValidationSeverity::Warning
850 ),
851 "empty models should produce a warning"
852 );
853 }
854
855 #[test]
858 fn complexity_range_inverted() {
859 let mut config = valid_tiered_config();
860 config.tiers[0].complexity_range = [0.8, 0.3];
861 let errors = validate_routing_config(&config);
862 assert!(
863 has_error(
864 &errors,
865 "routing.tiers[0].complexity_range",
866 ValidationSeverity::Error
867 ),
868 "inverted complexity range should produce an error"
869 );
870 }
871
872 #[test]
875 fn complexity_out_of_range() {
876 let mut config = valid_tiered_config();
877 config.tiers[0].complexity_range = [-0.1, 1.5];
878 let errors = validate_routing_config(&config);
879 assert!(
880 has_error(
881 &errors,
882 "routing.tiers[0].complexity_range[0]",
883 ValidationSeverity::Error
884 ),
885 "negative min should produce an error"
886 );
887 assert!(
888 has_error(
889 &errors,
890 "routing.tiers[0].complexity_range[1]",
891 ValidationSeverity::Error
892 ),
893 "max > 1.0 should produce an error"
894 );
895 }
896
897 #[test]
900 fn negative_cost_rejected() {
901 let mut config = valid_tiered_config();
902 config.tiers[0].cost_per_1k_tokens = -0.001;
903 let errors = validate_routing_config(&config);
904 assert!(
905 has_error(
906 &errors,
907 "routing.tiers[0].cost_per_1k_tokens",
908 ValidationSeverity::Error
909 ),
910 "negative cost should produce an error"
911 );
912 }
913
914 #[test]
917 fn zero_max_context_tokens_rejected() {
918 let mut config = valid_tiered_config();
919 config.tiers[0].max_context_tokens = 0;
920 let errors = validate_routing_config(&config);
921 assert!(
922 has_error(
923 &errors,
924 "routing.tiers[0].max_context_tokens",
925 ValidationSeverity::Error
926 ),
927 "zero max_context_tokens should produce an error"
928 );
929 }
930
931 #[test]
934 fn invalid_permission_level_warning() {
935 let mut config = valid_tiered_config();
936 config.permissions.zero_trust.level = Some(5);
937 let errors = validate_routing_config(&config);
938 assert!(
939 has_error(
940 &errors,
941 "routing.permissions.zero_trust.level",
942 ValidationSeverity::Warning
943 ),
944 "invalid level should produce a warning"
945 );
946 }
947
948 #[test]
951 fn invalid_escalation_threshold() {
952 let mut config = valid_tiered_config();
953 config.permissions.user.escalation_threshold = Some(1.5);
954 let errors = validate_routing_config(&config);
955 assert!(
956 has_error(
957 &errors,
958 "routing.permissions.user.escalation_threshold",
959 ValidationSeverity::Error
960 ),
961 "escalation_threshold > 1.0 should produce an error"
962 );
963 }
964
965 #[test]
968 fn negative_budget_rejected() {
969 let mut config = valid_tiered_config();
970 config.permissions.user.cost_budget_daily_usd = Some(-1.0);
971 let errors = validate_routing_config(&config);
972 assert!(
973 has_error(
974 &errors,
975 "routing.permissions.user.cost_budget_daily_usd",
976 ValidationSeverity::Error
977 ),
978 "negative daily budget should produce an error"
979 );
980 }
981
982 #[test]
985 fn max_tier_nonexistent_warns() {
986 let mut config = valid_tiered_config();
987 config.permissions.user.max_tier = Some("mythical".into());
988 let errors = validate_routing_config(&config);
989 assert!(
990 has_error(
991 &errors,
992 "routing.permissions.user.max_tier",
993 ValidationSeverity::Warning
994 ),
995 "undefined max_tier should produce a warning"
996 );
997 }
998
999 #[test]
1002 fn glob_like_tool_pattern_warning() {
1003 let mut config = valid_tiered_config();
1004 config.permissions.user.tool_access = Some(vec!["file_*".into()]);
1005 let errors = validate_routing_config(&config);
1006 assert!(
1007 has_error(
1008 &errors,
1009 "routing.permissions.user.tool_access",
1010 ValidationSeverity::Warning
1011 ),
1012 "glob-like tool_access should produce a warning"
1013 );
1014 }
1015
1016 #[test]
1019 fn exact_wildcard_no_warning() {
1020 let mut config = valid_tiered_config();
1021 config.permissions.admin.tool_access = Some(vec!["*".into()]);
1022 let errors = validate_routing_config(&config);
1023 let glob_warnings = errors.iter().any(|e| {
1024 e.field.contains("tool_access")
1025 && e.severity == ValidationSeverity::Warning
1026 && e.message.contains("glob")
1027 });
1028 assert!(
1029 !glob_warnings,
1030 "exact '*' should not produce a glob pattern warning"
1031 );
1032 }
1033
1034 #[test]
1037 fn fallback_model_format_warning() {
1038 let mut config = valid_tiered_config();
1039 config.fallback_model = Some("gpt-4o".into());
1040 let errors = validate_routing_config(&config);
1041 assert!(
1042 has_error(
1043 &errors,
1044 "routing.fallback_model",
1045 ValidationSeverity::Warning
1046 ),
1047 "fallback model without '/' should produce a warning"
1048 );
1049 }
1050
1051 #[test]
1054 fn workspace_ceiling_level_escalation_blocked() {
1055 let global = valid_tiered_config();
1056 let mut workspace = RoutingConfig::default();
1057 workspace.permissions.user.level = Some(2); let errors = validate_workspace_ceiling(&global, &workspace);
1059 assert!(
1060 has_error(
1061 &errors,
1062 "routing.permissions.user.level",
1063 ValidationSeverity::Error
1064 ),
1065 "workspace level exceeding max_grantable should produce an error"
1066 );
1067 }
1068
1069 #[test]
1072 fn workspace_ceiling_rate_limit_increase_blocked() {
1073 let mut global = valid_tiered_config();
1074 global.permissions.user.rate_limit = Some(60);
1075 let mut workspace = RoutingConfig::default();
1076 workspace.permissions.user.rate_limit = Some(120);
1077 let errors = validate_workspace_ceiling(&global, &workspace);
1078 assert!(
1079 has_error(
1080 &errors,
1081 "routing.permissions.user.rate_limit",
1082 ValidationSeverity::Error
1083 ),
1084 "workspace rate_limit exceeding global should produce an error"
1085 );
1086 }
1087
1088 #[test]
1091 fn workspace_ceiling_budget_increase_blocked() {
1092 let mut global = valid_tiered_config();
1093 global.permissions.user.cost_budget_daily_usd = Some(5.0);
1094 let mut workspace = RoutingConfig::default();
1095 workspace.permissions.user.cost_budget_daily_usd = Some(10.0);
1096 let errors = validate_workspace_ceiling(&global, &workspace);
1097 assert!(
1098 has_error(
1099 &errors,
1100 "routing.permissions.user.cost_budget_daily_usd",
1101 ValidationSeverity::Error
1102 ),
1103 "workspace daily budget exceeding global should produce an error"
1104 );
1105 }
1106
1107 #[test]
1110 fn workspace_ceiling_tool_access_expansion_blocked() {
1111 let mut global = valid_tiered_config();
1112 global.permissions.user.tool_access = Some(vec!["read_file".into(), "list_dir".into()]);
1113 let mut workspace = RoutingConfig::default();
1114 workspace.permissions.user.tool_access = Some(vec!["exec_shell".into()]);
1115 let errors = validate_workspace_ceiling(&global, &workspace);
1116 assert!(
1117 has_error(
1118 &errors,
1119 "routing.permissions.user.tool_access",
1120 ValidationSeverity::Error
1121 ),
1122 "workspace adding tool not in global should produce an error"
1123 );
1124 }
1125
1126 #[test]
1129 fn workspace_within_bounds_passes() {
1130 let mut global = valid_tiered_config();
1131 global.permissions.user.level = Some(1);
1132 global.permissions.user.rate_limit = Some(60);
1133 global.permissions.user.cost_budget_daily_usd = Some(5.0);
1134
1135 let mut workspace = RoutingConfig::default();
1136 workspace.permissions.user.level = Some(1);
1137 workspace.permissions.user.rate_limit = Some(30);
1138 workspace.permissions.user.cost_budget_daily_usd = Some(3.0);
1139
1140 let errors = validate_workspace_ceiling(&global, &workspace);
1141 let fatal = count_severity(&errors, ValidationSeverity::Error);
1142 assert_eq!(
1143 fatal, 0,
1144 "workspace within bounds should have no errors, got: {:?}",
1145 errors
1146 );
1147 }
1148
1149 #[test]
1152 fn default_tiers_generation() {
1153 let tiers = default_tiers();
1154 assert_eq!(tiers.len(), 3, "should produce 3 default tiers");
1155 assert_eq!(tiers[0].name, "free");
1156 assert_eq!(tiers[1].name, "standard");
1157 assert_eq!(tiers[2].name, "premium");
1158
1159 for tier in &tiers {
1161 assert!(
1162 !tier.models.is_empty(),
1163 "tier '{}' should have models",
1164 tier.name
1165 );
1166 }
1167
1168 assert_eq!(tiers[0].cost_per_1k_tokens, 0.0);
1170
1171 for tier in &tiers {
1173 assert!(tier.complexity_range[0] >= 0.0);
1174 assert!(tier.complexity_range[1] <= 1.0);
1175 assert!(tier.complexity_range[0] <= tier.complexity_range[1]);
1176 }
1177 }
1178
1179 #[test]
1182 fn default_tiers_pass_validation() {
1183 let config = RoutingConfig {
1184 mode: "tiered".into(),
1185 tiers: default_tiers(),
1186 ..RoutingConfig::default()
1187 };
1188 let errors = validate_routing_config(&config);
1189 let fatal = count_severity(&errors, ValidationSeverity::Error);
1190 assert_eq!(
1191 fatal, 0,
1192 "default tiers should pass validation, got: {:?}",
1193 errors
1194 );
1195 }
1196
1197 #[test]
1200 fn escalation_global_threshold_out_of_range() {
1201 let mut config = valid_tiered_config();
1202 config.escalation = EscalationConfig {
1203 enabled: true,
1204 threshold: 1.5,
1205 max_escalation_tiers: 1,
1206 };
1207 let errors = validate_routing_config(&config);
1208 assert!(
1209 has_error(
1210 &errors,
1211 "routing.escalation.threshold",
1212 ValidationSeverity::Error
1213 ),
1214 "escalation threshold > 1.0 should produce an error"
1215 );
1216 }
1217
1218 #[test]
1221 fn zero_window_seconds_rejected() {
1222 let mut config = valid_tiered_config();
1223 config.rate_limiting.window_seconds = 0;
1224 let errors = validate_routing_config(&config);
1225 assert!(
1226 has_error(
1227 &errors,
1228 "routing.rate_limiting.window_seconds",
1229 ValidationSeverity::Error
1230 ),
1231 "zero window_seconds should produce an error"
1232 );
1233 }
1234
1235 #[test]
1238 fn unknown_rate_limit_strategy_rejected() {
1239 let mut config = valid_tiered_config();
1240 config.rate_limiting.strategy = "token_bucket".into();
1241 let errors = validate_routing_config(&config);
1242 assert!(
1243 has_error(
1244 &errors,
1245 "routing.rate_limiting.strategy",
1246 ValidationSeverity::Error
1247 ),
1248 "unknown rate limit strategy should produce an error"
1249 );
1250 }
1251
1252 #[test]
1255 fn reset_hour_out_of_range() {
1256 let mut config = valid_tiered_config();
1257 config.cost_budgets.reset_hour_utc = 25;
1258 let errors = validate_routing_config(&config);
1259 assert!(
1260 has_error(
1261 &errors,
1262 "routing.cost_budgets.reset_hour_utc",
1263 ValidationSeverity::Error
1264 ),
1265 "reset_hour_utc > 23 should produce an error"
1266 );
1267 }
1268
1269 #[test]
1272 fn negative_global_daily_limit() {
1273 let mut config = valid_tiered_config();
1274 config.cost_budgets.global_daily_limit_usd = -10.0;
1275 let errors = validate_routing_config(&config);
1276 assert!(
1277 has_error(
1278 &errors,
1279 "routing.cost_budgets.global_daily_limit_usd",
1280 ValidationSeverity::Error
1281 ),
1282 "negative global daily limit should produce an error"
1283 );
1284 }
1285
1286 #[test]
1289 fn negative_global_monthly_limit() {
1290 let mut config = valid_tiered_config();
1291 config.cost_budgets.global_monthly_limit_usd = -100.0;
1292 let errors = validate_routing_config(&config);
1293 assert!(
1294 has_error(
1295 &errors,
1296 "routing.cost_budgets.global_monthly_limit_usd",
1297 ValidationSeverity::Error
1298 ),
1299 "negative global monthly limit should produce an error"
1300 );
1301 }
1302
1303 #[test]
1306 fn multiple_errors_collected() {
1307 let config = RoutingConfig {
1308 mode: "tiered".into(),
1309 tiers: vec![
1310 ModelTierConfig {
1311 name: "bad".into(),
1312 models: vec![],
1313 complexity_range: [0.8, 0.3], cost_per_1k_tokens: -1.0, max_context_tokens: 0, },
1317 ModelTierConfig {
1318 name: "bad".into(), models: vec![],
1320 complexity_range: [0.0, 1.0],
1321 cost_per_1k_tokens: 0.0,
1322 max_context_tokens: 8192,
1323 },
1324 ],
1325 rate_limiting: RateLimitConfig {
1326 window_seconds: 0, strategy: "sliding_window".into(),
1328 ..RateLimitConfig::default()
1329 },
1330 ..RoutingConfig::default()
1331 };
1332 let errors = validate_routing_config(&config);
1333 let fatal_count = count_severity(&errors, ValidationSeverity::Error);
1334 assert!(
1335 fatal_count >= 4,
1336 "should collect at least 4 errors, got {}: {:?}",
1337 fatal_count,
1338 errors
1339 );
1340 }
1341
1342 #[test]
1345 fn static_mode_skips_validation() {
1346 let config = RoutingConfig {
1347 mode: "static".into(),
1348 tiers: vec![ModelTierConfig {
1349 name: "broken".into(),
1350 models: vec![],
1351 complexity_range: [0.8, 0.3], cost_per_1k_tokens: -1.0,
1353 max_context_tokens: 0,
1354 }],
1355 ..RoutingConfig::default()
1356 };
1357 let errors = validate_routing_config(&config);
1358 assert!(
1359 errors.is_empty(),
1360 "static mode should skip all validation, got: {:?}",
1361 errors
1362 );
1363 }
1364
1365 #[test]
1368 fn overlapping_complexity_ranges_warning() {
1369 let config = RoutingConfig {
1370 mode: "tiered".into(),
1371 tiers: vec![
1372 ModelTierConfig {
1373 name: "a".into(),
1374 models: vec!["provider/model-a".into()],
1375 complexity_range: [0.0, 0.7],
1376 cost_per_1k_tokens: 0.0,
1377 max_context_tokens: 8192,
1378 },
1379 ModelTierConfig {
1380 name: "b".into(),
1381 models: vec!["provider/model-b".into()],
1382 complexity_range: [0.3, 1.0],
1383 cost_per_1k_tokens: 0.01,
1384 max_context_tokens: 16384,
1385 },
1386 ],
1387 ..RoutingConfig::default()
1388 };
1389 let errors = validate_routing_config(&config);
1390 let overlap_warnings = errors.iter().any(|e| {
1391 e.severity == ValidationSeverity::Warning && e.message.contains("overlapping")
1392 });
1393 assert!(
1394 overlap_warnings,
1395 "overlapping ranges should produce a warning"
1396 );
1397 }
1398
1399 #[test]
1402 fn model_format_warning() {
1403 let mut config = valid_tiered_config();
1404 config.tiers[0].models = vec!["gpt-4o".into()]; let errors = validate_routing_config(&config);
1406 assert!(
1407 has_error(
1408 &errors,
1409 "routing.tiers[0].models[0]",
1410 ValidationSeverity::Warning
1411 ),
1412 "model without '/' should produce a warning"
1413 );
1414 }
1415
1416 #[test]
1419 fn per_user_override_validated() {
1420 let mut config = valid_tiered_config();
1421 config.permissions.users.insert(
1422 "alice".into(),
1423 PermissionLevelConfig {
1424 cost_budget_monthly_usd: Some(-5.0),
1425 ..PermissionLevelConfig::default()
1426 },
1427 );
1428 let errors = validate_routing_config(&config);
1429 assert!(
1430 has_error(
1431 &errors,
1432 "routing.permissions.users.alice.cost_budget_monthly_usd",
1433 ValidationSeverity::Error
1434 ),
1435 "per-user negative budget should produce an error"
1436 );
1437 }
1438
1439 #[test]
1442 fn per_channel_override_validated() {
1443 let mut config = valid_tiered_config();
1444 config.permissions.channels.insert(
1445 "telegram".into(),
1446 PermissionLevelConfig {
1447 escalation_threshold: Some(-0.5),
1448 ..PermissionLevelConfig::default()
1449 },
1450 );
1451 let errors = validate_routing_config(&config);
1452 assert!(
1453 has_error(
1454 &errors,
1455 "routing.permissions.channels.telegram.escalation_threshold",
1456 ValidationSeverity::Error
1457 ),
1458 "per-channel invalid threshold should produce an error"
1459 );
1460 }
1461
1462 #[test]
1465 fn escalation_max_tiers_exceeds_defined_count_warns() {
1466 let mut config = valid_tiered_config();
1467 config.escalation = EscalationConfig {
1468 enabled: true,
1469 threshold: 0.6,
1470 max_escalation_tiers: 10, };
1472 let errors = validate_routing_config(&config);
1473 assert!(
1474 has_error(
1475 &errors,
1476 "routing.escalation.max_escalation_tiers",
1477 ValidationSeverity::Warning
1478 ),
1479 "max_escalation_tiers > tier count should warn"
1480 );
1481 }
1482
1483 #[test]
1486 fn workspace_ceiling_monthly_budget_increase_blocked() {
1487 let mut global = valid_tiered_config();
1488 global.permissions.user.cost_budget_monthly_usd = Some(50.0);
1489 let mut workspace = RoutingConfig::default();
1490 workspace.permissions.user.cost_budget_monthly_usd = Some(100.0);
1491 let errors = validate_workspace_ceiling(&global, &workspace);
1492 assert!(
1493 has_error(
1494 &errors,
1495 "routing.permissions.user.cost_budget_monthly_usd",
1496 ValidationSeverity::Error
1497 ),
1498 "workspace monthly budget exceeding global should produce an error"
1499 );
1500 }
1501
1502 #[test]
1505 fn workspace_ceiling_escalation_enable_blocked() {
1506 let mut global = valid_tiered_config();
1507 global.permissions.user.escalation_allowed = Some(false);
1508 let mut workspace = RoutingConfig::default();
1509 workspace.permissions.user.escalation_allowed = Some(true);
1510 let errors = validate_workspace_ceiling(&global, &workspace);
1511 assert!(
1512 has_error(
1513 &errors,
1514 "routing.permissions.user.escalation_allowed",
1515 ValidationSeverity::Error
1516 ),
1517 "workspace enabling escalation when global disables should error"
1518 );
1519 }
1520
1521 #[test]
1524 fn workspace_wildcard_tool_access_blocked() {
1525 let mut global = valid_tiered_config();
1526 global.permissions.user.tool_access = Some(vec!["read_file".into()]);
1527 let mut workspace = RoutingConfig::default();
1528 workspace.permissions.user.tool_access = Some(vec!["*".into()]);
1529 let errors = validate_workspace_ceiling(&global, &workspace);
1530 assert!(
1531 has_error(
1532 &errors,
1533 "routing.permissions.user.tool_access",
1534 ValidationSeverity::Error
1535 ),
1536 "workspace wildcard when global does not should error"
1537 );
1538 }
1539
1540 #[test]
1543 fn workspace_per_user_override_ceiling() {
1544 let global = valid_tiered_config();
1545 let mut workspace = RoutingConfig::default();
1546 workspace.permissions.users.insert(
1547 "attacker".into(),
1548 PermissionLevelConfig {
1549 level: Some(2), ..PermissionLevelConfig::default()
1551 },
1552 );
1553 let errors = validate_workspace_ceiling(&global, &workspace);
1554 assert!(
1555 has_error(
1556 &errors,
1557 "routing.permissions.users.attacker.level",
1558 ValidationSeverity::Error
1559 ),
1560 "workspace per-user admin level should be blocked"
1561 );
1562 }
1563
1564 #[test]
1567 fn display_impl_formats_correctly() {
1568 let err = ValidationError {
1569 field: "routing.tiers[0].name".into(),
1570 message: "duplicate tier name 'free'".into(),
1571 severity: ValidationSeverity::Error,
1572 };
1573 let displayed = format!("{}", err);
1574 assert!(displayed.contains("[ERROR]"));
1575 assert!(displayed.contains("routing.tiers[0].name"));
1576 assert!(displayed.contains("duplicate tier name 'free'"));
1577
1578 let warn = ValidationError {
1579 field: "routing.fallback_model".into(),
1580 message: "missing provider prefix".into(),
1581 severity: ValidationSeverity::Warning,
1582 };
1583 let displayed = format!("{}", warn);
1584 assert!(displayed.contains("[WARN]"));
1585 }
1586
1587 #[test]
1590 fn escalation_enabled_zero_max_tiers() {
1591 let mut config = valid_tiered_config();
1592 config.escalation = EscalationConfig {
1593 enabled: true,
1594 threshold: 0.6,
1595 max_escalation_tiers: 0,
1596 };
1597 let errors = validate_routing_config(&config);
1598 assert!(
1599 has_error(
1600 &errors,
1601 "routing.escalation.max_escalation_tiers",
1602 ValidationSeverity::Error
1603 ),
1604 "max_escalation_tiers=0 with enabled escalation should error"
1605 );
1606 }
1607
1608 #[test]
1611 fn valid_fallback_model_no_warning() {
1612 let mut config = valid_tiered_config();
1613 config.fallback_model = Some("groq/llama-3.1-8b".into());
1614 let errors = validate_routing_config(&config);
1615 let fb_warnings = errors.iter().any(|e| {
1616 e.field.contains("fallback_model") && e.severity == ValidationSeverity::Warning
1617 });
1618 assert!(!fb_warnings, "valid fallback model should not warn");
1619 }
1620
1621 #[test]
1624 fn workspace_tools_within_global_wildcard_ok() {
1625 let mut global = valid_tiered_config();
1626 global.permissions.user.tool_access = Some(vec!["*".into()]);
1627 let mut workspace = RoutingConfig::default();
1628 workspace.permissions.user.tool_access =
1629 Some(vec!["read_file".into(), "exec_shell".into()]);
1630 let errors = validate_workspace_ceiling(&global, &workspace);
1631 let tool_errors = errors
1632 .iter()
1633 .any(|e| e.field.contains("tool_access") && e.severity == ValidationSeverity::Error);
1634 assert!(
1635 !tool_errors,
1636 "workspace tools under global wildcard should pass"
1637 );
1638 }
1639}