Skip to main content

devboy_mcp/
routing.rs

1//! Routing engine — decides which executor should handle a tool call.
2//!
3//! The engine combines three inputs:
4//!
5//! - Global + per-server [`ProxyRoutingConfig`] (strategy, fallback, per-tool overrides)
6//! - A [`MatchReport`] describing which tools have both a local and a remote implementation
7//! - The raw tool name requested by the client
8//!
9//! Out of these it produces a [`RoutingDecision`]: a primary executor target, an optional
10//! fallback target for the error path, and a human-readable reason string for observability.
11//!
12//! # Name conventions
13//!
14//! Clients can address tools two ways:
15//!
16//! - **Unprefixed** (`get_issues`) — routing policy applies normally.
17//! - **Prefixed** (`cloud__get_issues`) — the prefix is an explicit "send to that upstream"
18//!   override. The engine honours it even when the strategy would otherwise pick Local.
19//!
20//! # Cloud priority
21//!
22//! The default [`RoutingStrategy`] is `Remote`. Local routing is never synthesized: it must
23//! be explicitly enabled per tool, per server, or globally. When the schemas disagree,
24//! matched tools degrade back to Remote automatically.
25
26use 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/// Concrete executor target for a single call.
35#[derive(Debug, Clone, PartialEq, Eq)]
36pub enum RoutingTarget {
37    /// Dispatch to the in-process local `ToolHandler`.
38    Local,
39    /// Dispatch to the upstream proxy identified by `prefix`, using `original_name`
40    /// as the unprefixed tool name forwarded to that upstream.
41    Remote {
42        prefix: String,
43        original_name: String,
44    },
45    /// Neither executor can handle the tool. The caller should reject with a clear error.
46    Reject,
47}
48
49/// Short reason label attached to every decision — surfaced in logs and `_meta` responses.
50#[derive(Debug, Clone, PartialEq, Eq)]
51pub enum RoutingReason {
52    /// Explicit prefix in the tool name override.
53    ExplicitPrefix,
54    /// No upstream advertises this tool; local-only execution.
55    LocalOnly,
56    /// No local counterpart exists; must go upstream.
57    RemoteOnly,
58    /// Local and upstream both advertise the tool and schemas match.
59    StrategyRemote,
60    StrategyLocal,
61    StrategyLocalFirst,
62    StrategyRemoteFirst,
63    OverrideRule(String),
64    /// Schemas disagree; forced back to remote.
65    SchemaIncompatible,
66    /// Tool name is completely unknown to both sides.
67    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/// Final routing plan for one invocation.
95#[derive(Debug, Clone)]
96pub struct RoutingDecision {
97    /// Executor to try first.
98    pub primary: RoutingTarget,
99    /// Executor to retry on error. `None` when no fallback applies.
100    pub fallback: Option<RoutingTarget>,
101    /// Why this decision was made — included in telemetry and debug logs.
102    pub reason: RoutingReason,
103    /// The resolved unprefixed tool name after stripping any explicit prefix.
104    pub resolved_name: String,
105    /// Moment the decision was made. Useful downstream for timing analysis.
106    pub decided_at: Instant,
107}
108
109impl RoutingDecision {
110    /// Produce a `_meta.routing` JSON blob suitable for attaching to an MCP response.
111    /// Consumers pack this under `_meta` so downstream tooling (CLI debug output, logs)
112    /// can inspect the decision without parsing free-form text.
113    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    /// Structured tracing log describing this decision. Emitted once per invocation at
139    /// info level so operators can grep for routing outcomes without enabling debug.
140    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
201/// Routes tool calls based on config + match report.
202///
203/// Immutable after construction: callers rebuild the engine whenever the match report
204/// changes (e.g., upstream reconnect).
205pub 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    /// Resolve the executor for the given tool name (with or without prefix).
216    /// Emits a `tracing::info` event with structured fields for every call.
217    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    /// Same as [`Self::decide`], but does not emit a tracing event. Useful from tests or
224    /// from diagnostic commands that render the decision themselves.
225    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        // 1. Explicit prefix → always remote.
231        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        // 2. Unknown to both sides.
238        let Some(m) = match_info else {
239            return RoutingDecision::reject(requested_name, RoutingReason::Unknown);
240        };
241
242        // 3. One-sided: trivial routing.
243        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        // 4. Matched pair — consult strategy, with schema-compat as a hard gate.
252        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    /// Expose the underlying config for observability / debug commands.
315    pub fn config(&self) -> &ProxyRoutingConfig {
316        &self.config
317    }
318
319    /// Expose the match report for observability / debug commands.
320    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
337/// If `name` contains `__`, split at the first occurrence; the left side is the upstream
338/// prefix, the right side is the unprefixed tool name. Otherwise returns `None`.
339fn 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/// Human-friendly summary of the current routing state. Used by the `proxy status` CLI
354/// command and any dashboard that wants to inspect how the proxy is configured.
355#[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/// One row in the "schema disagrees" table.
368#[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    /// Compute a status snapshot from the engine's current config + match report.
377    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    /// Render a plain-text report suitable for CLI output.
432    ///
433    /// The strategy is formatted via [`routing_strategy_slug`] (kebab-case),
434    /// consistently with `to_json()` and TOML serialization — the user sees the same
435    /// spelling in every output format.
436    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    /// JSON form — convenient for machine-readable CLI output (`--json` flag).
482    ///
483    /// Strategy values are serialized in the same kebab-case form as in the TOML
484    /// config (`local-first`, not `LocalFirst`); otherwise `proxy status --json | jq`
485    /// clients would have to deal with two spellings of the same value.
486    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        // Strategy and override rules render in kebab-case, symmetric with JSON/TOML.
790        assert!(
791            text.contains("strategy           : local-first"),
792            "missing kebab-case strategy line in:\n{}",
793            text
794        );
795        // Override rule line exists and uses kebab-case target (whitespace is cosmetic).
796        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        // Ensure PascalCase enum debug forms are gone.
806        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        // Strategy must be kebab-case (matches serde/TOML form), not PascalCase `Debug`.
819        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}