Skip to main content

orcs_hook/
fql.rs

1//! FQL (Fully Qualified Locator) pattern matching.
2//!
3//! FQL patterns address components in the ORCS hierarchy:
4//!
5//! ```text
6//! FQL := <scope>::<target> [ "/" <child_path> ] [ "#" <instance> ]
7//! ```
8//!
9//! Matches against `ComponentId::fqn()` which returns `"namespace::name"`.
10
11use crate::HookError;
12use orcs_types::ComponentId;
13use serde::{Deserialize, Serialize};
14use std::fmt;
15
16/// A single segment in an FQL pattern.
17#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
18pub enum PatternSegment {
19    /// Matches exactly the given string.
20    Exact(String),
21    /// Matches any string.
22    Wildcard,
23}
24
25impl PatternSegment {
26    /// Returns `true` if this segment matches the given value.
27    #[must_use]
28    pub fn matches(&self, value: &str) -> bool {
29        match self {
30            Self::Exact(s) => s == value,
31            Self::Wildcard => true,
32        }
33    }
34}
35
36impl fmt::Display for PatternSegment {
37    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
38        match self {
39            Self::Exact(s) => f.write_str(s),
40            Self::Wildcard => f.write_str("*"),
41        }
42    }
43}
44
45/// A parsed FQL pattern for matching components and children.
46///
47/// # Examples
48///
49/// ```text
50/// "builtin::llm"        → scope=Exact("builtin"), target=Exact("llm")
51/// "*::*"                 → scope=Wildcard, target=Wildcard
52/// "builtin::llm/agent-1" → + child_path=Exact("agent-1")
53/// "builtin::llm/*"      → + child_path=Wildcard
54/// ```
55#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
56pub struct FqlPattern {
57    /// Namespace scope (e.g., "builtin", "plugin", "*").
58    pub scope: PatternSegment,
59    /// Component name (e.g., "llm", "hil", "*").
60    pub target: PatternSegment,
61    /// Optional child path (e.g., "agent-1", "*").
62    pub child_path: Option<PatternSegment>,
63    /// Optional instance qualifier (e.g., "primary", "0").
64    pub instance: Option<PatternSegment>,
65}
66
67impl FqlPattern {
68    /// Parses an FQL pattern string.
69    ///
70    /// # Format
71    ///
72    /// ```text
73    /// <scope>::<target>[/<child_path>][#<instance>]
74    /// ```
75    ///
76    /// # Errors
77    ///
78    /// Returns `HookError::InvalidFql` if the string cannot be parsed.
79    pub fn parse(fql: &str) -> Result<Self, HookError> {
80        if fql.is_empty() {
81            return Err(HookError::InvalidFql("empty FQL pattern".into()));
82        }
83
84        // Split instance qualifier first: "foo::bar#inst" → "foo::bar", "inst"
85        let (main_part, instance) = if let Some(hash_pos) = fql.rfind('#') {
86            let inst = &fql[hash_pos + 1..];
87            if inst.is_empty() {
88                return Err(HookError::InvalidFql("empty instance qualifier".into()));
89            }
90            (&fql[..hash_pos], Some(parse_segment(inst)))
91        } else {
92            (fql, None)
93        };
94
95        // Split child path: "foo::bar/child" → "foo::bar", "child"
96        let (component_part, child_path) = if let Some(slash_pos) = main_part.find('/') {
97            let scope_target = &main_part[..slash_pos];
98            let child = &main_part[slash_pos + 1..];
99            if child.is_empty() {
100                return Err(HookError::InvalidFql("empty child path".into()));
101            }
102            (scope_target, Some(parse_segment(child)))
103        } else {
104            (main_part, None)
105        };
106
107        // Split scope::target
108        let sep_pos = component_part
109            .find("::")
110            .ok_or_else(|| HookError::InvalidFql(format!("missing '::' separator in '{fql}'")))?;
111
112        let scope_str = &component_part[..sep_pos];
113        let target_str = &component_part[sep_pos + 2..];
114
115        if scope_str.is_empty() {
116            return Err(HookError::InvalidFql("empty scope".into()));
117        }
118        if target_str.is_empty() {
119            return Err(HookError::InvalidFql("empty target".into()));
120        }
121
122        Ok(Self {
123            scope: parse_segment(scope_str),
124            target: parse_segment(target_str),
125            child_path,
126            instance,
127        })
128    }
129
130    /// Returns `true` if this pattern matches the given component and optional child.
131    ///
132    /// Matches against `component_id.fqn()` which returns `"namespace::name"`.
133    #[must_use]
134    pub fn matches(&self, component_id: &ComponentId, child_id: Option<&str>) -> bool {
135        let fqn = component_id.fqn();
136
137        // Parse fqn into namespace::name
138        let (namespace, name) = match fqn.find("::") {
139            Some(pos) => (&fqn[..pos], &fqn[pos + 2..]),
140            None => return false,
141        };
142
143        // Check scope and target
144        if !self.scope.matches(namespace) || !self.target.matches(name) {
145            return false;
146        }
147
148        // Check child path
149        match (&self.child_path, child_id) {
150            (Some(pattern), Some(id)) => pattern.matches(id),
151            (Some(_), None) => false, // Pattern requires child but none given
152            (None, _) => true,        // No child pattern → matches regardless
153        }
154    }
155}
156
157impl fmt::Display for FqlPattern {
158    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
159        write!(f, "{}::{}", self.scope, self.target)?;
160        if let Some(ref child) = self.child_path {
161            write!(f, "/{child}")?;
162        }
163        if let Some(ref inst) = self.instance {
164            write!(f, "#{inst}")?;
165        }
166        Ok(())
167    }
168}
169
170/// Parses a single pattern segment: `"*"` → Wildcard, anything else → Exact.
171fn parse_segment(s: &str) -> PatternSegment {
172    if s == "*" {
173        PatternSegment::Wildcard
174    } else {
175        PatternSegment::Exact(s.to_string())
176    }
177}
178
179#[cfg(test)]
180mod tests {
181    use super::*;
182
183    // ── Parsing ──────────────────────────────────────────────
184
185    #[test]
186    fn parse_exact_match() {
187        let p = FqlPattern::parse("builtin::llm")
188            .expect("exact FQL 'builtin::llm' should parse successfully");
189        assert_eq!(p.scope, PatternSegment::Exact("builtin".into()));
190        assert_eq!(p.target, PatternSegment::Exact("llm".into()));
191        assert_eq!(p.child_path, None);
192        assert_eq!(p.instance, None);
193    }
194
195    #[test]
196    fn parse_full_wildcard() {
197        let p = FqlPattern::parse("*::*").expect("wildcard FQL '*::*' should parse successfully");
198        assert_eq!(p.scope, PatternSegment::Wildcard);
199        assert_eq!(p.target, PatternSegment::Wildcard);
200    }
201
202    #[test]
203    fn parse_scope_wildcard() {
204        let p = FqlPattern::parse("*::llm")
205            .expect("scope-wildcard FQL '*::llm' should parse successfully");
206        assert_eq!(p.scope, PatternSegment::Wildcard);
207        assert_eq!(p.target, PatternSegment::Exact("llm".into()));
208    }
209
210    #[test]
211    fn parse_target_wildcard() {
212        let p = FqlPattern::parse("builtin::*")
213            .expect("target-wildcard FQL 'builtin::*' should parse successfully");
214        assert_eq!(p.scope, PatternSegment::Exact("builtin".into()));
215        assert_eq!(p.target, PatternSegment::Wildcard);
216    }
217
218    #[test]
219    fn parse_with_child_path() {
220        let p = FqlPattern::parse("builtin::llm/agent-1")
221            .expect("FQL with child path should parse successfully");
222        assert_eq!(p.target, PatternSegment::Exact("llm".into()));
223        assert_eq!(p.child_path, Some(PatternSegment::Exact("agent-1".into())));
224    }
225
226    #[test]
227    fn parse_with_child_wildcard() {
228        let p = FqlPattern::parse("builtin::llm/*")
229            .expect("FQL with child wildcard should parse successfully");
230        assert_eq!(p.child_path, Some(PatternSegment::Wildcard));
231    }
232
233    #[test]
234    fn parse_with_instance() {
235        let p = FqlPattern::parse("lua::custom#primary")
236            .expect("FQL with instance qualifier should parse successfully");
237        assert_eq!(p.instance, Some(PatternSegment::Exact("primary".into())));
238    }
239
240    #[test]
241    fn parse_full_pattern() {
242        let p = FqlPattern::parse("builtin::llm/agent-1#0")
243            .expect("full FQL pattern with child and instance should parse successfully");
244        assert_eq!(p.scope, PatternSegment::Exact("builtin".into()));
245        assert_eq!(p.target, PatternSegment::Exact("llm".into()));
246        assert_eq!(p.child_path, Some(PatternSegment::Exact("agent-1".into())));
247        assert_eq!(p.instance, Some(PatternSegment::Exact("0".into())));
248    }
249
250    // ── Parse errors ─────────────────────────────────────────
251
252    #[test]
253    fn parse_empty_string() {
254        assert!(FqlPattern::parse("").is_err());
255    }
256
257    #[test]
258    fn parse_missing_separator() {
259        assert!(FqlPattern::parse("builtin_llm").is_err());
260    }
261
262    #[test]
263    fn parse_empty_scope() {
264        assert!(FqlPattern::parse("::llm").is_err());
265    }
266
267    #[test]
268    fn parse_empty_target() {
269        assert!(FqlPattern::parse("builtin::").is_err());
270    }
271
272    #[test]
273    fn parse_empty_child_path() {
274        assert!(FqlPattern::parse("builtin::llm/").is_err());
275    }
276
277    #[test]
278    fn parse_empty_instance() {
279        assert!(FqlPattern::parse("builtin::llm#").is_err());
280    }
281
282    // ── Matching ─────────────────────────────────────────────
283
284    #[test]
285    fn match_exact() {
286        let p = FqlPattern::parse("builtin::llm")
287            .expect("FQL 'builtin::llm' should parse for matching test");
288        let id = ComponentId::builtin("llm");
289        assert!(p.matches(&id, None));
290    }
291
292    #[test]
293    fn match_exact_no_match() {
294        let p = FqlPattern::parse("builtin::hil")
295            .expect("FQL 'builtin::hil' should parse for non-match test");
296        let id = ComponentId::builtin("llm");
297        assert!(!p.matches(&id, None));
298    }
299
300    #[test]
301    fn match_wildcard_scope() {
302        let p =
303            FqlPattern::parse("*::llm").expect("scope-wildcard FQL should parse for matching test");
304        let id = ComponentId::builtin("llm");
305        assert!(p.matches(&id, None));
306    }
307
308    #[test]
309    fn match_wildcard_target() {
310        let p = FqlPattern::parse("builtin::*")
311            .expect("target-wildcard FQL should parse for matching test");
312        let id_llm = ComponentId::builtin("llm");
313        let id_hil = ComponentId::builtin("hil");
314        assert!(p.matches(&id_llm, None));
315        assert!(p.matches(&id_hil, None));
316    }
317
318    #[test]
319    fn match_full_wildcard() {
320        let p =
321            FqlPattern::parse("*::*").expect("full wildcard FQL should parse for matching test");
322        let id = ComponentId::builtin("anything");
323        assert!(p.matches(&id, None));
324    }
325
326    #[test]
327    fn match_child_exact() {
328        let p = FqlPattern::parse("builtin::llm/agent-1")
329            .expect("FQL with exact child path should parse for matching test");
330        let id = ComponentId::builtin("llm");
331        assert!(p.matches(&id, Some("agent-1")));
332        assert!(!p.matches(&id, Some("agent-2")));
333        assert!(!p.matches(&id, None));
334    }
335
336    #[test]
337    fn match_child_wildcard() {
338        let p = FqlPattern::parse("builtin::llm/*")
339            .expect("FQL with child wildcard should parse for matching test");
340        let id = ComponentId::builtin("llm");
341        assert!(p.matches(&id, Some("agent-1")));
342        assert!(p.matches(&id, Some("any-child")));
343        assert!(!p.matches(&id, None));
344    }
345
346    #[test]
347    fn match_no_child_pattern_accepts_any_child() {
348        let p = FqlPattern::parse("builtin::llm")
349            .expect("FQL without child path should parse for any-child matching test");
350        let id = ComponentId::builtin("llm");
351        assert!(p.matches(&id, None));
352        assert!(p.matches(&id, Some("agent-1")));
353    }
354
355    #[test]
356    fn match_plugin_namespace() {
357        let p = FqlPattern::parse("plugin::my-tool")
358            .expect("FQL with plugin namespace should parse for matching test");
359        let id = ComponentId::new("plugin", "my-tool");
360        assert!(p.matches(&id, None));
361        assert!(!p.matches(&ComponentId::builtin("my-tool"), None));
362    }
363
364    // ── Display ──────────────────────────────────────────────
365
366    #[test]
367    fn display_roundtrip() {
368        let patterns = [
369            "builtin::llm",
370            "*::*",
371            "builtin::*",
372            "*::llm",
373            "builtin::llm/agent-1",
374            "builtin::llm/*",
375            "lua::custom#primary",
376            "builtin::llm/agent-1#0",
377        ];
378        for &s in &patterns {
379            let p =
380                FqlPattern::parse(s).expect("FQL pattern should parse for display roundtrip test");
381            assert_eq!(p.to_string(), s, "display roundtrip failed for {s}");
382        }
383    }
384
385    // ── Serde ────────────────────────────────────────────────
386
387    #[test]
388    fn serde_roundtrip() {
389        let p = FqlPattern::parse("builtin::llm/agent-1#0")
390            .expect("full FQL pattern should parse for serde roundtrip");
391        let json = serde_json::to_string(&p).expect("FqlPattern should serialize to JSON");
392        let restored: FqlPattern =
393            serde_json::from_str(&json).expect("FqlPattern should deserialize from JSON");
394        assert_eq!(p, restored);
395    }
396}