1use crate::HookError;
12use orcs_types::ComponentId;
13use serde::{Deserialize, Serialize};
14use std::fmt;
15
16#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
18pub enum PatternSegment {
19 Exact(String),
21 Wildcard,
23}
24
25impl PatternSegment {
26 #[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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
56pub struct FqlPattern {
57 pub scope: PatternSegment,
59 pub target: PatternSegment,
61 pub child_path: Option<PatternSegment>,
63 pub instance: Option<PatternSegment>,
65}
66
67impl FqlPattern {
68 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 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 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 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 #[must_use]
134 pub fn matches(&self, component_id: &ComponentId, child_id: Option<&str>) -> bool {
135 let fqn = component_id.fqn();
136
137 let (namespace, name) = match fqn.find("::") {
139 Some(pos) => (&fqn[..pos], &fqn[pos + 2..]),
140 None => return false,
141 };
142
143 if !self.scope.matches(namespace) || !self.target.matches(name) {
145 return false;
146 }
147
148 match (&self.child_path, child_id) {
150 (Some(pattern), Some(id)) => pattern.matches(id),
151 (Some(_), None) => false, (None, _) => true, }
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
170fn 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 #[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 #[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 #[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 #[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 #[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}