Skip to main content

traitclaw_team/
conditional_router.rs

1//! `ConditionalRouter` — content-based routing using regex patterns.
2//!
3//! Routes messages to named targets based on their content matching
4//! configurable regex patterns. Falls back to a configurable default target.
5//!
6//! # Example
7//!
8//! ```rust
9//! use traitclaw_team::conditional_router::ConditionalRouter;
10//! use traitclaw_team::router::{Router, TeamMessage, TeamState};
11//!
12//! let router = ConditionalRouter::new()
13//!     .when("search|find|look up", "researcher")
14//!     .when("write|draft|compose", "writer")
15//!     .default("general");
16//!
17//! let state = TeamState::new(vec![
18//!     "researcher".into(), "writer".into(), "general".into(),
19//! ]);
20//!
21//! let msg = TeamMessage::new("user", "Please search for Rust benchmarks");
22//! let decision = router.route(&msg, &state);
23//! // Routes to "researcher" because content contains "search"
24//! ```
25
26use regex::Regex;
27
28use crate::router::{AgentId, Router, RoutingDecision, TeamMessage, TeamState};
29
30/// A routing rule: if message content matches `pattern`, route to `target`.
31struct RoutingRule {
32    pattern: Regex,
33    target: AgentId,
34}
35
36/// Content-based router using regex pattern matching.
37///
38/// Rules are evaluated in order. The first matching pattern wins.
39/// If no pattern matches, routes to the configured default target.
40pub struct ConditionalRouter {
41    rules: Vec<RoutingRule>,
42    default_target: Option<AgentId>,
43}
44
45impl ConditionalRouter {
46    /// Create a new empty `ConditionalRouter`.
47    #[must_use]
48    pub fn new() -> Self {
49        Self {
50            rules: Vec::new(),
51            default_target: None,
52        }
53    }
54
55    /// Add a routing rule: if message content matches `pattern`, route to `target`.
56    ///
57    /// Rules are checked in insertion order. The first match wins.
58    ///
59    /// # Panics
60    ///
61    /// Panics if `pattern` is not a valid regex.
62    #[must_use]
63    pub fn when(mut self, pattern: &str, target: impl Into<AgentId>) -> Self {
64        let re = Regex::new(pattern).unwrap_or_else(|e| panic!("Invalid regex '{pattern}': {e}"));
65        self.rules.push(RoutingRule {
66            pattern: re,
67            target: target.into(),
68        });
69        self
70    }
71
72    /// Set the fallback target when no pattern matches.
73    ///
74    /// If no default is set and no pattern matches, returns a no-op `Complete("")`.
75    #[must_use]
76    pub fn default(mut self, target: impl Into<AgentId>) -> Self {
77        self.default_target = Some(target.into());
78        self
79    }
80}
81
82impl Default for ConditionalRouter {
83    fn default() -> Self {
84        Self::new()
85    }
86}
87
88impl Router for ConditionalRouter {
89    fn route(&self, message: &TeamMessage, _state: &TeamState) -> RoutingDecision {
90        let content = &message.content;
91
92        // Check rules in order
93        for rule in &self.rules {
94            if rule.pattern.is_match(content) {
95                return RoutingDecision::SendTo(rule.target.clone());
96            }
97        }
98
99        // Fallback
100        match &self.default_target {
101            Some(target) => RoutingDecision::SendTo(target.clone()),
102            None => RoutingDecision::Complete(String::new()),
103        }
104    }
105}
106
107#[cfg(test)]
108mod tests {
109    use super::*;
110    use crate::router::{TeamMessage, TeamState};
111
112    fn state() -> TeamState {
113        TeamState::new(vec!["researcher".into(), "writer".into(), "general".into()])
114    }
115
116    #[test]
117    fn test_conditional_router_matches_first_rule() {
118        // AC #9: routes "search" keyword to "researcher"
119        let router = ConditionalRouter::new()
120            .when("search|find|look up", "researcher")
121            .when("write|draft", "writer")
122            .default("general");
123
124        let msg = TeamMessage::new("user", "Please search for Rust benchmarks");
125        let decision = router.route(&msg, &state());
126        assert_eq!(decision, RoutingDecision::SendTo("researcher".into()));
127    }
128
129    #[test]
130    fn test_conditional_router_second_rule_matches() {
131        let router = ConditionalRouter::new()
132            .when("search", "researcher")
133            .when("write|draft", "writer")
134            .default("general");
135
136        let msg = TeamMessage::new("user", "Please draft a blog post");
137        let decision = router.route(&msg, &state());
138        assert_eq!(decision, RoutingDecision::SendTo("writer".into()));
139    }
140
141    #[test]
142    fn test_conditional_router_fallback_to_default() {
143        let router = ConditionalRouter::new()
144            .when("search", "researcher")
145            .default("general");
146
147        let msg = TeamMessage::new("user", "Tell me a joke");
148        let decision = router.route(&msg, &state());
149        assert_eq!(decision, RoutingDecision::SendTo("general".into()));
150    }
151
152    #[test]
153    fn test_conditional_router_no_match_no_default() {
154        let router = ConditionalRouter::new().when("search", "researcher");
155
156        let msg = TeamMessage::new("user", "Tell me a joke");
157        let decision = router.route(&msg, &state());
158        assert_eq!(decision, RoutingDecision::Complete(String::new()));
159    }
160
161    #[test]
162    fn test_conditional_router_case_insensitive_via_regex() {
163        // Regex (?i) prefix for case-insensitive
164        let router = ConditionalRouter::new()
165            .when("(?i)search|(?i)find", "researcher")
166            .default("general");
167
168        let msg = TeamMessage::new("user", "SEARCH for information");
169        let decision = router.route(&msg, &state());
170        assert_eq!(decision, RoutingDecision::SendTo("researcher".into()));
171    }
172
173    #[test]
174    fn test_conditional_router_empty_rules_uses_default() {
175        let router = ConditionalRouter::new().default("general");
176
177        let msg = TeamMessage::new("user", "anything at all");
178        let decision = router.route(&msg, &state());
179        assert_eq!(decision, RoutingDecision::SendTo("general".into()));
180    }
181
182    #[test]
183    fn test_conditional_router_is_router_trait_object() {
184        let r: Box<dyn Router> = Box::new(ConditionalRouter::new().default("fallback"));
185        let msg = TeamMessage::new("user", "test");
186        let decision = r.route(&msg, &state());
187        assert_eq!(decision, RoutingDecision::SendTo("fallback".into()));
188    }
189}