1use std::time::Instant;
27
28use devboy_core::config::{ProxyRoutingConfig, RoutingStrategy, routing_strategy_slug};
29use serde_json::{Value, json};
30use tracing::info;
31
32use crate::signature_match::MatchReport;
33
34#[derive(Debug, Clone, PartialEq, Eq)]
36pub enum RoutingTarget {
37 Local,
39 Remote {
42 prefix: String,
43 original_name: String,
44 },
45 Reject,
47}
48
49#[derive(Debug, Clone, PartialEq, Eq)]
51pub enum RoutingReason {
52 ExplicitPrefix,
54 LocalOnly,
56 RemoteOnly,
58 StrategyRemote,
60 StrategyLocal,
61 StrategyLocalFirst,
62 StrategyRemoteFirst,
63 OverrideRule(String),
64 SchemaIncompatible,
66 Unknown,
68}
69
70impl RoutingReason {
71 pub fn as_label(&self) -> &str {
72 match self {
73 Self::ExplicitPrefix => "explicit_prefix",
74 Self::LocalOnly => "local_only",
75 Self::RemoteOnly => "remote_only",
76 Self::StrategyRemote => "strategy_remote",
77 Self::StrategyLocal => "strategy_local",
78 Self::StrategyLocalFirst => "strategy_local_first",
79 Self::StrategyRemoteFirst => "strategy_remote_first",
80 Self::OverrideRule(_) => "override_rule",
81 Self::SchemaIncompatible => "schema_incompatible",
82 Self::Unknown => "unknown",
83 }
84 }
85
86 pub fn detail(&self) -> Option<&str> {
87 match self {
88 Self::OverrideRule(p) => Some(p.as_str()),
89 _ => None,
90 }
91 }
92}
93
94#[derive(Debug, Clone)]
96pub struct RoutingDecision {
97 pub primary: RoutingTarget,
99 pub fallback: Option<RoutingTarget>,
101 pub reason: RoutingReason,
103 pub resolved_name: String,
105 pub decided_at: Instant,
107}
108
109impl RoutingDecision {
110 pub fn to_meta_json(&self) -> Value {
114 let target_label = match &self.primary {
115 RoutingTarget::Local => "local".to_string(),
116 RoutingTarget::Remote { prefix, .. } => format!("remote:{}", prefix),
117 RoutingTarget::Reject => "reject".to_string(),
118 };
119 let fallback_label = self.fallback.as_ref().map(|t| match t {
120 RoutingTarget::Local => "local".to_string(),
121 RoutingTarget::Remote { prefix, .. } => format!("remote:{}", prefix),
122 RoutingTarget::Reject => "reject".to_string(),
123 });
124 let mut obj = json!({
125 "resolved_name": self.resolved_name,
126 "target": target_label,
127 "reason": self.reason.as_label(),
128 });
129 if let Some(detail) = self.reason.detail() {
130 obj["reason_detail"] = Value::String(detail.to_string());
131 }
132 if let Some(f) = fallback_label {
133 obj["fallback"] = Value::String(f);
134 }
135 obj
136 }
137
138 pub fn emit_tracing(&self, requested_name: &str) {
141 let target = match &self.primary {
142 RoutingTarget::Local => "local".to_string(),
143 RoutingTarget::Remote { prefix, .. } => format!("remote:{}", prefix),
144 RoutingTarget::Reject => "reject".to_string(),
145 };
146 info!(
147 tool = requested_name,
148 resolved = self.resolved_name.as_str(),
149 target = target.as_str(),
150 reason = self.reason.as_label(),
151 reason_detail = self.reason.detail().unwrap_or(""),
152 has_fallback = self.fallback.is_some(),
153 "routing decision"
154 );
155 }
156
157 pub fn local(name: impl Into<String>, reason: RoutingReason) -> Self {
158 Self {
159 primary: RoutingTarget::Local,
160 fallback: None,
161 reason,
162 resolved_name: name.into(),
163 decided_at: Instant::now(),
164 }
165 }
166
167 pub fn remote(
168 prefix: impl Into<String>,
169 original_name: impl Into<String>,
170 reason: RoutingReason,
171 ) -> Self {
172 let original = original_name.into();
173 Self {
174 primary: RoutingTarget::Remote {
175 prefix: prefix.into(),
176 original_name: original.clone(),
177 },
178 fallback: None,
179 reason,
180 resolved_name: original,
181 decided_at: Instant::now(),
182 }
183 }
184
185 pub fn reject(name: impl Into<String>, reason: RoutingReason) -> Self {
186 Self {
187 primary: RoutingTarget::Reject,
188 fallback: None,
189 reason,
190 resolved_name: name.into(),
191 decided_at: Instant::now(),
192 }
193 }
194
195 pub fn with_fallback(mut self, fallback: RoutingTarget) -> Self {
196 self.fallback = Some(fallback);
197 self
198 }
199}
200
201pub struct RoutingEngine {
206 config: ProxyRoutingConfig,
207 report: MatchReport,
208}
209
210impl RoutingEngine {
211 pub fn new(config: ProxyRoutingConfig, report: MatchReport) -> Self {
212 Self { config, report }
213 }
214
215 pub fn decide(&self, requested_name: &str) -> RoutingDecision {
218 let decision = self.decide_inner(requested_name);
219 decision.emit_tracing(requested_name);
220 decision
221 }
222
223 pub fn decide_quiet(&self, requested_name: &str) -> RoutingDecision {
226 self.decide_inner(requested_name)
227 }
228
229 fn decide_inner(&self, requested_name: &str) -> RoutingDecision {
230 if let Some((prefix, unprefixed)) = split_prefix(requested_name) {
232 return RoutingDecision::remote(prefix, unprefixed, RoutingReason::ExplicitPrefix);
233 }
234
235 let match_info = self.report.get(requested_name);
236
237 let Some(m) = match_info else {
239 return RoutingDecision::reject(requested_name, RoutingReason::Unknown);
240 };
241
242 if m.local_present && !m.remote_present {
244 return RoutingDecision::local(requested_name, RoutingReason::LocalOnly);
245 }
246 if m.remote_present && !m.local_present {
247 let prefix = m.upstream_prefix.clone().unwrap_or_default();
248 return RoutingDecision::remote(prefix, requested_name, RoutingReason::RemoteOnly);
249 }
250
251 if m.schema_compatible == Some(false) {
253 let prefix = m.upstream_prefix.clone().unwrap_or_default();
254 return RoutingDecision::remote(
255 prefix,
256 requested_name,
257 RoutingReason::SchemaIncompatible,
258 );
259 }
260
261 let (strategy, override_pattern) = self.resolved_strategy_with_override(requested_name);
262
263 let base_reason = match override_pattern {
264 Some(pat) => RoutingReason::OverrideRule(pat),
265 None => strategy_label(strategy),
266 };
267
268 let local_target = RoutingTarget::Local;
269 let remote_target = RoutingTarget::Remote {
270 prefix: m.upstream_prefix.clone().unwrap_or_default(),
271 original_name: requested_name.to_string(),
272 };
273
274 match strategy {
275 RoutingStrategy::Remote => RoutingDecision {
276 primary: remote_target,
277 fallback: None,
278 reason: base_reason,
279 resolved_name: requested_name.to_string(),
280 decided_at: Instant::now(),
281 },
282 RoutingStrategy::Local => RoutingDecision {
283 primary: local_target,
284 fallback: None,
285 reason: base_reason,
286 resolved_name: requested_name.to_string(),
287 decided_at: Instant::now(),
288 },
289 RoutingStrategy::LocalFirst => RoutingDecision {
290 primary: local_target,
291 fallback: if self.config.fallback_on_error {
292 Some(remote_target)
293 } else {
294 None
295 },
296 reason: base_reason,
297 resolved_name: requested_name.to_string(),
298 decided_at: Instant::now(),
299 },
300 RoutingStrategy::RemoteFirst => RoutingDecision {
301 primary: remote_target,
302 fallback: if self.config.fallback_on_error {
303 Some(local_target)
304 } else {
305 None
306 },
307 reason: base_reason,
308 resolved_name: requested_name.to_string(),
309 decided_at: Instant::now(),
310 },
311 }
312 }
313
314 pub fn config(&self) -> &ProxyRoutingConfig {
316 &self.config
317 }
318
319 pub fn report(&self) -> &MatchReport {
321 &self.report
322 }
323
324 fn resolved_strategy_with_override(
325 &self,
326 tool_name: &str,
327 ) -> (RoutingStrategy, Option<String>) {
328 for rule in &self.config.tool_overrides {
329 if devboy_core::config::matches_glob(&rule.pattern, tool_name) {
330 return (rule.strategy, Some(rule.pattern.clone()));
331 }
332 }
333 (self.config.strategy, None)
334 }
335}
336
337fn split_prefix(name: &str) -> Option<(String, String)> {
340 name.split_once("__")
341 .map(|(p, t)| (p.to_string(), t.to_string()))
342}
343
344fn strategy_label(s: RoutingStrategy) -> RoutingReason {
345 match s {
346 RoutingStrategy::Remote => RoutingReason::StrategyRemote,
347 RoutingStrategy::Local => RoutingReason::StrategyLocal,
348 RoutingStrategy::LocalFirst => RoutingReason::StrategyLocalFirst,
349 RoutingStrategy::RemoteFirst => RoutingReason::StrategyRemoteFirst,
350 }
351}
352
353#[derive(Debug, Clone)]
356pub struct ProxyStatus {
357 pub strategy: RoutingStrategy,
358 pub fallback_on_error: bool,
359 pub total_tools: usize,
360 pub routable_locally: Vec<String>,
361 pub remote_only: Vec<String>,
362 pub local_only: Vec<String>,
363 pub incompatible: Vec<IncompatibleTool>,
364 pub override_rules: Vec<(String, RoutingStrategy)>,
365}
366
367#[derive(Debug, Clone)]
369pub struct IncompatibleTool {
370 pub tool: String,
371 pub upstream_prefix: Option<String>,
372 pub reason: Option<String>,
373}
374
375impl ProxyStatus {
376 pub fn from_engine(engine: &RoutingEngine) -> Self {
378 let report = engine.report();
379 let config = engine.config();
380
381 let mut routable_locally: Vec<String> = report
382 .routable_locally()
383 .iter()
384 .map(|m| m.tool_name.clone())
385 .collect();
386 routable_locally.sort();
387
388 let mut remote_only: Vec<String> = report
389 .remote_only()
390 .iter()
391 .map(|m| m.tool_name.clone())
392 .collect();
393 remote_only.sort();
394
395 let mut local_only: Vec<String> = report
396 .local_only()
397 .iter()
398 .map(|m| m.tool_name.clone())
399 .collect();
400 local_only.sort();
401
402 let mut incompatible: Vec<IncompatibleTool> = report
403 .incompatible_pairs()
404 .iter()
405 .map(|m| IncompatibleTool {
406 tool: m.tool_name.clone(),
407 upstream_prefix: m.upstream_prefix.clone(),
408 reason: m.schema_mismatch.clone(),
409 })
410 .collect();
411 incompatible.sort_by(|a, b| a.tool.cmp(&b.tool));
412
413 let override_rules: Vec<(String, RoutingStrategy)> = config
414 .tool_overrides
415 .iter()
416 .map(|r| (r.pattern.clone(), r.strategy))
417 .collect();
418
419 Self {
420 strategy: config.strategy,
421 fallback_on_error: config.fallback_on_error,
422 total_tools: report.len(),
423 routable_locally,
424 remote_only,
425 local_only,
426 incompatible,
427 override_rules,
428 }
429 }
430
431 pub fn to_text_report(&self) -> String {
437 use std::fmt::Write;
438 let mut out = String::new();
439 let _ = writeln!(out, "Proxy routing status");
440 let _ = writeln!(out, "====================");
441 let _ = writeln!(
442 out,
443 "strategy : {}",
444 routing_strategy_slug(self.strategy)
445 );
446 let _ = writeln!(out, "fallback_on_error : {}", self.fallback_on_error);
447 let _ = writeln!(out, "total_tools : {}", self.total_tools);
448
449 let _ = writeln!(out);
450 let _ = writeln!(out, "Routable locally ({}):", self.routable_locally.len());
451 for t in &self.routable_locally {
452 let _ = writeln!(out, " • {}", t);
453 }
454 let _ = writeln!(out);
455 let _ = writeln!(out, "Remote-only ({}):", self.remote_only.len());
456 for t in &self.remote_only {
457 let _ = writeln!(out, " • {}", t);
458 }
459 let _ = writeln!(out);
460 let _ = writeln!(out, "Local-only ({}):", self.local_only.len());
461 for t in &self.local_only {
462 let _ = writeln!(out, " • {}", t);
463 }
464 let _ = writeln!(out);
465 let _ = writeln!(out, "Schema incompatible ({}):", self.incompatible.len());
466 for it in &self.incompatible {
467 let up = it.upstream_prefix.as_deref().unwrap_or("(unknown)");
468 let reason = it.reason.as_deref().unwrap_or("(no detail)");
469 let _ = writeln!(out, " • {:<32} via {:<10} — {}", it.tool, up, reason);
470 }
471 if !self.override_rules.is_empty() {
472 let _ = writeln!(out);
473 let _ = writeln!(out, "Override rules:");
474 for (pat, strat) in &self.override_rules {
475 let _ = writeln!(out, " • {:<24} → {}", pat, routing_strategy_slug(*strat));
476 }
477 }
478 out
479 }
480
481 pub fn to_json(&self) -> Value {
487 json!({
488 "strategy": routing_strategy_slug(self.strategy),
489 "fallback_on_error": self.fallback_on_error,
490 "total_tools": self.total_tools,
491 "routable_locally": self.routable_locally,
492 "remote_only": self.remote_only,
493 "local_only": self.local_only,
494 "incompatible": self.incompatible.iter().map(|it| json!({
495 "tool": it.tool,
496 "upstream_prefix": it.upstream_prefix,
497 "reason": it.reason,
498 })).collect::<Vec<_>>(),
499 "override_rules": self.override_rules.iter().map(|(p, s)| json!({
500 "pattern": p,
501 "strategy": routing_strategy_slug(*s),
502 })).collect::<Vec<_>>(),
503 })
504 }
505}
506
507#[cfg(test)]
508mod tests {
509 use super::*;
510 use crate::signature_match::ToolMatch;
511 use devboy_core::config::ProxyToolRule;
512
513 fn report_with(entries: Vec<ToolMatch>) -> MatchReport {
514 let mut report = MatchReport::default();
515 for e in entries {
516 report.matches.insert(e.tool_name.clone(), e);
517 }
518 report
519 }
520
521 fn matched(name: &str, compatible: bool) -> ToolMatch {
522 ToolMatch {
523 tool_name: name.to_string(),
524 local_present: true,
525 remote_present: true,
526 schema_compatible: Some(compatible),
527 upstream_prefix: Some("cloud".to_string()),
528 schema_mismatch: None,
529 }
530 }
531
532 fn local_only(name: &str) -> ToolMatch {
533 ToolMatch {
534 tool_name: name.to_string(),
535 local_present: true,
536 remote_present: false,
537 schema_compatible: None,
538 upstream_prefix: None,
539 schema_mismatch: None,
540 }
541 }
542
543 fn remote_only(name: &str, prefix: &str) -> ToolMatch {
544 ToolMatch {
545 tool_name: name.to_string(),
546 local_present: false,
547 remote_present: true,
548 schema_compatible: None,
549 upstream_prefix: Some(prefix.to_string()),
550 schema_mismatch: None,
551 }
552 }
553
554 #[test]
555 fn test_explicit_prefix_forces_remote() {
556 let engine = RoutingEngine::new(
557 ProxyRoutingConfig::default(),
558 report_with(vec![matched("get_issues", true)]),
559 );
560 let d = engine.decide("cloud__get_issues");
561 assert_eq!(
562 d.primary,
563 RoutingTarget::Remote {
564 prefix: "cloud".to_string(),
565 original_name: "get_issues".to_string(),
566 }
567 );
568 assert_eq!(d.reason, RoutingReason::ExplicitPrefix);
569 }
570
571 #[test]
572 fn test_unknown_tool_rejects() {
573 let engine = RoutingEngine::new(ProxyRoutingConfig::default(), MatchReport::default());
574 let d = engine.decide("mystery_tool");
575 assert_eq!(d.primary, RoutingTarget::Reject);
576 assert_eq!(d.reason, RoutingReason::Unknown);
577 }
578
579 #[test]
580 fn test_local_only_tool_goes_local() {
581 let engine = RoutingEngine::new(
582 ProxyRoutingConfig::default(),
583 report_with(vec![local_only("list_contexts")]),
584 );
585 let d = engine.decide("list_contexts");
586 assert_eq!(d.primary, RoutingTarget::Local);
587 assert_eq!(d.reason, RoutingReason::LocalOnly);
588 }
589
590 #[test]
591 fn test_remote_only_tool_goes_remote() {
592 let engine = RoutingEngine::new(
593 ProxyRoutingConfig::default(),
594 report_with(vec![remote_only("cloud_specific", "cloud")]),
595 );
596 let d = engine.decide("cloud_specific");
597 assert_eq!(
598 d.primary,
599 RoutingTarget::Remote {
600 prefix: "cloud".to_string(),
601 original_name: "cloud_specific".to_string(),
602 }
603 );
604 assert_eq!(d.reason, RoutingReason::RemoteOnly);
605 }
606
607 #[test]
608 fn test_matched_default_strategy_is_remote() {
609 let engine = RoutingEngine::new(
610 ProxyRoutingConfig::default(),
611 report_with(vec![matched("get_issues", true)]),
612 );
613 let d = engine.decide("get_issues");
614 assert!(matches!(d.primary, RoutingTarget::Remote { .. }));
615 assert_eq!(d.reason, RoutingReason::StrategyRemote);
616 assert!(d.fallback.is_none());
617 }
618
619 #[test]
620 fn test_matched_local_strategy() {
621 let config = ProxyRoutingConfig {
622 strategy: RoutingStrategy::Local,
623 ..Default::default()
624 };
625 let engine = RoutingEngine::new(config, report_with(vec![matched("get_issues", true)]));
626 let d = engine.decide("get_issues");
627 assert_eq!(d.primary, RoutingTarget::Local);
628 assert_eq!(d.reason, RoutingReason::StrategyLocal);
629 assert!(d.fallback.is_none());
630 }
631
632 #[test]
633 fn test_local_first_with_fallback_on_error() {
634 let config = ProxyRoutingConfig {
635 strategy: RoutingStrategy::LocalFirst,
636 fallback_on_error: true,
637 ..Default::default()
638 };
639 let engine = RoutingEngine::new(config, report_with(vec![matched("get_issues", true)]));
640 let d = engine.decide("get_issues");
641 assert_eq!(d.primary, RoutingTarget::Local);
642 assert_eq!(d.reason, RoutingReason::StrategyLocalFirst);
643 assert_eq!(
644 d.fallback,
645 Some(RoutingTarget::Remote {
646 prefix: "cloud".to_string(),
647 original_name: "get_issues".to_string(),
648 })
649 );
650 }
651
652 #[test]
653 fn test_remote_first_with_fallback() {
654 let config = ProxyRoutingConfig {
655 strategy: RoutingStrategy::RemoteFirst,
656 fallback_on_error: true,
657 ..Default::default()
658 };
659 let engine = RoutingEngine::new(config, report_with(vec![matched("get_issues", true)]));
660 let d = engine.decide("get_issues");
661 assert!(matches!(d.primary, RoutingTarget::Remote { .. }));
662 assert_eq!(d.fallback, Some(RoutingTarget::Local));
663 assert_eq!(d.reason, RoutingReason::StrategyRemoteFirst);
664 }
665
666 #[test]
667 fn test_local_first_no_fallback_when_disabled() {
668 let config = ProxyRoutingConfig {
669 strategy: RoutingStrategy::LocalFirst,
670 fallback_on_error: false,
671 ..Default::default()
672 };
673 let engine = RoutingEngine::new(config, report_with(vec![matched("get_issues", true)]));
674 let d = engine.decide("get_issues");
675 assert!(d.fallback.is_none());
676 }
677
678 #[test]
679 fn test_incompatible_schema_forces_remote() {
680 let config = ProxyRoutingConfig {
681 strategy: RoutingStrategy::LocalFirst,
682 fallback_on_error: true,
683 ..Default::default()
684 };
685 let engine = RoutingEngine::new(config, report_with(vec![matched("get_issues", false)]));
686 let d = engine.decide("get_issues");
687 assert!(matches!(d.primary, RoutingTarget::Remote { .. }));
688 assert_eq!(d.reason, RoutingReason::SchemaIncompatible);
689 assert!(d.fallback.is_none());
690 }
691
692 #[test]
693 fn test_per_tool_override_wins_over_global_strategy() {
694 let config = ProxyRoutingConfig {
695 strategy: RoutingStrategy::Remote,
696 fallback_on_error: true,
697 tool_overrides: vec![ProxyToolRule {
698 pattern: "get_*".to_string(),
699 strategy: RoutingStrategy::Local,
700 }],
701 };
702 let engine = RoutingEngine::new(
703 config,
704 report_with(vec![
705 matched("get_issues", true),
706 matched("create_issue", true),
707 ]),
708 );
709
710 let d_get = engine.decide("get_issues");
711 assert_eq!(d_get.primary, RoutingTarget::Local);
712 match &d_get.reason {
713 RoutingReason::OverrideRule(p) => assert_eq!(p, "get_*"),
714 other => panic!("expected OverrideRule, got {:?}", other),
715 }
716
717 let d_create = engine.decide("create_issue");
718 assert!(matches!(d_create.primary, RoutingTarget::Remote { .. }));
719 assert_eq!(d_create.reason, RoutingReason::StrategyRemote);
720 }
721
722 #[test]
723 fn test_decision_label_roundtrip() {
724 assert_eq!(RoutingReason::ExplicitPrefix.as_label(), "explicit_prefix");
725 assert_eq!(
726 RoutingReason::OverrideRule("get_*".to_string()).as_label(),
727 "override_rule"
728 );
729 assert_eq!(
730 RoutingReason::OverrideRule("get_*".to_string()).detail(),
731 Some("get_*")
732 );
733 }
734
735 #[test]
736 fn test_to_meta_json_shapes() {
737 let d = RoutingDecision::local("get_issues", RoutingReason::StrategyLocal);
738 let v = d.to_meta_json();
739 assert_eq!(v["target"], "local");
740 assert_eq!(v["reason"], "strategy_local");
741 assert!(v.get("fallback").is_none());
742
743 let d = RoutingDecision::remote(
744 "cloud",
745 "get_issues",
746 RoutingReason::OverrideRule("get_*".to_string()),
747 )
748 .with_fallback(RoutingTarget::Local);
749 let v = d.to_meta_json();
750 assert_eq!(v["target"], "remote:cloud");
751 assert_eq!(v["reason"], "override_rule");
752 assert_eq!(v["reason_detail"], "get_*");
753 assert_eq!(v["fallback"], "local");
754 }
755
756 #[test]
757 fn test_proxy_status_summary() {
758 let config = ProxyRoutingConfig {
759 strategy: RoutingStrategy::LocalFirst,
760 fallback_on_error: true,
761 tool_overrides: vec![ProxyToolRule {
762 pattern: "create_*".to_string(),
763 strategy: RoutingStrategy::Remote,
764 }],
765 };
766 let engine = RoutingEngine::new(
767 config,
768 report_with(vec![
769 matched("get_issues", true),
770 matched("update_issue", false),
771 local_only("list_contexts"),
772 remote_only("cloud_only", "cloud"),
773 ]),
774 );
775
776 let status = ProxyStatus::from_engine(&engine);
777 assert_eq!(status.strategy, RoutingStrategy::LocalFirst);
778 assert_eq!(status.total_tools, 4);
779 assert_eq!(status.routable_locally, vec!["get_issues".to_string()]);
780 assert_eq!(status.remote_only, vec!["cloud_only".to_string()]);
781 assert_eq!(status.local_only, vec!["list_contexts".to_string()]);
782 assert_eq!(status.incompatible.len(), 1);
783 assert_eq!(status.incompatible[0].tool, "update_issue");
784 assert_eq!(status.override_rules.len(), 1);
785
786 let text = status.to_text_report();
787 assert!(text.contains("Routable locally (1):"));
788 assert!(text.contains("get_issues"));
789 assert!(
791 text.contains("strategy : local-first"),
792 "missing kebab-case strategy line in:\n{}",
793 text
794 );
795 let override_line = text
797 .lines()
798 .find(|l| l.contains("create_*"))
799 .expect("override rule line present");
800 assert!(
801 override_line.ends_with("→ remote"),
802 "override rule must end with '→ remote' (kebab-case), got: {:?}",
803 override_line
804 );
805 assert!(
807 !text.contains("LocalFirst")
808 && !text.contains("RemoteFirst")
809 && !text.contains(": Remote\n")
810 && !text.contains(": Local\n"),
811 "text still contains PascalCase strategy name:\n{}",
812 text
813 );
814
815 let json = status.to_json();
816 assert_eq!(json["total_tools"], 4);
817 assert_eq!(json["routable_locally"][0], "get_issues");
818 assert_eq!(json["strategy"], "local-first");
820 assert_eq!(json["override_rules"][0]["strategy"], "remote");
821 }
822
823 #[test]
824 fn test_decide_quiet_does_not_panic_and_matches_decide() {
825 let engine = RoutingEngine::new(
826 ProxyRoutingConfig::default(),
827 report_with(vec![matched("get_issues", true)]),
828 );
829 let a = engine.decide_quiet("get_issues");
830 let b = engine.decide_quiet("get_issues");
831 assert_eq!(a.resolved_name, b.resolved_name);
832 assert_eq!(a.reason, b.reason);
833 }
834}