use std::time::Instant;
use devboy_core::config::{ProxyRoutingConfig, RoutingStrategy, routing_strategy_slug};
use serde_json::{Value, json};
use tracing::info;
use crate::signature_match::MatchReport;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RoutingTarget {
Local,
Remote {
prefix: String,
original_name: String,
},
Reject,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RoutingReason {
ExplicitPrefix,
LocalOnly,
RemoteOnly,
StrategyRemote,
StrategyLocal,
StrategyLocalFirst,
StrategyRemoteFirst,
OverrideRule(String),
SchemaIncompatible,
Unknown,
}
impl RoutingReason {
pub fn as_label(&self) -> &str {
match self {
Self::ExplicitPrefix => "explicit_prefix",
Self::LocalOnly => "local_only",
Self::RemoteOnly => "remote_only",
Self::StrategyRemote => "strategy_remote",
Self::StrategyLocal => "strategy_local",
Self::StrategyLocalFirst => "strategy_local_first",
Self::StrategyRemoteFirst => "strategy_remote_first",
Self::OverrideRule(_) => "override_rule",
Self::SchemaIncompatible => "schema_incompatible",
Self::Unknown => "unknown",
}
}
pub fn detail(&self) -> Option<&str> {
match self {
Self::OverrideRule(p) => Some(p.as_str()),
_ => None,
}
}
}
#[derive(Debug, Clone)]
pub struct RoutingDecision {
pub primary: RoutingTarget,
pub fallback: Option<RoutingTarget>,
pub reason: RoutingReason,
pub resolved_name: String,
pub decided_at: Instant,
}
impl RoutingDecision {
pub fn to_meta_json(&self) -> Value {
let target_label = match &self.primary {
RoutingTarget::Local => "local".to_string(),
RoutingTarget::Remote { prefix, .. } => format!("remote:{}", prefix),
RoutingTarget::Reject => "reject".to_string(),
};
let fallback_label = self.fallback.as_ref().map(|t| match t {
RoutingTarget::Local => "local".to_string(),
RoutingTarget::Remote { prefix, .. } => format!("remote:{}", prefix),
RoutingTarget::Reject => "reject".to_string(),
});
let mut obj = json!({
"resolved_name": self.resolved_name,
"target": target_label,
"reason": self.reason.as_label(),
});
if let Some(detail) = self.reason.detail() {
obj["reason_detail"] = Value::String(detail.to_string());
}
if let Some(f) = fallback_label {
obj["fallback"] = Value::String(f);
}
obj
}
pub fn emit_tracing(&self, requested_name: &str) {
let target = match &self.primary {
RoutingTarget::Local => "local".to_string(),
RoutingTarget::Remote { prefix, .. } => format!("remote:{}", prefix),
RoutingTarget::Reject => "reject".to_string(),
};
info!(
tool = requested_name,
resolved = self.resolved_name.as_str(),
target = target.as_str(),
reason = self.reason.as_label(),
reason_detail = self.reason.detail().unwrap_or(""),
has_fallback = self.fallback.is_some(),
"routing decision"
);
}
pub fn local(name: impl Into<String>, reason: RoutingReason) -> Self {
Self {
primary: RoutingTarget::Local,
fallback: None,
reason,
resolved_name: name.into(),
decided_at: Instant::now(),
}
}
pub fn remote(
prefix: impl Into<String>,
original_name: impl Into<String>,
reason: RoutingReason,
) -> Self {
let original = original_name.into();
Self {
primary: RoutingTarget::Remote {
prefix: prefix.into(),
original_name: original.clone(),
},
fallback: None,
reason,
resolved_name: original,
decided_at: Instant::now(),
}
}
pub fn reject(name: impl Into<String>, reason: RoutingReason) -> Self {
Self {
primary: RoutingTarget::Reject,
fallback: None,
reason,
resolved_name: name.into(),
decided_at: Instant::now(),
}
}
pub fn with_fallback(mut self, fallback: RoutingTarget) -> Self {
self.fallback = Some(fallback);
self
}
}
pub struct RoutingEngine {
config: ProxyRoutingConfig,
report: MatchReport,
}
impl RoutingEngine {
pub fn new(config: ProxyRoutingConfig, report: MatchReport) -> Self {
Self { config, report }
}
pub fn decide(&self, requested_name: &str) -> RoutingDecision {
let decision = self.decide_inner(requested_name);
decision.emit_tracing(requested_name);
decision
}
pub fn decide_quiet(&self, requested_name: &str) -> RoutingDecision {
self.decide_inner(requested_name)
}
fn decide_inner(&self, requested_name: &str) -> RoutingDecision {
if let Some((prefix, unprefixed)) = split_prefix(requested_name) {
return RoutingDecision::remote(prefix, unprefixed, RoutingReason::ExplicitPrefix);
}
let match_info = self.report.get(requested_name);
let Some(m) = match_info else {
return RoutingDecision::reject(requested_name, RoutingReason::Unknown);
};
if m.local_present && !m.remote_present {
return RoutingDecision::local(requested_name, RoutingReason::LocalOnly);
}
if m.remote_present && !m.local_present {
let prefix = m.upstream_prefix.clone().unwrap_or_default();
return RoutingDecision::remote(prefix, requested_name, RoutingReason::RemoteOnly);
}
if m.schema_compatible == Some(false) {
let prefix = m.upstream_prefix.clone().unwrap_or_default();
return RoutingDecision::remote(
prefix,
requested_name,
RoutingReason::SchemaIncompatible,
);
}
let (strategy, override_pattern) = self.resolved_strategy_with_override(requested_name);
let base_reason = match override_pattern {
Some(pat) => RoutingReason::OverrideRule(pat),
None => strategy_label(strategy),
};
let local_target = RoutingTarget::Local;
let remote_target = RoutingTarget::Remote {
prefix: m.upstream_prefix.clone().unwrap_or_default(),
original_name: requested_name.to_string(),
};
match strategy {
RoutingStrategy::Remote => RoutingDecision {
primary: remote_target,
fallback: None,
reason: base_reason,
resolved_name: requested_name.to_string(),
decided_at: Instant::now(),
},
RoutingStrategy::Local => RoutingDecision {
primary: local_target,
fallback: None,
reason: base_reason,
resolved_name: requested_name.to_string(),
decided_at: Instant::now(),
},
RoutingStrategy::LocalFirst => RoutingDecision {
primary: local_target,
fallback: if self.config.fallback_on_error {
Some(remote_target)
} else {
None
},
reason: base_reason,
resolved_name: requested_name.to_string(),
decided_at: Instant::now(),
},
RoutingStrategy::RemoteFirst => RoutingDecision {
primary: remote_target,
fallback: if self.config.fallback_on_error {
Some(local_target)
} else {
None
},
reason: base_reason,
resolved_name: requested_name.to_string(),
decided_at: Instant::now(),
},
}
}
pub fn config(&self) -> &ProxyRoutingConfig {
&self.config
}
pub fn report(&self) -> &MatchReport {
&self.report
}
fn resolved_strategy_with_override(
&self,
tool_name: &str,
) -> (RoutingStrategy, Option<String>) {
for rule in &self.config.tool_overrides {
if devboy_core::config::matches_glob(&rule.pattern, tool_name) {
return (rule.strategy, Some(rule.pattern.clone()));
}
}
(self.config.strategy, None)
}
}
fn split_prefix(name: &str) -> Option<(String, String)> {
name.split_once("__")
.map(|(p, t)| (p.to_string(), t.to_string()))
}
fn strategy_label(s: RoutingStrategy) -> RoutingReason {
match s {
RoutingStrategy::Remote => RoutingReason::StrategyRemote,
RoutingStrategy::Local => RoutingReason::StrategyLocal,
RoutingStrategy::LocalFirst => RoutingReason::StrategyLocalFirst,
RoutingStrategy::RemoteFirst => RoutingReason::StrategyRemoteFirst,
}
}
#[derive(Debug, Clone)]
pub struct ProxyStatus {
pub strategy: RoutingStrategy,
pub fallback_on_error: bool,
pub total_tools: usize,
pub routable_locally: Vec<String>,
pub remote_only: Vec<String>,
pub local_only: Vec<String>,
pub incompatible: Vec<IncompatibleTool>,
pub override_rules: Vec<(String, RoutingStrategy)>,
}
#[derive(Debug, Clone)]
pub struct IncompatibleTool {
pub tool: String,
pub upstream_prefix: Option<String>,
pub reason: Option<String>,
}
impl ProxyStatus {
pub fn from_engine(engine: &RoutingEngine) -> Self {
let report = engine.report();
let config = engine.config();
let mut routable_locally: Vec<String> = report
.routable_locally()
.iter()
.map(|m| m.tool_name.clone())
.collect();
routable_locally.sort();
let mut remote_only: Vec<String> = report
.remote_only()
.iter()
.map(|m| m.tool_name.clone())
.collect();
remote_only.sort();
let mut local_only: Vec<String> = report
.local_only()
.iter()
.map(|m| m.tool_name.clone())
.collect();
local_only.sort();
let mut incompatible: Vec<IncompatibleTool> = report
.incompatible_pairs()
.iter()
.map(|m| IncompatibleTool {
tool: m.tool_name.clone(),
upstream_prefix: m.upstream_prefix.clone(),
reason: m.schema_mismatch.clone(),
})
.collect();
incompatible.sort_by(|a, b| a.tool.cmp(&b.tool));
let override_rules: Vec<(String, RoutingStrategy)> = config
.tool_overrides
.iter()
.map(|r| (r.pattern.clone(), r.strategy))
.collect();
Self {
strategy: config.strategy,
fallback_on_error: config.fallback_on_error,
total_tools: report.len(),
routable_locally,
remote_only,
local_only,
incompatible,
override_rules,
}
}
pub fn to_text_report(&self) -> String {
use std::fmt::Write;
let mut out = String::new();
let _ = writeln!(out, "Proxy routing status");
let _ = writeln!(out, "====================");
let _ = writeln!(
out,
"strategy : {}",
routing_strategy_slug(self.strategy)
);
let _ = writeln!(out, "fallback_on_error : {}", self.fallback_on_error);
let _ = writeln!(out, "total_tools : {}", self.total_tools);
let _ = writeln!(out);
let _ = writeln!(out, "Routable locally ({}):", self.routable_locally.len());
for t in &self.routable_locally {
let _ = writeln!(out, " • {}", t);
}
let _ = writeln!(out);
let _ = writeln!(out, "Remote-only ({}):", self.remote_only.len());
for t in &self.remote_only {
let _ = writeln!(out, " • {}", t);
}
let _ = writeln!(out);
let _ = writeln!(out, "Local-only ({}):", self.local_only.len());
for t in &self.local_only {
let _ = writeln!(out, " • {}", t);
}
let _ = writeln!(out);
let _ = writeln!(out, "Schema incompatible ({}):", self.incompatible.len());
for it in &self.incompatible {
let up = it.upstream_prefix.as_deref().unwrap_or("(unknown)");
let reason = it.reason.as_deref().unwrap_or("(no detail)");
let _ = writeln!(out, " • {:<32} via {:<10} — {}", it.tool, up, reason);
}
if !self.override_rules.is_empty() {
let _ = writeln!(out);
let _ = writeln!(out, "Override rules:");
for (pat, strat) in &self.override_rules {
let _ = writeln!(out, " • {:<24} → {}", pat, routing_strategy_slug(*strat));
}
}
out
}
pub fn to_json(&self) -> Value {
json!({
"strategy": routing_strategy_slug(self.strategy),
"fallback_on_error": self.fallback_on_error,
"total_tools": self.total_tools,
"routable_locally": self.routable_locally,
"remote_only": self.remote_only,
"local_only": self.local_only,
"incompatible": self.incompatible.iter().map(|it| json!({
"tool": it.tool,
"upstream_prefix": it.upstream_prefix,
"reason": it.reason,
})).collect::<Vec<_>>(),
"override_rules": self.override_rules.iter().map(|(p, s)| json!({
"pattern": p,
"strategy": routing_strategy_slug(*s),
})).collect::<Vec<_>>(),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::signature_match::ToolMatch;
use devboy_core::config::ProxyToolRule;
fn report_with(entries: Vec<ToolMatch>) -> MatchReport {
let mut report = MatchReport::default();
for e in entries {
report.matches.insert(e.tool_name.clone(), e);
}
report
}
fn matched(name: &str, compatible: bool) -> ToolMatch {
ToolMatch {
tool_name: name.to_string(),
local_present: true,
remote_present: true,
schema_compatible: Some(compatible),
upstream_prefix: Some("cloud".to_string()),
schema_mismatch: None,
}
}
fn local_only(name: &str) -> ToolMatch {
ToolMatch {
tool_name: name.to_string(),
local_present: true,
remote_present: false,
schema_compatible: None,
upstream_prefix: None,
schema_mismatch: None,
}
}
fn remote_only(name: &str, prefix: &str) -> ToolMatch {
ToolMatch {
tool_name: name.to_string(),
local_present: false,
remote_present: true,
schema_compatible: None,
upstream_prefix: Some(prefix.to_string()),
schema_mismatch: None,
}
}
#[test]
fn test_explicit_prefix_forces_remote() {
let engine = RoutingEngine::new(
ProxyRoutingConfig::default(),
report_with(vec![matched("get_issues", true)]),
);
let d = engine.decide("cloud__get_issues");
assert_eq!(
d.primary,
RoutingTarget::Remote {
prefix: "cloud".to_string(),
original_name: "get_issues".to_string(),
}
);
assert_eq!(d.reason, RoutingReason::ExplicitPrefix);
}
#[test]
fn test_unknown_tool_rejects() {
let engine = RoutingEngine::new(ProxyRoutingConfig::default(), MatchReport::default());
let d = engine.decide("mystery_tool");
assert_eq!(d.primary, RoutingTarget::Reject);
assert_eq!(d.reason, RoutingReason::Unknown);
}
#[test]
fn test_local_only_tool_goes_local() {
let engine = RoutingEngine::new(
ProxyRoutingConfig::default(),
report_with(vec![local_only("list_contexts")]),
);
let d = engine.decide("list_contexts");
assert_eq!(d.primary, RoutingTarget::Local);
assert_eq!(d.reason, RoutingReason::LocalOnly);
}
#[test]
fn test_remote_only_tool_goes_remote() {
let engine = RoutingEngine::new(
ProxyRoutingConfig::default(),
report_with(vec![remote_only("cloud_specific", "cloud")]),
);
let d = engine.decide("cloud_specific");
assert_eq!(
d.primary,
RoutingTarget::Remote {
prefix: "cloud".to_string(),
original_name: "cloud_specific".to_string(),
}
);
assert_eq!(d.reason, RoutingReason::RemoteOnly);
}
#[test]
fn test_matched_default_strategy_is_remote() {
let engine = RoutingEngine::new(
ProxyRoutingConfig::default(),
report_with(vec![matched("get_issues", true)]),
);
let d = engine.decide("get_issues");
assert!(matches!(d.primary, RoutingTarget::Remote { .. }));
assert_eq!(d.reason, RoutingReason::StrategyRemote);
assert!(d.fallback.is_none());
}
#[test]
fn test_matched_local_strategy() {
let config = ProxyRoutingConfig {
strategy: RoutingStrategy::Local,
..Default::default()
};
let engine = RoutingEngine::new(config, report_with(vec![matched("get_issues", true)]));
let d = engine.decide("get_issues");
assert_eq!(d.primary, RoutingTarget::Local);
assert_eq!(d.reason, RoutingReason::StrategyLocal);
assert!(d.fallback.is_none());
}
#[test]
fn test_local_first_with_fallback_on_error() {
let config = ProxyRoutingConfig {
strategy: RoutingStrategy::LocalFirst,
fallback_on_error: true,
..Default::default()
};
let engine = RoutingEngine::new(config, report_with(vec![matched("get_issues", true)]));
let d = engine.decide("get_issues");
assert_eq!(d.primary, RoutingTarget::Local);
assert_eq!(d.reason, RoutingReason::StrategyLocalFirst);
assert_eq!(
d.fallback,
Some(RoutingTarget::Remote {
prefix: "cloud".to_string(),
original_name: "get_issues".to_string(),
})
);
}
#[test]
fn test_remote_first_with_fallback() {
let config = ProxyRoutingConfig {
strategy: RoutingStrategy::RemoteFirst,
fallback_on_error: true,
..Default::default()
};
let engine = RoutingEngine::new(config, report_with(vec![matched("get_issues", true)]));
let d = engine.decide("get_issues");
assert!(matches!(d.primary, RoutingTarget::Remote { .. }));
assert_eq!(d.fallback, Some(RoutingTarget::Local));
assert_eq!(d.reason, RoutingReason::StrategyRemoteFirst);
}
#[test]
fn test_local_first_no_fallback_when_disabled() {
let config = ProxyRoutingConfig {
strategy: RoutingStrategy::LocalFirst,
fallback_on_error: false,
..Default::default()
};
let engine = RoutingEngine::new(config, report_with(vec![matched("get_issues", true)]));
let d = engine.decide("get_issues");
assert!(d.fallback.is_none());
}
#[test]
fn test_incompatible_schema_forces_remote() {
let config = ProxyRoutingConfig {
strategy: RoutingStrategy::LocalFirst,
fallback_on_error: true,
..Default::default()
};
let engine = RoutingEngine::new(config, report_with(vec![matched("get_issues", false)]));
let d = engine.decide("get_issues");
assert!(matches!(d.primary, RoutingTarget::Remote { .. }));
assert_eq!(d.reason, RoutingReason::SchemaIncompatible);
assert!(d.fallback.is_none());
}
#[test]
fn test_per_tool_override_wins_over_global_strategy() {
let config = ProxyRoutingConfig {
strategy: RoutingStrategy::Remote,
fallback_on_error: true,
tool_overrides: vec![ProxyToolRule {
pattern: "get_*".to_string(),
strategy: RoutingStrategy::Local,
}],
};
let engine = RoutingEngine::new(
config,
report_with(vec![
matched("get_issues", true),
matched("create_issue", true),
]),
);
let d_get = engine.decide("get_issues");
assert_eq!(d_get.primary, RoutingTarget::Local);
match &d_get.reason {
RoutingReason::OverrideRule(p) => assert_eq!(p, "get_*"),
other => panic!("expected OverrideRule, got {:?}", other),
}
let d_create = engine.decide("create_issue");
assert!(matches!(d_create.primary, RoutingTarget::Remote { .. }));
assert_eq!(d_create.reason, RoutingReason::StrategyRemote);
}
#[test]
fn test_decision_label_roundtrip() {
assert_eq!(RoutingReason::ExplicitPrefix.as_label(), "explicit_prefix");
assert_eq!(
RoutingReason::OverrideRule("get_*".to_string()).as_label(),
"override_rule"
);
assert_eq!(
RoutingReason::OverrideRule("get_*".to_string()).detail(),
Some("get_*")
);
}
#[test]
fn test_to_meta_json_shapes() {
let d = RoutingDecision::local("get_issues", RoutingReason::StrategyLocal);
let v = d.to_meta_json();
assert_eq!(v["target"], "local");
assert_eq!(v["reason"], "strategy_local");
assert!(v.get("fallback").is_none());
let d = RoutingDecision::remote(
"cloud",
"get_issues",
RoutingReason::OverrideRule("get_*".to_string()),
)
.with_fallback(RoutingTarget::Local);
let v = d.to_meta_json();
assert_eq!(v["target"], "remote:cloud");
assert_eq!(v["reason"], "override_rule");
assert_eq!(v["reason_detail"], "get_*");
assert_eq!(v["fallback"], "local");
}
#[test]
fn test_proxy_status_summary() {
let config = ProxyRoutingConfig {
strategy: RoutingStrategy::LocalFirst,
fallback_on_error: true,
tool_overrides: vec![ProxyToolRule {
pattern: "create_*".to_string(),
strategy: RoutingStrategy::Remote,
}],
};
let engine = RoutingEngine::new(
config,
report_with(vec![
matched("get_issues", true),
matched("update_issue", false),
local_only("list_contexts"),
remote_only("cloud_only", "cloud"),
]),
);
let status = ProxyStatus::from_engine(&engine);
assert_eq!(status.strategy, RoutingStrategy::LocalFirst);
assert_eq!(status.total_tools, 4);
assert_eq!(status.routable_locally, vec!["get_issues".to_string()]);
assert_eq!(status.remote_only, vec!["cloud_only".to_string()]);
assert_eq!(status.local_only, vec!["list_contexts".to_string()]);
assert_eq!(status.incompatible.len(), 1);
assert_eq!(status.incompatible[0].tool, "update_issue");
assert_eq!(status.override_rules.len(), 1);
let text = status.to_text_report();
assert!(text.contains("Routable locally (1):"));
assert!(text.contains("get_issues"));
assert!(
text.contains("strategy : local-first"),
"missing kebab-case strategy line in:\n{}",
text
);
let override_line = text
.lines()
.find(|l| l.contains("create_*"))
.expect("override rule line present");
assert!(
override_line.ends_with("→ remote"),
"override rule must end with '→ remote' (kebab-case), got: {:?}",
override_line
);
assert!(
!text.contains("LocalFirst")
&& !text.contains("RemoteFirst")
&& !text.contains(": Remote\n")
&& !text.contains(": Local\n"),
"text still contains PascalCase strategy name:\n{}",
text
);
let json = status.to_json();
assert_eq!(json["total_tools"], 4);
assert_eq!(json["routable_locally"][0], "get_issues");
assert_eq!(json["strategy"], "local-first");
assert_eq!(json["override_rules"][0]["strategy"], "remote");
}
#[test]
fn test_decide_quiet_does_not_panic_and_matches_decide() {
let engine = RoutingEngine::new(
ProxyRoutingConfig::default(),
report_with(vec![matched("get_issues", true)]),
);
let a = engine.decide_quiet("get_issues");
let b = engine.decide_quiet("get_issues");
assert_eq!(a.resolved_name, b.resolved_name);
assert_eq!(a.reason, b.reason);
}
}