Skip to main content

agent_teams/backend/
router.rs

1//! Dynamic backend routing — select the optimal backend for a given task.
2//!
3//! The [`BackendRouter`] trait decides which [`BackendType`] to use for a given
4//! [`SpawnConfig`], based on keyword heuristics, cost, or any custom logic.
5
6use std::collections::HashMap;
7
8use async_trait::async_trait;
9
10use super::{BackendType, SpawnConfig};
11
12/// A router that selects the best backend for a given spawn configuration.
13///
14/// Implementations can use keyword matching, cost estimation, model capability
15/// databases, or even LLM-based classification to make the decision.
16#[async_trait]
17pub trait BackendRouter: Send + Sync {
18    /// Choose the optimal backend for the given config.
19    ///
20    /// `available` lists the backends that the orchestrator has registered.
21    /// Returns `None` if no suitable backend can be found (e.g., `available` is
22    /// empty or all backends are filtered out by requirements).
23    async fn route(
24        &self,
25        config: &SpawnConfig,
26        available: &[BackendType],
27    ) -> Option<BackendType>;
28}
29
30// ---------------------------------------------------------------------------
31// KeywordRouter
32// ---------------------------------------------------------------------------
33
34/// A keyword-based router with configurable rules and a default fallback.
35///
36/// Rules map keyword patterns (matched case-insensitively against the prompt)
37/// to a preferred [`BackendType`]. The first matching rule wins. If no rules
38/// match, the `default` backend is used.
39///
40/// By default, keywords are matched as substrings. Enable [`word_boundary`](Self::word_boundary)
41/// to require that keywords appear as whole words (e.g., "test" won't match "testing").
42///
43/// # Example
44///
45/// ```rust
46/// use agent_teams::backend::router::KeywordRouter;
47/// use agent_teams::BackendType;
48///
49/// let router = KeywordRouter::new(BackendType::ClaudeCode)
50///     .word_boundary(true)
51///     .rule("review", BackendType::GeminiCli)
52///     .rule("analyze", BackendType::GeminiCli)
53///     .rule("implement", BackendType::ClaudeCode)
54///     .rule("test", BackendType::Codex);
55/// ```
56#[derive(Debug)]
57pub struct KeywordRouter {
58    rules: Vec<(String, BackendType)>,
59    default: BackendType,
60    /// When true, keywords must appear as whole words (bounded by non-alphanumeric chars).
61    use_word_boundary: bool,
62}
63
64impl KeywordRouter {
65    /// Create a new keyword router with a default backend.
66    pub fn new(default: BackendType) -> Self {
67        Self {
68            rules: Vec::new(),
69            default,
70            use_word_boundary: false,
71        }
72    }
73
74    /// Enable or disable word-boundary matching.
75    ///
76    /// When enabled, "test" matches "test this code" but NOT "testing this code".
77    /// Default is `false` (substring matching) for backward compatibility.
78    pub fn word_boundary(mut self, enable: bool) -> Self {
79        self.use_word_boundary = enable;
80        self
81    }
82
83    /// Add a keyword → backend rule. Keywords are matched case-insensitively
84    /// against the `SpawnConfig::prompt` field.
85    pub fn rule(mut self, keyword: impl Into<String>, backend: BackendType) -> Self {
86        self.rules.push((keyword.into().to_lowercase(), backend));
87        self
88    }
89
90    /// Add multiple rules from an iterator of `(keyword, backend)` pairs.
91    pub fn rules(mut self, rules: impl IntoIterator<Item = (String, BackendType)>) -> Self {
92        for (kw, bt) in rules {
93            self.rules.push((kw.to_lowercase(), bt));
94        }
95        self
96    }
97}
98
99/// Check if `text` contains `word` as a whole word (bounded by non-alphanumeric characters).
100///
101/// Both `text` and `word` must be valid UTF-8 (guaranteed by `&str`). The boundary
102/// check uses [`u8::is_ascii_alphanumeric`], so non-ASCII characters are always
103/// treated as word boundaries — this is intentional for prompt-level keyword matching.
104fn contains_word(text: &str, word: &str) -> bool {
105    if word.is_empty() || text.len() < word.len() {
106        return false;
107    }
108    let text_bytes = text.as_bytes();
109    let mut start = 0;
110    while start + word.len() <= text.len() {
111        match text[start..].find(word) {
112            Some(pos) => {
113                let abs_pos = start + pos;
114                let before_ok =
115                    abs_pos == 0 || !text_bytes[abs_pos - 1].is_ascii_alphanumeric();
116                let after_pos = abs_pos + word.len();
117                let after_ok =
118                    after_pos >= text.len() || !text_bytes[after_pos].is_ascii_alphanumeric();
119                if before_ok && after_ok {
120                    return true;
121                }
122                // Advance past the current match start to the next character boundary.
123                // Using `abs_pos + word.len()` is safe (word is a valid &str so its
124                // byte length always lands on a UTF-8 boundary) and also more efficient
125                // since a shorter match starting within the current word cannot exist.
126                start = abs_pos + word.len();
127            }
128            None => break,
129        }
130    }
131    false
132}
133
134#[async_trait]
135impl BackendRouter for KeywordRouter {
136    async fn route(
137        &self,
138        config: &SpawnConfig,
139        available: &[BackendType],
140    ) -> Option<BackendType> {
141        if available.is_empty() {
142            return None;
143        }
144
145        let prompt_lower = config.prompt.to_lowercase();
146
147        // Find the first matching rule whose backend is available
148        for (keyword, backend) in &self.rules {
149            let matched = if self.use_word_boundary {
150                contains_word(&prompt_lower, keyword.as_str())
151            } else {
152                prompt_lower.contains(keyword.as_str())
153            };
154            if matched && available.contains(backend) {
155                return Some(*backend);
156            }
157        }
158
159        // Fall back to default if available, otherwise first available
160        if available.contains(&self.default) {
161            Some(self.default)
162        } else {
163            available.first().copied()
164        }
165    }
166}
167
168// ---------------------------------------------------------------------------
169// CapabilityRouter
170// ---------------------------------------------------------------------------
171
172/// Backend capabilities for making routing decisions.
173///
174/// Default values represent a generic mid-tier backend (multi-turn, streaming,
175/// cost=2, latency=2). Use [`BackendCapability::defaults()`] for pre-configured
176/// values for known backends.
177#[derive(Debug, Clone)]
178pub struct BackendCapability {
179    /// Supports multi-turn conversations.
180    pub multi_turn: bool,
181    /// Supports streaming output.
182    pub streaming: bool,
183    /// Relative cost (lower is cheaper). Unitless; used for comparison only.
184    pub cost_tier: u8,
185    /// Relative latency (lower is faster). Unitless; used for comparison only.
186    pub latency_tier: u8,
187}
188
189impl Default for BackendCapability {
190    fn default() -> Self {
191        Self {
192            multi_turn: true,
193            streaming: true,
194            cost_tier: 2,
195            latency_tier: 2,
196        }
197    }
198}
199
200impl BackendCapability {
201    /// Default capabilities for known backends.
202    pub fn defaults() -> HashMap<BackendType, BackendCapability> {
203        let mut map = HashMap::new();
204        map.insert(BackendType::ClaudeCode, BackendCapability {
205            multi_turn: true,
206            streaming: true,
207            cost_tier: 3,   // most capable, highest cost
208            latency_tier: 2,
209        });
210        map.insert(BackendType::Codex, BackendCapability {
211            multi_turn: true,
212            streaming: true,
213            cost_tier: 2,
214            latency_tier: 2,
215        });
216        map.insert(BackendType::GeminiCli, BackendCapability {
217            multi_turn: false, // one-shot process per turn
218            streaming: true,
219            cost_tier: 1,     // cheapest for simple tasks
220            latency_tier: 3,  // CLI startup overhead
221        });
222        map
223    }
224}
225
226/// A capability-aware router that selects backends based on task requirements.
227///
228/// Scores each available backend on a weighted combination of cost and latency,
229/// with optional requirement filters (e.g., must support multi-turn).
230#[derive(Debug)]
231pub struct CapabilityRouter {
232    capabilities: HashMap<BackendType, BackendCapability>,
233    /// If true, only consider multi-turn backends.
234    require_multi_turn: bool,
235    /// Weight for cost (0.0–1.0). Remainder goes to latency.
236    cost_weight: f32,
237}
238
239impl Default for CapabilityRouter {
240    fn default() -> Self {
241        Self::new()
242    }
243}
244
245impl CapabilityRouter {
246    /// Create a capability router with default capability data.
247    pub fn new() -> Self {
248        Self {
249            capabilities: BackendCapability::defaults(),
250            require_multi_turn: false,
251            cost_weight: 0.5,
252        }
253    }
254
255    /// Only route to backends that support multi-turn conversations.
256    pub fn require_multi_turn(mut self, require: bool) -> Self {
257        self.require_multi_turn = require;
258        self
259    }
260
261    /// Set the cost vs latency weight (0.0 = pure latency, 1.0 = pure cost).
262    pub fn cost_weight(mut self, weight: f32) -> Self {
263        self.cost_weight = weight.clamp(0.0, 1.0);
264        self
265    }
266
267    /// Override capability data for a backend.
268    pub fn with_capability(mut self, backend: BackendType, cap: BackendCapability) -> Self {
269        self.capabilities.insert(backend, cap);
270        self
271    }
272}
273
274#[async_trait]
275impl BackendRouter for CapabilityRouter {
276    async fn route(
277        &self,
278        _config: &SpawnConfig,
279        available: &[BackendType],
280    ) -> Option<BackendType> {
281        let latency_weight = 1.0 - self.cost_weight;
282
283        // Score each candidate; backends without capability entries get a neutral default
284        available
285            .iter()
286            .filter(|bt| {
287                if let Some(cap) = self.capabilities.get(bt) {
288                    !self.require_multi_turn || cap.multi_turn
289                } else {
290                    // Unknown backends are included with neutral score unless multi-turn is required
291                    !self.require_multi_turn
292                }
293            })
294            .min_by(|a, b| {
295                let score = |bt: &BackendType| -> f32 {
296                    if let Some(cap) = self.capabilities.get(bt) {
297                        self.cost_weight * cap.cost_tier as f32
298                            + latency_weight * cap.latency_tier as f32
299                    } else {
300                        // Neutral score for unknown backends (middle tier)
301                        self.cost_weight * 2.0 + latency_weight * 2.0
302                    }
303                };
304                score(a)
305                    .partial_cmp(&score(b))
306                    .unwrap_or(std::cmp::Ordering::Equal)
307            })
308            .copied()
309    }
310}
311
312// ---------------------------------------------------------------------------
313// ChainRouter
314// ---------------------------------------------------------------------------
315
316/// A composite router that tries multiple routers in sequence.
317///
318/// The first router to return `Some(backend)` wins. If all routers return
319/// `None`, the chain returns `None`.
320///
321/// This is useful for combining a precise [`KeywordRouter`] with a broader
322/// [`CapabilityRouter`] as a fallback:
323///
324/// ```rust
325/// use agent_teams::backend::router::{ChainRouter, KeywordRouter, CapabilityRouter};
326/// use agent_teams::BackendType;
327///
328/// let router = ChainRouter::new()
329///     .push(KeywordRouter::new(BackendType::ClaudeCode)
330///         .word_boundary(true)
331///         .rule("review", BackendType::GeminiCli))
332///     .push(CapabilityRouter::new().cost_weight(0.7));
333/// ```
334pub struct ChainRouter {
335    routers: Vec<Box<dyn BackendRouter>>,
336}
337
338impl std::fmt::Debug for ChainRouter {
339    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
340        f.debug_struct("ChainRouter")
341            .field("routers_count", &self.routers.len())
342            .finish()
343    }
344}
345
346impl ChainRouter {
347    /// Create an empty chain.
348    pub fn new() -> Self {
349        Self {
350            routers: Vec::new(),
351        }
352    }
353
354    /// Append a router to the chain.
355    pub fn push(mut self, router: impl BackendRouter + 'static) -> Self {
356        self.routers.push(Box::new(router));
357        self
358    }
359}
360
361impl Default for ChainRouter {
362    fn default() -> Self {
363        Self::new()
364    }
365}
366
367#[async_trait]
368impl BackendRouter for ChainRouter {
369    async fn route(
370        &self,
371        config: &SpawnConfig,
372        available: &[BackendType],
373    ) -> Option<BackendType> {
374        for router in &self.routers {
375            if let Some(bt) = router.route(config, available).await {
376                return Some(bt);
377            }
378        }
379        None
380    }
381}
382
383// ---------------------------------------------------------------------------
384// SmartRouter
385// ---------------------------------------------------------------------------
386
387/// Prompt complexity level inferred from heuristic analysis.
388#[derive(Debug, Clone, Copy, PartialEq, Eq)]
389pub enum PromptComplexity {
390    /// Short, simple prompts (single sentence, no code).
391    Simple,
392    /// Medium-length prompts or those containing code snippets.
393    Medium,
394    /// Long, multi-paragraph prompts with significant code or technical depth.
395    Complex,
396}
397
398/// A smart router that combines keyword matching, capability scoring, and
399/// prompt complexity analysis to select the optimal backend.
400///
401/// **Routing strategy** (evaluated in order):
402///
403/// 1. **Priority overrides** — explicit `(keyword, backend)` pairs checked first.
404///    If a keyword matches the prompt and the backend is available, it wins immediately.
405///
406/// 2. **Prompt complexity analysis** — the prompt is scored for length, code presence,
407///    and technical depth. Each complexity level maps to a preferred backend.
408///
409/// 3. **Capability fallback** — an embedded [`CapabilityRouter`] scores remaining
410///    candidates on cost/latency when neither keywords nor complexity produce a match.
411///
412/// # Example
413///
414/// ```rust
415/// use agent_teams::backend::router::SmartRouter;
416/// use agent_teams::BackendType;
417///
418/// let router = SmartRouter::new(BackendType::ClaudeCode)
419///     .priority("security audit", BackendType::ClaudeCode)
420///     .priority("quick fix", BackendType::Codex)
421///     .simple_backend(BackendType::GeminiCli)
422///     .complex_backend(BackendType::ClaudeCode)
423///     .complexity_threshold(200, 800)
424///     .cost_weight(0.6);
425/// ```
426#[derive(Debug)]
427pub struct SmartRouter {
428    /// Priority keyword overrides (checked first, word-boundary matching).
429    priorities: Vec<(String, BackendType)>,
430    /// Backend preferred for simple prompts.
431    simple: Option<BackendType>,
432    /// Backend preferred for medium-complexity prompts.
433    medium: Option<BackendType>,
434    /// Backend preferred for complex prompts.
435    complex: Option<BackendType>,
436    /// Character threshold: prompts shorter than this are `Simple`.
437    simple_threshold: usize,
438    /// Character threshold: prompts longer than this are `Complex`.
439    complex_threshold: usize,
440    /// Capability-based fallback router.
441    capability: CapabilityRouter,
442    /// Default backend (used when nothing else matches).
443    default: BackendType,
444}
445
446impl SmartRouter {
447    /// Create a new smart router with a default backend.
448    pub fn new(default: BackendType) -> Self {
449        Self {
450            priorities: Vec::new(),
451            simple: None,
452            medium: None,
453            complex: None,
454            simple_threshold: 200,
455            complex_threshold: 800,
456            capability: CapabilityRouter::new(),
457            default,
458        }
459    }
460
461    /// Add a priority keyword override.
462    ///
463    /// Priority keywords use word-boundary matching (same semantics as
464    /// [`KeywordRouter::word_boundary(true)`]). The first matching priority wins.
465    pub fn priority(mut self, keyword: impl Into<String>, backend: BackendType) -> Self {
466        self.priorities.push((keyword.into().to_lowercase(), backend));
467        self
468    }
469
470    /// Set the preferred backend for simple (short, non-technical) prompts.
471    pub fn simple_backend(mut self, backend: BackendType) -> Self {
472        self.simple = Some(backend);
473        self
474    }
475
476    /// Set the preferred backend for medium-complexity prompts.
477    pub fn medium_backend(mut self, backend: BackendType) -> Self {
478        self.medium = Some(backend);
479        self
480    }
481
482    /// Set the preferred backend for complex (long, code-heavy) prompts.
483    pub fn complex_backend(mut self, backend: BackendType) -> Self {
484        self.complex = Some(backend);
485        self
486    }
487
488    /// Set the character-length thresholds for complexity classification.
489    ///
490    /// - Prompts shorter than `simple` characters are `Simple`.
491    /// - Prompts longer than `complex` characters are `Complex`.
492    /// - Everything in between is `Medium`.
493    ///
494    /// Defaults: `simple = 200`, `complex = 800`.
495    ///
496    /// # Panics
497    ///
498    /// Panics in debug mode if `simple >= complex`.
499    pub fn complexity_threshold(mut self, simple: usize, complex: usize) -> Self {
500        debug_assert!(simple < complex, "simple threshold must be less than complex threshold");
501        self.simple_threshold = simple;
502        self.complex_threshold = complex;
503        self
504    }
505
506    /// Set the cost vs latency weight for the capability fallback (0.0–1.0).
507    pub fn cost_weight(mut self, weight: f32) -> Self {
508        self.capability = self.capability.cost_weight(weight);
509        self
510    }
511
512    /// Require multi-turn support in the capability fallback.
513    pub fn require_multi_turn(mut self, require: bool) -> Self {
514        self.capability = self.capability.require_multi_turn(require);
515        self
516    }
517
518    /// Override capability data for a backend in the fallback scorer.
519    pub fn with_capability(mut self, backend: BackendType, cap: BackendCapability) -> Self {
520        self.capability = self.capability.with_capability(backend, cap);
521        self
522    }
523
524    /// Analyze a prompt and return its complexity level.
525    pub fn analyze_complexity(&self, prompt: &str) -> PromptComplexity {
526        let len = prompt.len();
527
528        // Code indicators: triple-backtick blocks, `fn `, `def `, `class `, `impl `,
529        // `struct `, `import `, `#include`, common code patterns
530        let has_code = prompt.contains("```")
531            || prompt.contains("fn ")
532            || prompt.contains("def ")
533            || prompt.contains("class ")
534            || prompt.contains("impl ")
535            || prompt.contains("struct ")
536            || prompt.contains("import ")
537            || prompt.contains("#include")
538            || prompt.contains("function ")
539            || prompt.contains("async fn");
540
541        // Multi-paragraph indicator
542        let paragraph_count = prompt.split("\n\n").count();
543
544        if len >= self.complex_threshold || (has_code && len >= self.simple_threshold) || paragraph_count >= 4 {
545            PromptComplexity::Complex
546        } else if len >= self.simple_threshold || has_code || paragraph_count >= 2 {
547            PromptComplexity::Medium
548        } else {
549            PromptComplexity::Simple
550        }
551    }
552}
553
554#[async_trait]
555impl BackendRouter for SmartRouter {
556    async fn route(
557        &self,
558        config: &SpawnConfig,
559        available: &[BackendType],
560    ) -> Option<BackendType> {
561        if available.is_empty() {
562            return None;
563        }
564
565        let prompt_lower = config.prompt.to_lowercase();
566
567        // Phase 1: Priority keyword overrides (word-boundary matching)
568        for (keyword, backend) in &self.priorities {
569            if contains_word(&prompt_lower, keyword.as_str()) && available.contains(backend) {
570                return Some(*backend);
571            }
572        }
573
574        // Phase 2: Prompt complexity routing
575        let complexity = self.analyze_complexity(&config.prompt);
576        let preferred = match complexity {
577            PromptComplexity::Simple => self.simple,
578            PromptComplexity::Medium => self.medium,
579            PromptComplexity::Complex => self.complex,
580        };
581        if let Some(backend) = preferred {
582            if available.contains(&backend) {
583                return Some(backend);
584            }
585        }
586
587        // Phase 3: Capability-based fallback
588        if let Some(bt) = self.capability.route(config, available).await {
589            return Some(bt);
590        }
591
592        // Phase 4: Default or first available
593        if available.contains(&self.default) {
594            Some(self.default)
595        } else {
596            available.first().copied()
597        }
598    }
599}
600
601// ---------------------------------------------------------------------------
602// Tests
603// ---------------------------------------------------------------------------
604
605#[cfg(test)]
606mod tests {
607    use super::*;
608
609    #[tokio::test]
610    async fn keyword_router_matches_first_rule() {
611        let router = KeywordRouter::new(BackendType::ClaudeCode)
612            .rule("review", BackendType::GeminiCli)
613            .rule("implement", BackendType::ClaudeCode)
614            .rule("test", BackendType::Codex);
615
616        let config = SpawnConfig::new("reviewer", "Please review this code carefully.");
617        let available = vec![BackendType::ClaudeCode, BackendType::Codex, BackendType::GeminiCli];
618
619        let result = router.route(&config, &available).await;
620        assert_eq!(result, Some(BackendType::GeminiCli));
621    }
622
623    #[tokio::test]
624    async fn keyword_router_falls_back_to_default() {
625        let router = KeywordRouter::new(BackendType::Codex)
626            .rule("review", BackendType::GeminiCli);
627
628        let config = SpawnConfig::new("worker", "Do something unrelated.");
629        let available = vec![BackendType::ClaudeCode, BackendType::Codex];
630
631        let result = router.route(&config, &available).await;
632        assert_eq!(result, Some(BackendType::Codex));
633    }
634
635    #[tokio::test]
636    async fn keyword_router_skips_unavailable_backend() {
637        let router = KeywordRouter::new(BackendType::ClaudeCode)
638            .rule("review", BackendType::GeminiCli);
639
640        let config = SpawnConfig::new("reviewer", "Please review this code.");
641        // GeminiCli not available
642        let available = vec![BackendType::ClaudeCode, BackendType::Codex];
643
644        let result = router.route(&config, &available).await;
645        // Should fall through to default since matched backend not available
646        assert_eq!(result, Some(BackendType::ClaudeCode));
647    }
648
649    #[tokio::test]
650    async fn keyword_router_case_insensitive() {
651        let router = KeywordRouter::new(BackendType::ClaudeCode)
652            .rule("REVIEW", BackendType::GeminiCli);
653
654        let config = SpawnConfig::new("reviewer", "review this code");
655        let available = vec![BackendType::ClaudeCode, BackendType::GeminiCli];
656
657        let result = router.route(&config, &available).await;
658        assert_eq!(result, Some(BackendType::GeminiCli));
659    }
660
661    #[tokio::test]
662    async fn keyword_router_returns_none_for_empty_available() {
663        let router = KeywordRouter::new(BackendType::ClaudeCode);
664        let config = SpawnConfig::new("worker", "Do something.");
665        let result = router.route(&config, &[]).await;
666        assert_eq!(result, None);
667    }
668
669    #[tokio::test]
670    async fn capability_router_prefers_cheapest() {
671        let router = CapabilityRouter::new()
672            .cost_weight(1.0); // pure cost optimization
673
674        let config = SpawnConfig::new("worker", "Do a simple task.");
675        let available = vec![BackendType::ClaudeCode, BackendType::Codex, BackendType::GeminiCli];
676
677        let result = router.route(&config, &available).await;
678        assert_eq!(result, Some(BackendType::GeminiCli)); // cost_tier=1
679    }
680
681    #[tokio::test]
682    async fn capability_router_multi_turn_requirement() {
683        let router = CapabilityRouter::new()
684            .require_multi_turn(true)
685            .cost_weight(1.0); // cheapest multi-turn
686
687        let config = SpawnConfig::new("worker", "Multi-turn session.");
688        let available = vec![BackendType::ClaudeCode, BackendType::Codex, BackendType::GeminiCli];
689
690        let result = router.route(&config, &available).await;
691        // GeminiCli excluded (not multi-turn), Codex is cheaper than ClaudeCode
692        assert_eq!(result, Some(BackendType::Codex));
693    }
694
695    #[tokio::test]
696    async fn capability_router_prefers_fastest() {
697        let router = CapabilityRouter::new()
698            .cost_weight(0.0); // pure latency optimization
699
700        let config = SpawnConfig::new("worker", "Quick task.");
701        let available = vec![BackendType::ClaudeCode, BackendType::Codex, BackendType::GeminiCli];
702
703        let result = router.route(&config, &available).await;
704        // ClaudeCode & Codex both latency_tier=2, ClaudeCode wins (first in available list)
705        assert_eq!(result, Some(BackendType::ClaudeCode));
706    }
707
708    #[tokio::test]
709    async fn capability_router_returns_none_for_empty_available() {
710        let router = CapabilityRouter::new();
711        let config = SpawnConfig::new("worker", "Do something.");
712        let result = router.route(&config, &[]).await;
713        assert_eq!(result, None);
714    }
715
716    // -----------------------------------------------------------------------
717    // Word boundary tests
718    // -----------------------------------------------------------------------
719
720    #[test]
721    fn contains_word_exact_match() {
722        assert!(super::contains_word("review this code", "review"));
723        assert!(super::contains_word("please review", "review"));
724        assert!(super::contains_word("review", "review"));
725    }
726
727    #[test]
728    fn contains_word_rejects_substring() {
729        assert!(!super::contains_word("reviewing this code", "review"));
730        assert!(!super::contains_word("code reviewer", "review"));
731        assert!(!super::contains_word("prereview step", "review"));
732    }
733
734    #[test]
735    fn contains_word_with_punctuation() {
736        assert!(super::contains_word("please review, thanks", "review"));
737        assert!(super::contains_word("review.", "review"));
738        assert!(super::contains_word("(review)", "review"));
739    }
740
741    #[test]
742    fn contains_word_test_vs_testing() {
743        assert!(super::contains_word("test this function", "test"));
744        assert!(!super::contains_word("testing this function", "test"));
745        assert!(!super::contains_word("run the contest", "test"));
746    }
747
748    #[test]
749    fn contains_word_edge_cases() {
750        // Empty text / empty word
751        assert!(!super::contains_word("", "review"));
752        assert!(!super::contains_word("some text", ""));
753        assert!(!super::contains_word("", ""));
754
755        // Single character word
756        assert!(super::contains_word("a quick task", "a"));
757        assert!(!super::contains_word("analyze this", "a"));
758
759        // Word at very end
760        assert!(super::contains_word("do a test", "test"));
761
762        // Multiple occurrences, only later one is a word
763        assert!(super::contains_word("testing the test", "test"));
764
765        // Hyphenated context (hyphen is not alphanumeric → boundary)
766        assert!(super::contains_word("run unit-test now", "test"));
767
768        // UTF-8 multi-byte: boundary check must not panic
769        // "café test" — 'é' is 2 bytes (0xC3 0xA9), acts as word boundary
770        assert!(super::contains_word("café test", "test"));
771        assert!(super::contains_word("café", "café"));
772        // 'é' before keyword — boundary check on multi-byte char must not panic
773        assert!(!super::contains_word("acafé", "café"));
774        // Non-ASCII word embedded with ASCII neighbors — ensure no mid-char slicing
775        assert!(super::contains_word("aéb test éb", "éb"));
776    }
777
778    #[tokio::test]
779    async fn keyword_router_word_boundary_mode() {
780        let router = KeywordRouter::new(BackendType::ClaudeCode)
781            .word_boundary(true)
782            .rule("test", BackendType::Codex);
783
784        let available = vec![BackendType::ClaudeCode, BackendType::Codex];
785
786        // "test" as a whole word — should match
787        let config = SpawnConfig::new("w", "test this function");
788        assert_eq!(router.route(&config, &available).await, Some(BackendType::Codex));
789
790        // "testing" contains "test" as substring but not as word — should NOT match
791        let config = SpawnConfig::new("w", "testing this function");
792        assert_eq!(router.route(&config, &available).await, Some(BackendType::ClaudeCode));
793    }
794
795    // -----------------------------------------------------------------------
796    // ChainRouter tests
797    // -----------------------------------------------------------------------
798
799    #[tokio::test]
800    async fn chain_router_first_match_wins() {
801        let keyword = KeywordRouter::new(BackendType::ClaudeCode)
802            .rule("review", BackendType::GeminiCli);
803        let capability = CapabilityRouter::new().cost_weight(1.0);
804
805        let router = ChainRouter::new().push(keyword).push(capability);
806
807        let config = SpawnConfig::new("w", "review this code");
808        let available = vec![BackendType::ClaudeCode, BackendType::Codex, BackendType::GeminiCli];
809
810        // Keyword router matches "review" → GeminiCli
811        assert_eq!(router.route(&config, &available).await, Some(BackendType::GeminiCli));
812    }
813
814    #[tokio::test]
815    async fn chain_router_falls_through_to_second() {
816        let keyword = KeywordRouter::new(BackendType::ClaudeCode)
817            .word_boundary(true)
818            .rule("review", BackendType::GeminiCli);
819        let capability = CapabilityRouter::new().cost_weight(1.0);
820
821        let router = ChainRouter::new().push(keyword).push(capability);
822
823        // No keyword match → falls through to CapabilityRouter → cheapest = GeminiCli
824        let config = SpawnConfig::new("w", "do a simple task");
825        let available = vec![BackendType::ClaudeCode, BackendType::Codex, BackendType::GeminiCli];
826
827        // KeywordRouter default is ClaudeCode which IS available, so it returns ClaudeCode
828        // Actually, KeywordRouter falls back to its default which is ClaudeCode
829        // So the chain stops at the first router that returns Some
830        let result = router.route(&config, &available).await;
831        assert_eq!(result, Some(BackendType::ClaudeCode));
832    }
833
834    #[tokio::test]
835    async fn chain_router_empty_returns_none() {
836        let router = ChainRouter::new();
837        let config = SpawnConfig::new("w", "anything");
838        let result = router.route(&config, &[BackendType::ClaudeCode]).await;
839        assert_eq!(result, None);
840    }
841
842    // -----------------------------------------------------------------------
843    // BackendCapability + Debug tests
844    // -----------------------------------------------------------------------
845
846    #[test]
847    fn backend_capability_default() {
848        let cap = BackendCapability::default();
849        assert!(cap.multi_turn);
850        assert!(cap.streaming);
851        assert_eq!(cap.cost_tier, 2);
852        assert_eq!(cap.latency_tier, 2);
853    }
854
855    #[test]
856    fn capability_router_with_custom_capability() {
857        let cap = BackendCapability {
858            cost_tier: 1,
859            ..Default::default()
860        };
861        assert!(cap.multi_turn);   // from default
862        assert_eq!(cap.cost_tier, 1); // overridden
863    }
864
865    #[test]
866    fn routers_are_debug() {
867        let kw = KeywordRouter::new(BackendType::ClaudeCode).rule("test", BackendType::Codex);
868        assert!(format!("{kw:?}").contains("KeywordRouter"));
869
870        let cap = CapabilityRouter::new();
871        assert!(format!("{cap:?}").contains("CapabilityRouter"));
872
873        let chain = ChainRouter::new().push(KeywordRouter::new(BackendType::ClaudeCode));
874        let debug = format!("{chain:?}");
875        assert!(debug.contains("ChainRouter"));
876        assert!(debug.contains("routers_count"));
877
878        let smart = SmartRouter::new(BackendType::ClaudeCode);
879        assert!(format!("{smart:?}").contains("SmartRouter"));
880    }
881
882    // -----------------------------------------------------------------------
883    // SmartRouter tests
884    // -----------------------------------------------------------------------
885
886    #[test]
887    fn smart_router_complexity_simple() {
888        let router = SmartRouter::new(BackendType::ClaudeCode);
889        let complexity = router.analyze_complexity("Fix the bug.");
890        assert_eq!(complexity, PromptComplexity::Simple);
891    }
892
893    #[test]
894    fn smart_router_complexity_medium_by_length() {
895        let router = SmartRouter::new(BackendType::ClaudeCode);
896        let prompt = "a".repeat(250); // > 200 chars
897        let complexity = router.analyze_complexity(&prompt);
898        assert_eq!(complexity, PromptComplexity::Medium);
899    }
900
901    #[test]
902    fn smart_router_complexity_medium_by_code() {
903        let router = SmartRouter::new(BackendType::ClaudeCode);
904        let complexity = router.analyze_complexity("Please add fn main() here");
905        assert_eq!(complexity, PromptComplexity::Medium);
906    }
907
908    #[test]
909    fn smart_router_complexity_complex_by_length() {
910        let router = SmartRouter::new(BackendType::ClaudeCode);
911        let prompt = "a".repeat(900); // > 800 chars
912        let complexity = router.analyze_complexity(&prompt);
913        assert_eq!(complexity, PromptComplexity::Complex);
914    }
915
916    #[test]
917    fn smart_router_complexity_complex_by_code_and_length() {
918        let router = SmartRouter::new(BackendType::ClaudeCode);
919        // Has code indicators AND >= simple_threshold
920        let prompt = format!("Please implement this:\n```rust\nfn main() {{}}\n```\n{}", "x".repeat(200));
921        let complexity = router.analyze_complexity(&prompt);
922        assert_eq!(complexity, PromptComplexity::Complex);
923    }
924
925    #[test]
926    fn smart_router_complexity_complex_by_paragraphs() {
927        let router = SmartRouter::new(BackendType::ClaudeCode);
928        let prompt = "First paragraph.\n\nSecond paragraph.\n\nThird paragraph.\n\nFourth paragraph.";
929        let complexity = router.analyze_complexity(prompt);
930        assert_eq!(complexity, PromptComplexity::Complex);
931    }
932
933    #[tokio::test]
934    async fn smart_router_priority_wins() {
935        let router = SmartRouter::new(BackendType::Codex)
936            .priority("security audit", BackendType::ClaudeCode)
937            .simple_backend(BackendType::GeminiCli);
938
939        let config = SpawnConfig::new("w", "Run a security audit on this module");
940        let available = vec![BackendType::ClaudeCode, BackendType::Codex, BackendType::GeminiCli];
941
942        // Priority "security audit" matches → ClaudeCode
943        assert_eq!(router.route(&config, &available).await, Some(BackendType::ClaudeCode));
944    }
945
946    #[tokio::test]
947    async fn smart_router_complexity_routing_simple() {
948        let router = SmartRouter::new(BackendType::Codex)
949            .simple_backend(BackendType::GeminiCli);
950
951        let config = SpawnConfig::new("w", "Fix the typo.");
952        let available = vec![BackendType::ClaudeCode, BackendType::Codex, BackendType::GeminiCli];
953
954        // Short prompt → Simple → GeminiCli
955        assert_eq!(router.route(&config, &available).await, Some(BackendType::GeminiCli));
956    }
957
958    #[tokio::test]
959    async fn smart_router_complexity_routing_complex() {
960        let router = SmartRouter::new(BackendType::Codex)
961            .complex_backend(BackendType::ClaudeCode);
962
963        let long_prompt = "a".repeat(900);
964        let config = SpawnConfig::new("w", &long_prompt);
965        let available = vec![BackendType::ClaudeCode, BackendType::Codex, BackendType::GeminiCli];
966
967        // Long prompt → Complex → ClaudeCode
968        assert_eq!(router.route(&config, &available).await, Some(BackendType::ClaudeCode));
969    }
970
971    #[tokio::test]
972    async fn smart_router_falls_back_to_capability() {
973        // No priorities, no complexity mappings → falls through to capability router
974        let router = SmartRouter::new(BackendType::Codex)
975            .cost_weight(1.0); // cheapest wins
976
977        let config = SpawnConfig::new("w", "Do a task.");
978        let available = vec![BackendType::ClaudeCode, BackendType::Codex, BackendType::GeminiCli];
979
980        // CapabilityRouter with cost_weight=1.0 picks cheapest → GeminiCli (cost_tier=1)
981        assert_eq!(router.route(&config, &available).await, Some(BackendType::GeminiCli));
982    }
983
984    #[tokio::test]
985    async fn smart_router_priority_skips_unavailable() {
986        let router = SmartRouter::new(BackendType::Codex)
987            .priority("audit", BackendType::ClaudeCode)
988            .simple_backend(BackendType::GeminiCli);
989
990        let config = SpawnConfig::new("w", "Run an audit");
991        // ClaudeCode NOT available
992        let available = vec![BackendType::Codex, BackendType::GeminiCli];
993
994        // Priority matches but ClaudeCode unavailable → falls to complexity (Simple) → GeminiCli
995        assert_eq!(router.route(&config, &available).await, Some(BackendType::GeminiCli));
996    }
997
998    #[tokio::test]
999    async fn smart_router_returns_none_for_empty() {
1000        let router = SmartRouter::new(BackendType::ClaudeCode);
1001        let config = SpawnConfig::new("w", "anything");
1002        assert_eq!(router.route(&config, &[]).await, None);
1003    }
1004
1005    #[tokio::test]
1006    async fn smart_router_default_fallback() {
1007        // No priorities, no complexity backends, capability returns None
1008        // (this shouldn't normally happen, but test the default path)
1009        let router = SmartRouter::new(BackendType::Codex);
1010
1011        let config = SpawnConfig::new("w", "Short task.");
1012        let available = vec![BackendType::Codex];
1013
1014        // No simple_backend set, capability picks Codex (only option), or default
1015        assert_eq!(router.route(&config, &available).await, Some(BackendType::Codex));
1016    }
1017
1018    #[test]
1019    fn smart_router_custom_thresholds() {
1020        let router = SmartRouter::new(BackendType::ClaudeCode)
1021            .complexity_threshold(50, 300);
1022
1023        // 60 chars = medium (>50, <300)
1024        assert_eq!(router.analyze_complexity(&"a".repeat(60)), PromptComplexity::Medium);
1025        // 30 chars = simple (<50)
1026        assert_eq!(router.analyze_complexity(&"a".repeat(30)), PromptComplexity::Simple);
1027        // 400 chars = complex (>300)
1028        assert_eq!(router.analyze_complexity(&"a".repeat(400)), PromptComplexity::Complex);
1029    }
1030}