1use crate::schema::{
6 GenericsMatch, MatchAttrs, NameMatcher, NameMatcherDetailed, Query, QueryKind, ReceiverKind,
7 Scope, Visibility,
8};
9use ryo_analysis::{DiscoveryQuery, Pattern, SymbolKind, TypeFilter};
10use thiserror::Error;
11
12#[derive(Debug, Error)]
14pub enum ConvertError {
15 #[error("invalid regex pattern: {0}")]
17 InvalidRegex(String),
18
19 #[error("unsupported query kind for conversion: {kind:?}")]
21 UnsupportedKind {
22 kind: QueryKind,
24 },
25
26 #[error("Pattern query requires 'name' field")]
28 PatternNameRequired,
29
30 #[error("Or/And query requires 'queries' field")]
32 QueriesRequired,
33}
34
35pub struct QueryConverter;
37
38impl QueryConverter {
39 pub fn to_discovery_query(query: &Query) -> Result<ConversionResult, ConvertError> {
51 match query.kind {
52 QueryKind::Or | QueryKind::And => {
53 if query.queries.is_empty() {
55 return Err(ConvertError::QueriesRequired);
56 }
57 let sub_results: Result<Vec<_>, _> =
58 query.queries.iter().map(Self::to_discovery_query).collect();
59 Ok(ConversionResult {
60 discovery_query: None,
61 composite: Some(CompositeQuery {
62 op: if query.kind == QueryKind::Or {
63 CompositeOp::Or
64 } else {
65 CompositeOp::And
66 },
67 queries: sub_results?,
68 }),
69 post_filters: vec![],
70 unsupported: vec![],
71 })
72 }
73 QueryKind::Pattern => {
74 let name = query
77 .name
78 .as_ref()
79 .ok_or(ConvertError::PatternNameRequired)?;
80 Ok(ConversionResult {
81 discovery_query: Some(DiscoveryQuery::exact(name)),
82 composite: None,
83 post_filters: vec![PostFilter::PatternSearch(name.clone())],
84 unsupported: vec!["Pattern search (requires pattern registry)".to_string()],
85 })
86 }
87 _ => Self::convert_simple_query(query),
88 }
89 }
90
91 fn convert_simple_query(query: &Query) -> Result<ConversionResult, ConvertError> {
93 let mut unsupported = Vec::new();
94 let mut post_filters = Vec::new();
95
96 let pattern = Self::convert_name_matcher(query.r#match.as_ref())?;
98
99 let mut dq = match pattern {
101 Some(p) => DiscoveryQuery::symbol(p.as_str()),
102 None => DiscoveryQuery::symbol("*"),
103 };
104
105 if let Some(kinds) = Self::convert_kind(query.kind) {
107 dq = dq.kinds(kinds);
108 }
109
110 if let Some(ref scope) = query.scope {
112 dq = Self::apply_scope(dq, scope, &mut post_filters, &mut unsupported);
113 }
114
115 if let Some(limit) = query.limit {
117 dq = dq.limit(limit);
118 }
119
120 if !query.inner.is_empty() {
123 Self::collect_inner_post_filters(&query.inner, &mut post_filters)?;
124 }
125
126 let mut type_filter: Option<TypeFilter> = None;
129
130 if let Some(ref attrs) = query.r#match {
132 if let Some(ref generics) = attrs.generics {
133 if let Some(ref bounds) = generics.bounds {
134 if let Some(bound_pattern) = Self::convert_bounds_to_pattern(bounds)? {
135 let filter = type_filter.get_or_insert_with(TypeFilter::default);
136 filter.has_bound = Some(bound_pattern);
137 }
138 }
139 }
140 }
141
142 if let Some(filter) = type_filter {
144 dq = dq.with_type_filter(filter);
145 }
146
147 if let Some(ref attrs) = query.r#match {
149 Self::collect_post_filters(attrs, &mut post_filters, &mut unsupported);
150 }
151
152 if let Some(ref body) = query.body {
154 post_filters.push(PostFilter::BodyMatch(body.clone()));
155 }
156
157 if let Some(ref relations) = query.relations {
159 if !relations.is_empty() {
160 post_filters.push(PostFilter::Relations(relations.clone()));
161 }
162 }
163
164 Ok(ConversionResult {
168 discovery_query: Some(dq),
169 composite: None,
170 post_filters,
171 unsupported,
172 })
173 }
174
175 fn convert_name_matcher(attrs: Option<&MatchAttrs>) -> Result<Option<Pattern>, ConvertError> {
177 let attrs = match attrs {
178 Some(a) => a,
179 None => return Ok(None),
180 };
181
182 if let Some(ref name) = attrs.name {
184 let pattern = match name {
185 NameMatcher::Exact(s) => Pattern::exact(s),
186 NameMatcher::Detailed(d) => Self::convert_detailed_matcher(d)?,
187 };
188 return Ok(Some(pattern));
189 }
190
191 if let Some(ref pattern_str) = attrs.pattern {
193 return Ok(Some(Pattern::glob(pattern_str)));
194 }
195
196 Ok(None)
197 }
198
199 fn convert_detailed_matcher(d: &NameMatcherDetailed) -> Result<Pattern, ConvertError> {
201 if let Some(ref regex) = d.regex {
203 return Pattern::regex(regex).map_err(|e| ConvertError::InvalidRegex(e.to_string()));
204 }
205
206 if let Some(ref glob) = d.glob {
207 return Ok(Pattern::glob(glob));
208 }
209
210 if let Some(ref contains) = d.contains {
211 return Ok(Pattern::glob(format!("*{}*", contains)));
212 }
213
214 if let Some(ref starts) = d.starts_with {
215 return Ok(Pattern::glob(format!("{}*", starts)));
216 }
217
218 if let Some(ref ends) = d.ends_with {
219 return Ok(Pattern::glob(format!("*{}", ends)));
220 }
221
222 Ok(Pattern::glob("*"))
224 }
225
226 fn convert_kind(kind: QueryKind) -> Option<Vec<SymbolKind>> {
228 match kind {
229 QueryKind::Any => None,
231 QueryKind::Function => Some(vec![SymbolKind::Function]),
232 QueryKind::Struct => Some(vec![SymbolKind::Struct]),
233 QueryKind::Enum => Some(vec![SymbolKind::Enum]),
234 QueryKind::Trait => Some(vec![SymbolKind::Trait]),
235 QueryKind::Impl => Some(vec![SymbolKind::Impl]),
236 QueryKind::Mod => Some(vec![SymbolKind::Mod]),
237 QueryKind::Const => Some(vec![SymbolKind::Const]),
238 QueryKind::Static => Some(vec![SymbolKind::Static]),
239 QueryKind::TypeAlias => Some(vec![SymbolKind::TypeAlias]),
240 QueryKind::ReturnType
242 | QueryKind::Parameter
243 | QueryKind::Field
244 | QueryKind::Variant
245 | QueryKind::Or
246 | QueryKind::And
247 | QueryKind::Pattern => None,
248 QueryKind::Literal => None,
250 }
251 }
252
253 fn apply_scope(
255 mut dq: DiscoveryQuery,
256 scope: &Scope,
257 post_filters: &mut Vec<PostFilter>,
258 _unsupported: &mut Vec<String>,
259 ) -> DiscoveryQuery {
260 if let Some(ref module) = scope.module {
262 dq = dq.in_module(module);
263 }
264
265 if let Some(ref path) = scope.path {
269 post_filters.push(PostFilter::PathInclude(path.clone()));
270 }
271
272 if let Some(ref exclude) = scope.exclude_path {
273 post_filters.push(PostFilter::PathExclude(exclude.clone()));
274 }
275
276 dq
277 }
278
279 fn convert_bounds_to_pattern(bounds: &[NameMatcher]) -> Result<Option<Pattern>, ConvertError> {
283 if bounds.is_empty() {
284 return Ok(None);
285 }
286
287 let first = &bounds[0];
289 let pattern = match first {
290 NameMatcher::Exact(s) => Pattern::exact(s),
291 NameMatcher::Detailed(d) => Self::convert_detailed_matcher(d)?,
292 };
293
294 Ok(Some(pattern))
295 }
296
297 fn collect_inner_post_filters(
304 inner: &[Query],
305 post_filters: &mut Vec<PostFilter>,
306 ) -> Result<(), ConvertError> {
307 for q in inner {
308 let pattern_str = if let Some(ref attrs) = q.r#match {
310 if let Some(ref name) = attrs.name {
311 Self::name_matcher_to_pattern_str(name)
312 } else {
313 continue;
314 }
315 } else {
316 continue;
317 };
318
319 match q.kind {
320 QueryKind::ReturnType => {
321 post_filters.push(PostFilter::ReturnType(pattern_str));
322 }
323 QueryKind::Parameter => {
324 post_filters.push(PostFilter::ParamType(pattern_str));
325 }
326 QueryKind::Field => {
327 post_filters.push(PostFilter::FieldType(pattern_str));
328 }
329 QueryKind::Variant => {
330 post_filters.push(PostFilter::FieldType(pattern_str));
332 }
333 _ => {
334 }
336 }
337 }
338
339 Ok(())
340 }
341
342 fn name_matcher_to_pattern_str(name: &NameMatcher) -> String {
344 match name {
345 NameMatcher::Exact(s) => s.clone(),
346 NameMatcher::Detailed(d) => {
347 if let Some(ref glob) = d.glob {
349 glob.clone()
350 } else if let Some(ref regex) = d.regex {
351 format!("regex:{}", regex)
352 } else if let Some(ref contains) = d.contains {
353 format!("*{}*", contains)
354 } else if let Some(ref starts_with) = d.starts_with {
355 format!("{}*", starts_with)
356 } else if let Some(ref ends_with) = d.ends_with {
357 format!("*{}", ends_with)
358 } else {
359 "*".to_string()
360 }
361 }
362 }
363 }
364
365 #[allow(dead_code)]
367 fn convert_inner_to_type_filter(inner: &[Query]) -> Result<Option<TypeFilter>, ConvertError> {
368 let mut filter = TypeFilter::default();
369 let mut has_filter = false;
370
371 for q in inner {
372 let pattern = Self::convert_name_matcher(q.r#match.as_ref())?;
373 let Some(pattern) = pattern else {
374 continue;
375 };
376
377 match q.kind {
378 QueryKind::ReturnType => {
379 filter.return_type = Some(pattern);
380 has_filter = true;
381 }
382 QueryKind::Parameter => {
383 filter.param_type = Some(pattern);
384 has_filter = true;
385 }
386 QueryKind::Field => {
387 filter.field_type = Some(pattern);
388 has_filter = true;
389 }
390 QueryKind::Variant => {
391 filter.field_type = Some(pattern);
393 has_filter = true;
394 }
395 _ => {
396 }
398 }
399 }
400
401 if has_filter {
402 Ok(Some(filter))
403 } else {
404 Ok(None)
405 }
406 }
407
408 fn collect_post_filters(
410 attrs: &MatchAttrs,
411 post_filters: &mut Vec<PostFilter>,
412 unsupported: &mut Vec<String>,
413 ) {
414 if let Some(ref sid) = attrs.symbol_id {
418 post_filters.push(PostFilter::SymbolId(sid.clone()));
419 }
420
421 if let Some(is_async) = attrs.is_async {
422 post_filters.push(PostFilter::IsAsync(is_async));
423 unsupported.push(format!("is_async: {}", is_async));
424 }
425
426 if let Some(is_unsafe) = attrs.is_unsafe {
427 post_filters.push(PostFilter::IsUnsafe(is_unsafe));
428 unsupported.push(format!("is_unsafe: {}", is_unsafe));
429 }
430
431 if let Some(ref vis) = attrs.vis {
432 post_filters.push(PostFilter::Visibility(vis.clone()));
433 unsupported.push(format!("visibility: {:?}", vis));
434 }
435
436 if let Some(ref receiver) = attrs.receiver {
437 post_filters.push(PostFilter::Receiver(*receiver));
438 unsupported.push(format!("receiver: {:?}", receiver));
439 }
440
441 if let Some(ref attributes) = attrs.attributes {
442 post_filters.push(PostFilter::Attributes(attributes.clone()));
443 unsupported.push(format!("attributes: {:?}", attributes));
444 }
445
446 if let Some(ref generics) = attrs.generics {
447 let has_params = generics.params.as_ref().is_some_and(|p| !p.is_empty());
450 let has_lifetimes = generics.lifetimes.as_ref().is_some_and(|l| !l.is_empty());
451
452 if has_params || has_lifetimes {
453 let filtered_generics = GenericsMatch {
454 params: generics.params.clone(),
455 bounds: None, lifetimes: generics.lifetimes.clone(),
457 };
458 post_filters.push(PostFilter::Generics(filtered_generics));
459 unsupported.push(format!(
460 "generics (params/lifetimes): params={:?}, lifetimes={:?}",
461 generics.params, generics.lifetimes
462 ));
463 }
464 }
465
466 if attrs.on_empty.is_some() {
468 post_filters.push(PostFilter::OnEmpty);
469 }
470
471 if let Some(ref parent) = attrs.parent {
473 let pattern = match parent {
474 NameMatcher::Exact(s) => Pattern::exact(s),
475 NameMatcher::Detailed(d) => {
476 if let Some(ref regex) = d.regex {
478 Pattern::regex(regex).unwrap_or_else(|_| Pattern::glob(regex))
479 } else if let Some(ref glob) = d.glob {
480 Pattern::glob(glob)
481 } else if let Some(ref contains) = d.contains {
482 Pattern::glob(format!("*{}*", contains))
483 } else if let Some(ref starts) = d.starts_with {
484 Pattern::glob(format!("{}*", starts))
485 } else if let Some(ref ends) = d.ends_with {
486 Pattern::glob(format!("*{}", ends))
487 } else {
488 Pattern::glob("*")
489 }
490 }
491 };
492 post_filters.push(PostFilter::Parent(pattern));
493 }
494 }
495}
496
497#[derive(Debug, Clone)]
499pub struct ConversionResult {
500 pub discovery_query: Option<DiscoveryQuery>,
502
503 pub composite: Option<CompositeQuery>,
505
506 pub post_filters: Vec<PostFilter>,
508
509 pub unsupported: Vec<String>,
511}
512
513#[derive(Debug, Clone)]
515pub struct CompositeQuery {
516 pub op: CompositeOp,
518 pub queries: Vec<ConversionResult>,
520}
521
522#[derive(Debug, Clone, Copy, PartialEq, Eq)]
524pub enum CompositeOp {
525 Or,
527 And,
529}
530
531#[derive(Debug, Clone)]
536pub enum PostFilter {
537 IsAsync(bool),
539 IsUnsafe(bool),
541 Visibility(Visibility),
543 Receiver(ReceiverKind),
545 Attributes(Vec<String>),
547 Generics(GenericsMatch),
549 PathInclude(String),
551 PathExclude(String),
553 PatternSearch(String),
555 OnEmpty,
557 ReturnType(String),
559 ParamType(String),
561 FieldType(String),
563 Parent(Pattern),
565 SymbolId(String),
567 BodyMatch(ryo_pattern::BodyMatch),
569 Relations(ryo_pattern::Relations),
571}
572
573#[cfg(test)]
574mod tests {
575 use super::*;
576 use crate::parser::QueryParser;
577
578 #[test]
579 fn test_convert_simple_function_query() {
580 let yaml = r#"
581kind: Function
582match:
583 name: "process"
584"#;
585 let query = QueryParser::from_yaml(yaml).unwrap();
586 let result = QueryConverter::to_discovery_query(&query).unwrap();
587
588 assert!(result.discovery_query.is_some());
589 assert!(result.composite.is_none());
590 }
591
592 #[test]
593 fn test_convert_glob_pattern() {
594 let yaml = r#"
595kind: Struct
596match:
597 name: { glob: "*Config" }
598"#;
599 let query = QueryParser::from_yaml(yaml).unwrap();
600 let result = QueryConverter::to_discovery_query(&query).unwrap();
601
602 let dq = result.discovery_query.unwrap();
603 assert!(dq.pattern.matches("AppConfig"));
604 assert!(dq.pattern.matches("Config"));
605 assert!(!dq.pattern.matches("ConfigManager"));
606 }
607
608 #[test]
609 fn test_convert_contains_to_glob() {
610 let yaml = r#"
611kind: Function
612match:
613 name: { contains: "process" }
614"#;
615 let query = QueryParser::from_yaml(yaml).unwrap();
616 let result = QueryConverter::to_discovery_query(&query).unwrap();
617
618 let dq = result.discovery_query.unwrap();
619 assert!(dq.pattern.matches("process"));
621 assert!(dq.pattern.matches("process_event"));
622 assert!(dq.pattern.matches("do_process"));
623 }
624
625 #[test]
626 fn test_convert_with_post_filters() {
627 let yaml = r#"
628kind: Function
629match:
630 name: "handler"
631 is_async: true
632 vis: Public
633"#;
634 let query = QueryParser::from_yaml(yaml).unwrap();
635 let result = QueryConverter::to_discovery_query(&query).unwrap();
636
637 assert!(result
639 .post_filters
640 .iter()
641 .any(|f| matches!(f, PostFilter::IsAsync(true))));
642 assert!(result
643 .post_filters
644 .iter()
645 .any(|f| matches!(f, PostFilter::Visibility(_))));
646 assert!(!result.unsupported.is_empty());
647 }
648
649 #[test]
650 fn test_convert_or_query() {
651 let yaml = r#"
652kind: Or
653queries:
654 - kind: Struct
655 match:
656 name: { contains: "Error" }
657 - kind: Enum
658 match:
659 name: { contains: "Error" }
660"#;
661 let query = QueryParser::from_yaml(yaml).unwrap();
662 let result = QueryConverter::to_discovery_query(&query).unwrap();
663
664 assert!(result.discovery_query.is_none());
665 assert!(result.composite.is_some());
666
667 let composite = result.composite.unwrap();
668 assert_eq!(composite.op, CompositeOp::Or);
669 assert_eq!(composite.queries.len(), 2);
670 }
671
672 #[test]
673 fn test_convert_with_scope() {
674 let yaml = r#"
675kind: Function
676match:
677 name: "*"
678scope:
679 module: "handlers"
680 path: "src/**"
681"#;
682 let query = QueryParser::from_yaml(yaml).unwrap();
683 let result = QueryConverter::to_discovery_query(&query).unwrap();
684
685 let dq = result.discovery_query.unwrap();
686 assert_eq!(dq.in_module, Some("handlers".to_string()));
687
688 assert!(result
690 .post_filters
691 .iter()
692 .any(|f| matches!(f, PostFilter::PathInclude(_))));
693 }
694
695 #[test]
696 fn test_convert_with_inner() {
697 let yaml = r#"
698kind: Function
699match:
700 name: { starts_with: "process_" }
701inner:
702 - kind: ReturnType
703 match:
704 name: "Result"
705"#;
706 let query = QueryParser::from_yaml(yaml).unwrap();
707 let result = QueryConverter::to_discovery_query(&query).unwrap();
708
709 assert!(result
711 .post_filters
712 .iter()
713 .any(|f| matches!(f, PostFilter::ReturnType(_))));
714 }
715
716 #[test]
717 fn test_convert_with_multiple_inner() {
718 let yaml = r#"
719kind: Function
720match:
721 name: "*"
722inner:
723 - kind: ReturnType
724 match:
725 name: { contains: "Result" }
726 - kind: Parameter
727 match:
728 name: { contains: "Config" }
729"#;
730 let query = QueryParser::from_yaml(yaml).unwrap();
731 let result = QueryConverter::to_discovery_query(&query).unwrap();
732
733 assert!(result
735 .post_filters
736 .iter()
737 .any(|f| matches!(f, PostFilter::ReturnType(_))));
738 assert!(result
739 .post_filters
740 .iter()
741 .any(|f| matches!(f, PostFilter::ParamType(_))));
742 }
743
744 #[test]
745 fn test_convert_field_inner() {
746 let yaml = r#"
747kind: Struct
748match:
749 name: "*"
750inner:
751 - kind: Field
752 match:
753 name: "String"
754"#;
755 let query = QueryParser::from_yaml(yaml).unwrap();
756 let result = QueryConverter::to_discovery_query(&query).unwrap();
757
758 assert!(result
760 .post_filters
761 .iter()
762 .any(|f| matches!(f, PostFilter::FieldType(_))));
763 }
764
765 #[test]
766 fn test_convert_kind_function_excludes_method() {
767 let kinds = QueryConverter::convert_kind(QueryKind::Function).unwrap();
769 assert_eq!(kinds, vec![SymbolKind::Function]);
770 assert!(!kinds.contains(&SymbolKind::Method));
771 }
772
773 #[test]
774 fn test_convert_kind_struct_single() {
775 let kinds = QueryConverter::convert_kind(QueryKind::Struct).unwrap();
776 assert_eq!(kinds, vec![SymbolKind::Struct]);
777 }
778
779 #[test]
780 fn test_convert_generics_bounds_to_type_filter() {
781 let yaml = r#"
782kind: Function
783match:
784 name: "*"
785 generics:
786 bounds: ["Clone"]
787"#;
788 let query = QueryParser::from_yaml(yaml).unwrap();
789 let result = QueryConverter::to_discovery_query(&query).unwrap();
790
791 let dq = result.discovery_query.unwrap();
793 assert!(dq.type_filter.is_some());
794
795 let type_filter = dq.type_filter.unwrap();
796 assert!(type_filter.has_bound.is_some());
797 assert!(type_filter.has_bound.unwrap().matches("Clone"));
798 }
799
800 #[test]
801 fn test_convert_generics_params_to_post_filter() {
802 let yaml = r#"
803kind: Function
804match:
805 name: "*"
806 generics:
807 params: ["T", "E"]
808 lifetimes: ["'a"]
809"#;
810 let query = QueryParser::from_yaml(yaml).unwrap();
811 let result = QueryConverter::to_discovery_query(&query).unwrap();
812
813 assert!(result.post_filters.iter().any(|f| {
815 matches!(f, PostFilter::Generics(g) if g.params.is_some() && g.bounds.is_none())
816 }));
817 }
818
819 #[test]
820 fn test_convert_generics_mixed_bounds_and_params() {
821 let yaml = r#"
822kind: Struct
823match:
824 name: "*"
825 generics:
826 params: ["T"]
827 bounds: [{ glob: "*Send*" }]
828"#;
829 let query = QueryParser::from_yaml(yaml).unwrap();
830 let result = QueryConverter::to_discovery_query(&query).unwrap();
831
832 let dq = result.discovery_query.unwrap();
834 let type_filter = dq.type_filter.unwrap();
835 assert!(type_filter.has_bound.is_some());
836 assert!(type_filter.has_bound.unwrap().matches("MySendTrait"));
837
838 assert!(result.post_filters.iter().any(|f| {
840 matches!(f, PostFilter::Generics(g) if g.params.is_some() && g.bounds.is_none())
841 }));
842 }
843
844 #[test]
845 fn test_convert_symbol_id_filter() {
846 let json = r#"{"kind":"Function","match":{"symbol_id":"2421v1"}}"#;
847 let query = QueryParser::from_json(json).unwrap();
848 let result = QueryConverter::to_discovery_query(&query).unwrap();
849
850 assert!(
851 result
852 .post_filters
853 .iter()
854 .any(|f| matches!(f, PostFilter::SymbolId(ref s) if s == "2421v1")),
855 "symbol_id must be converted to PostFilter::SymbolId"
856 );
857 }
858
859 #[test]
860 fn test_convert_symbol_id_with_prefix() {
861 let json = r#"{"kind":"Any","match":{"symbol_id":"SymbolId(165v1)"}}"#;
862 let query = QueryParser::from_json(json).unwrap();
863 let result = QueryConverter::to_discovery_query(&query).unwrap();
864
865 assert!(
866 result
867 .post_filters
868 .iter()
869 .any(|f| matches!(f, PostFilter::SymbolId(ref s) if s == "SymbolId(165v1)")),
870 "SymbolId(xxx) format must also be preserved in PostFilter"
871 );
872 }
873
874 #[test]
875 fn test_convert_body_contains_filter() {
876 let json = r#"{
877 "kind": "Function",
878 "body": {
879 "contains": [
880 {"node": "MethodCall"}
881 ]
882 }
883 }"#;
884 let query = QueryParser::from_json(json).unwrap();
885 let result = QueryConverter::to_discovery_query(&query).unwrap();
886
887 assert!(
888 result
889 .post_filters
890 .iter()
891 .any(|f| matches!(f, PostFilter::BodyMatch(_))),
892 "body.contains must be converted to PostFilter::BodyMatch"
893 );
894 }
895
896 #[test]
897 fn test_convert_body_not_contains_filter() {
898 let json = r#"{
899 "kind": "Function",
900 "body": {
901 "not_contains": [
902 {"node": "MethodCall", "children": {"method": {"name": "unwrap"}}}
903 ]
904 }
905 }"#;
906 let query = QueryParser::from_json(json).unwrap();
907 let result = QueryConverter::to_discovery_query(&query).unwrap();
908
909 assert!(
910 result
911 .post_filters
912 .iter()
913 .any(|f| matches!(f, PostFilter::BodyMatch(bm) if bm.not_contains.is_some())),
914 "body.not_contains must be preserved in PostFilter::BodyMatch"
915 );
916 }
917
918 #[test]
919 fn test_convert_relations_any_filter() {
920 let json = r#"{
921 "kind": "Function",
922 "relations": {
923 "any": [
924 {"kind": "Calls", "target": {"kind": "Function", "match": {"name": "serve"}}}
925 ]
926 }
927 }"#;
928 let query = QueryParser::from_json(json).unwrap();
929 let result = QueryConverter::to_discovery_query(&query).unwrap();
930
931 assert!(
932 result
933 .post_filters
934 .iter()
935 .any(|f| matches!(f, PostFilter::Relations(r) if r.any.is_some())),
936 "relations.any must be converted to PostFilter::Relations"
937 );
938 }
939
940 #[test]
941 fn test_convert_relations_none_filter() {
942 let json = r#"{
943 "kind": "Trait",
944 "relations": {
945 "none": [
946 {"kind": "ImplementedBy", "target": {"kind": "Struct", "match": {"name": "Router"}}}
947 ]
948 }
949 }"#;
950 let query = QueryParser::from_json(json).unwrap();
951 let result = QueryConverter::to_discovery_query(&query).unwrap();
952
953 assert!(
954 result
955 .post_filters
956 .iter()
957 .any(|f| matches!(f, PostFilter::Relations(r) if r.none.is_some())),
958 "relations.none must be converted to PostFilter::Relations"
959 );
960 }
961
962 #[test]
963 fn test_convert_empty_relations_skipped() {
964 let json = r#"{"kind": "Function", "relations": {}}"#;
965 let query = QueryParser::from_json(json).unwrap();
966 let result = QueryConverter::to_discovery_query(&query).unwrap();
967
968 assert!(
969 !result
970 .post_filters
971 .iter()
972 .any(|f| matches!(f, PostFilter::Relations(_))),
973 "empty relations must not produce PostFilter"
974 );
975 }
976}