Skip to main content

sqry_core/query/
parsed_query.rs

1//! `ParsedQuery` - canonical AST representation for indexed and non-indexed flows
2//!
3//! This module provides the `ParsedQuery` struct which encapsulates:
4//! - The parsed `QueryAST` (Arc-wrapped for zero-copy sharing)
5//! - Extracted `RepoFilter` for workspace/multi-repo queries
6//! - Normalized query string (repo predicates stripped for cache key normalization)
7//!
8//! # Purpose
9//!
10//! `ParsedQuery` serves as the unified representation used by:
11//! - `QueryExecutor::execute_with_index_ast` (indexed queries)
12//! - `QueryExecutor::execute_query_with_stats` (non-indexed filesystem queries)
13//! - `SessionManager::query` (session-based cached queries)
14//! - `WorkspaceIndex::query` (multi-repo workspace queries)
15//!
16//! By extracting repo filters and normalizing query strings, we enable:
17//! - Better cache hit rates (repo filters don't pollute cache keys)
18//! - Consistent repo filtering across all execution paths
19//! - Zero-copy AST sharing via Arc
20
21use crate::query::RepoFilter;
22use crate::query::types::{Expr, JoinExpr, Operator, Query as QueryAST, Value};
23use anyhow::Result;
24use std::sync::Arc;
25
26/// Canonical parsed query representation
27///
28/// Contains the boolean query AST, extracted repo filter, and normalized
29/// query string for cache key generation.
30///
31/// # Example
32///
33/// ```ignore
34/// use sqry_core::query::QueryExecutor;
35///
36/// let executor = QueryExecutor::new();
37/// let parsed = executor.parse_query_ast("repo:backend-* AND kind:function")?;
38///
39/// // AST contains the boolean expression (without repo predicates)
40/// let ast = &parsed.ast;
41///
42/// // Repo filter extracted for workspace/multi-repo filtering
43/// let repo_filter = &parsed.repo_filter;
44///
45/// // Normalized string used for cache keys (repo predicates stripped)
46/// let cache_key_string = &parsed.normalized;
47/// ```
48#[derive(Debug, Clone)]
49pub struct ParsedQuery {
50    /// Parsed boolean query AST (Arc-wrapped for zero-copy sharing)
51    ///
52    /// This AST represents the query after:
53    /// - Lexing and parsing
54    /// - Validation against field registry
55    /// - Optimization (if enabled)
56    /// - Repo predicate extraction (repo conditions removed from AST)
57    pub ast: Arc<QueryAST>,
58
59    /// Extracted repository filter
60    ///
61    /// Compiled from `repo:` predicates in the original query.
62    /// Used by workspace and session managers to filter repositories
63    /// before executing the query.
64    ///
65    /// Empty filter matches all repositories.
66    pub repo_filter: RepoFilter,
67
68    /// Normalized query string (repo predicates stripped)
69    ///
70    /// Used for cache key generation. By stripping repo predicates,
71    /// we improve cache hit rates across different repo filters.
72    ///
73    /// Example:
74    /// - Input: "repo:backend-* AND kind:function AND name:test"
75    /// - Normalized: "kind:function AND name:test"
76    ///
77    /// `Arc<str>` provides zero-copy sharing in cache keys.
78    pub normalized: Arc<str>,
79}
80
81impl ParsedQuery {
82    /// Create a new `ParsedQuery`
83    ///
84    /// # Arguments
85    ///
86    /// * `ast` - Parsed boolean query AST
87    /// * `repo_filter` - Extracted repository filter
88    /// * `normalized` - Normalized query string (repo predicates stripped)
89    #[must_use]
90    pub fn new(ast: Arc<QueryAST>, repo_filter: RepoFilter, normalized: String) -> Self {
91        Self {
92            ast,
93            repo_filter,
94            normalized: Arc::from(normalized),
95        }
96    }
97
98    /// Create `ParsedQuery` from an AST
99    ///
100    /// This is a convenience method that:
101    /// 1. Extracts repo filter from AST
102    /// 2. Strips repo predicates to create normalized AST
103    /// 3. Serializes normalized AST to string for cache key
104    ///
105    /// # Arguments
106    ///
107    /// * `ast` - Parsed boolean query AST (Arc-wrapped)
108    ///
109    /// # Returns
110    ///
111    /// * `Ok(ParsedQuery)` - Successfully created `ParsedQuery`
112    /// * `Err(...)` - Failed to extract repo filter or normalize AST
113    ///
114    /// # Example
115    ///
116    /// ```ignore
117    /// use sqry_core::query::{QueryParser, ParsedQuery};
118    /// use std::sync::Arc;
119    ///
120    /// let ast = QueryParser::parse_query("kind:function AND name:test")?;
121    /// let parsed = ParsedQuery::from_ast(Arc::new(ast))?;
122    /// ```
123    ///
124    /// # Errors
125    ///
126    /// Returns [`anyhow::Error`] when repo filter extraction or normalization fails.
127    pub fn from_ast(ast: Arc<QueryAST>) -> Result<Self> {
128        // Extract repo filter
129        let repo_filter = extract_repo_filter(&ast)?;
130
131        // Strip repo predicates for normalization
132        let normalized_ast = strip_repo_predicates(&ast);
133
134        // Serialize normalized AST to string for cache key
135        let normalized = if let Some(norm_ast) = normalized_ast {
136            serialize_query(&norm_ast)
137        } else {
138            // Query is only repo predicates, normalize to empty string
139            String::new()
140        };
141
142        Ok(Self {
143            ast,
144            repo_filter,
145            normalized: Arc::from(normalized),
146        })
147    }
148
149    /// Get a reference to the AST
150    #[inline]
151    #[must_use]
152    pub fn ast(&self) -> &Arc<QueryAST> {
153        &self.ast
154    }
155
156    /// Get a reference to the repo filter
157    #[inline]
158    #[must_use]
159    pub fn repo_filter(&self) -> &RepoFilter {
160        &self.repo_filter
161    }
162
163    /// Get the normalized query string
164    #[inline]
165    #[must_use]
166    pub fn normalized(&self) -> &str {
167        &self.normalized
168    }
169}
170
171/// Extract repo filter patterns from a `QueryAST`
172///
173/// Traverses the AST and collects all `repo:` predicates into a `RepoFilter`.
174/// Returns an empty filter if no repo predicates are found OR if any repo
175/// predicate appears in a negated context (NOT).
176///
177/// # Negation Handling
178///
179/// If the query contains `NOT repo:foo`, we CANNOT use repo-based pre-filtering
180/// because `RepoFilter` only supports positive patterns, not exclusions. In such
181/// cases, we return an empty filter (matches all repos) and let the executor
182/// handle repo filtering during post-filtering.
183///
184/// # Examples
185///
186/// ```ignore
187/// // Positive repo filter (works)
188/// let ast = QueryParser::parse_query("repo:backend-* AND kind:function")?;
189/// let filter = extract_repo_filter(&ast)?;
190/// assert_eq!(filter.patterns().len(), 1);
191///
192/// // Negated repo filter (returns empty filter)
193/// let ast = QueryParser::parse_query("NOT repo:test-* AND kind:function")?;
194/// let filter = extract_repo_filter(&ast)?;
195/// assert_eq!(filter.patterns().len(), 0); // Cannot pre-filter
196/// ```
197///
198/// # Errors
199///
200/// Returns [`anyhow::Error`] when repo filter construction fails.
201pub fn extract_repo_filter(ast: &QueryAST) -> Result<RepoFilter> {
202    let mut patterns = Vec::new();
203    let has_negated_repo = collect_repo_patterns(&ast.root, &mut patterns, false);
204
205    if has_negated_repo {
206        // Found repo predicate in negated context - cannot use pre-filtering
207        // Return empty filter (matches all repos)
208        RepoFilter::new(vec![])
209    } else {
210        RepoFilter::new(patterns)
211    }
212}
213
214/// Recursively collect repo: patterns from an expression
215///
216/// Returns true if any repo predicate is found in a negated context.
217///
218/// # Arguments
219///
220/// * `expr` - Expression to traverse
221/// * `patterns` - Vector to collect positive repo patterns
222/// * `is_negated` - Whether we're currently in a NOT context
223fn collect_repo_patterns(expr: &Expr, patterns: &mut Vec<String>, is_negated: bool) -> bool {
224    match expr {
225        Expr::And(operands) | Expr::Or(operands) => {
226            let mut found_negated = false;
227            for operand in operands {
228                if collect_repo_patterns(operand, patterns, is_negated) {
229                    found_negated = true;
230                }
231            }
232            found_negated
233        }
234        Expr::Not(operand) => {
235            // Flip negation flag when entering NOT
236            collect_repo_patterns(operand, patterns, !is_negated)
237        }
238        Expr::Condition(condition) => {
239            if condition.field.as_str() == "repo" {
240                if is_negated {
241                    // Found repo predicate in negated context - abort pre-filtering
242                    return true;
243                }
244
245                // Only extract string values (Equal operator with string value)
246                if let (Operator::Equal, Value::String(pattern)) =
247                    (&condition.operator, &condition.value)
248                {
249                    patterns.push(pattern.clone());
250                }
251            }
252            false
253        }
254        Expr::Join(join) => {
255            let left = collect_repo_patterns(&join.left, patterns, is_negated);
256            let right = collect_repo_patterns(&join.right, patterns, is_negated);
257            left || right
258        }
259    }
260}
261
262/// Strip repo predicates from a `QueryAST`, returning a normalized AST
263///
264/// Creates a new AST with all `repo:` conditions removed. This normalized
265/// AST is used for cache keys to improve hit rates across different repo filters.
266///
267/// # Examples
268///
269/// ```ignore
270/// // Input: repo:backend-* AND kind:function
271/// // Output: kind:function
272/// let ast = QueryParser::parse_query("repo:backend-* AND kind:function")?;
273/// let normalized_ast = strip_repo_predicates(&ast);
274/// ```
275///
276/// # Behavior
277///
278/// - Removes repo conditions from AND/OR operand lists
279/// - Simplifies single-operand AND/OR to the operand itself
280/// - Returns None if the entire query consists only of repo predicates
281/// - NOT(repo:...) is removed entirely (repo filters don't support NOT)
282#[must_use]
283pub fn strip_repo_predicates(ast: &QueryAST) -> Option<QueryAST> {
284    strip_repo_from_expr(&ast.root).map(|root| QueryAST {
285        root,
286        span: ast.span.clone(),
287    })
288}
289
290/// Recursively strip repo predicates from an expression
291fn strip_repo_from_expr(expr: &Expr) -> Option<Expr> {
292    match expr {
293        Expr::And(operands) => {
294            let filtered: Vec<Expr> = operands.iter().filter_map(strip_repo_from_expr).collect();
295
296            match filtered.len() {
297                0 => None,                                       // All operands were repo predicates
298                1 => Some(filtered.into_iter().next().unwrap()), // Simplify AND with single operand
299                _ => Some(Expr::And(filtered)),
300            }
301        }
302        Expr::Or(operands) => {
303            let filtered: Vec<Expr> = operands.iter().filter_map(strip_repo_from_expr).collect();
304
305            match filtered.len() {
306                0 => None,                                       // All operands were repo predicates
307                1 => Some(filtered.into_iter().next().unwrap()), // Simplify OR with single operand
308                _ => Some(Expr::Or(filtered)),
309            }
310        }
311        Expr::Not(operand) => {
312            // Strip repo from the NOT operand
313            strip_repo_from_expr(operand).map(|expr| Expr::Not(Box::new(expr)))
314        }
315        Expr::Condition(condition) => {
316            // Filter out repo conditions
317            if condition.field.as_str() == "repo" {
318                None
319            } else {
320                Some(Expr::Condition(condition.clone()))
321            }
322        }
323        Expr::Join(join) => {
324            let left = strip_repo_from_expr(&join.left)?;
325            let right = strip_repo_from_expr(&join.right)?;
326            Some(Expr::Join(JoinExpr {
327                left: Box::new(left),
328                edge: join.edge.clone(),
329                right: Box::new(right),
330                span: join.span.clone(),
331            }))
332        }
333    }
334}
335
336/// Serialize a `QueryAST` back to a query string (for normalized cache keys)
337///
338/// This is a simplified serializer that produces canonical query strings.
339/// NOT guaranteed to preserve exact formatting or parentheses from original input.
340///
341/// # Example
342///
343/// ```ignore
344/// let ast = QueryParser::parse_query("kind:function AND name:test")?;
345/// let query_str = serialize_query(&ast);
346/// assert_eq!(query_str, "kind:function AND name:test");
347/// ```
348#[must_use]
349pub fn serialize_query(ast: &QueryAST) -> String {
350    serialize_expr(&ast.root)
351}
352
353/// Recursively serialize an expression to a query string
354fn serialize_expr(expr: &Expr) -> String {
355    match expr {
356        Expr::And(operands) => {
357            let parts: Vec<String> = operands.iter().map(serialize_expr).collect();
358            parts.join(" AND ")
359        }
360        Expr::Or(operands) => {
361            let parts: Vec<String> = operands.iter().map(serialize_expr).collect();
362            format!("({})", parts.join(" OR "))
363        }
364        Expr::Not(operand) => {
365            format!("NOT {}", serialize_expr(operand))
366        }
367        Expr::Condition(condition) => {
368            let op_str = match condition.operator {
369                Operator::Equal => ":",
370                Operator::Regex => "~=",
371                Operator::Greater => ">",
372                Operator::GreaterEq => ">=",
373                Operator::Less => "<",
374                Operator::LessEq => "<=",
375            };
376
377            let value_str = match &condition.value {
378                Value::String(s) => s.clone(),
379                Value::Number(n) => n.to_string(),
380                Value::Boolean(b) => b.to_string(),
381                Value::Regex(r) => {
382                    // Include flags to prevent cache key collisions
383                    // e.g., /foo/i, /foo/ms, /foo/ (no flags)
384                    let mut flags = String::new();
385                    if r.flags.case_insensitive {
386                        flags.push('i');
387                    }
388                    if r.flags.multiline {
389                        flags.push('m');
390                    }
391                    if r.flags.dot_all {
392                        flags.push('s');
393                    }
394                    format!("/{}/{}", r.pattern, flags)
395                }
396                Value::Variable(name) => format!("${name}"),
397                Value::Subquery(expr) => format!("({})", serialize_expr(expr)),
398            };
399
400            format!("{}{}{}", condition.field.as_str(), op_str, value_str)
401        }
402        Expr::Join(join) => {
403            format!(
404                "({}) {} ({})",
405                serialize_expr(&join.left),
406                join.edge,
407                serialize_expr(&join.right)
408            )
409        }
410    }
411}
412
413#[cfg(test)]
414mod tests {
415    use super::*;
416    use crate::query::types::{
417        Condition, Expr, Field, Operator, RegexFlags, RegexValue, Span, Value,
418    };
419
420    fn build_regex_ast(flags: RegexFlags) -> QueryAST {
421        QueryAST {
422            root: Expr::Condition(Condition {
423                field: Field::new("name"),
424                operator: Operator::Regex,
425                value: Value::Regex(RegexValue {
426                    pattern: "foo".to_string(),
427                    flags,
428                }),
429                span: Span::new(0, 10),
430            }),
431            span: Span::new(0, 10),
432        }
433    }
434
435    fn assert_regex_serialization(flags: RegexFlags, expected: &str) {
436        let ast = build_regex_ast(flags);
437        let serialized = serialize_query(&ast);
438        assert_eq!(serialized, expected);
439    }
440
441    #[test]
442    fn test_parsed_query_creation() {
443        // Create a simple AST: kind:function
444        let ast = QueryAST {
445            root: Expr::Condition(Condition {
446                field: Field::new("kind"),
447                operator: Operator::Equal,
448                value: Value::String("function".to_string()),
449                span: Span::new(0, 13),
450            }),
451            span: Span::new(0, 13),
452        };
453
454        let repo_filter = RepoFilter::new(vec![]).unwrap();
455        let normalized = "kind:function".to_string();
456
457        let parsed = ParsedQuery::new(Arc::new(ast), repo_filter, normalized);
458
459        assert_eq!(parsed.normalized(), "kind:function");
460        assert!(parsed.repo_filter().patterns().is_empty());
461    }
462
463    #[test]
464    fn test_parsed_query_with_repo_filter() {
465        let ast = QueryAST {
466            root: Expr::Condition(Condition {
467                field: Field::new("kind"),
468                operator: Operator::Equal,
469                value: Value::String("function".to_string()),
470                span: Span::new(0, 13),
471            }),
472            span: Span::new(0, 13),
473        };
474
475        let repo_filter = RepoFilter::new(vec!["backend-*".to_string()]).unwrap();
476        let normalized = "kind:function".to_string();
477
478        let parsed = ParsedQuery::new(Arc::new(ast), repo_filter, normalized.clone());
479
480        assert_eq!(parsed.normalized(), "kind:function");
481        assert_eq!(parsed.repo_filter().patterns().len(), 1);
482        assert_eq!(parsed.repo_filter().patterns()[0], "backend-*");
483    }
484
485    #[test]
486    fn test_parsed_query_arc_sharing() {
487        let ast = QueryAST {
488            root: Expr::Condition(Condition {
489                field: Field::new("name"),
490                operator: Operator::Equal,
491                value: Value::String("test".to_string()),
492                span: Span::new(0, 10),
493            }),
494            span: Span::new(0, 10),
495        };
496
497        let repo_filter = RepoFilter::new(vec![]).unwrap();
498        let parsed = ParsedQuery::new(Arc::new(ast), repo_filter, "name:test".to_string());
499
500        // Clone should share the same Arc
501        let parsed_clone = parsed.clone();
502        assert!(Arc::ptr_eq(&parsed.ast, &parsed_clone.ast));
503        assert!(Arc::ptr_eq(&parsed.normalized, &parsed_clone.normalized));
504    }
505
506    #[test]
507    fn test_extract_repo_filter_empty() {
508        let ast = QueryAST {
509            root: Expr::Condition(Condition {
510                field: Field::new("kind"),
511                operator: Operator::Equal,
512                value: Value::String("function".to_string()),
513                span: Span::new(0, 13),
514            }),
515            span: Span::new(0, 13),
516        };
517
518        let repo_filter = extract_repo_filter(&ast).unwrap();
519        assert_eq!(repo_filter.patterns().len(), 0);
520    }
521
522    #[test]
523    fn test_extract_repo_filter_single() {
524        let ast = QueryAST {
525            root: Expr::And(vec![
526                Expr::Condition(Condition {
527                    field: Field::new("repo"),
528                    operator: Operator::Equal,
529                    value: Value::String("backend-*".to_string()),
530                    span: Span::new(0, 16),
531                }),
532                Expr::Condition(Condition {
533                    field: Field::new("kind"),
534                    operator: Operator::Equal,
535                    value: Value::String("function".to_string()),
536                    span: Span::new(21, 34),
537                }),
538            ]),
539            span: Span::new(0, 34),
540        };
541
542        let repo_filter = extract_repo_filter(&ast).unwrap();
543        assert_eq!(repo_filter.patterns().len(), 1);
544        assert_eq!(repo_filter.patterns()[0], "backend-*");
545    }
546
547    #[test]
548    fn test_extract_repo_filter_multiple() {
549        let ast = QueryAST {
550            root: Expr::And(vec![
551                Expr::Condition(Condition {
552                    field: Field::new("repo"),
553                    operator: Operator::Equal,
554                    value: Value::String("backend-*".to_string()),
555                    span: Span::new(0, 16),
556                }),
557                Expr::Condition(Condition {
558                    field: Field::new("repo"),
559                    operator: Operator::Equal,
560                    value: Value::String("frontend-*".to_string()),
561                    span: Span::new(21, 38),
562                }),
563            ]),
564            span: Span::new(0, 38),
565        };
566
567        let repo_filter = extract_repo_filter(&ast).unwrap();
568        assert_eq!(repo_filter.patterns().len(), 2);
569        assert!(repo_filter.patterns().contains(&"backend-*".to_string()));
570        assert!(repo_filter.patterns().contains(&"frontend-*".to_string()));
571    }
572
573    #[test]
574    fn test_extract_repo_filter_negated_aborts() {
575        // NOT repo:test-* AND kind:function
576        // Should return empty filter (cannot pre-filter with negation)
577        let ast = QueryAST {
578            root: Expr::And(vec![
579                Expr::Not(Box::new(Expr::Condition(Condition {
580                    field: Field::new("repo"),
581                    operator: Operator::Equal,
582                    value: Value::String("test-*".to_string()),
583                    span: Span::new(4, 16),
584                }))),
585                Expr::Condition(Condition {
586                    field: Field::new("kind"),
587                    operator: Operator::Equal,
588                    value: Value::String("function".to_string()),
589                    span: Span::new(21, 34),
590                }),
591            ]),
592            span: Span::new(0, 34),
593        };
594
595        let repo_filter = extract_repo_filter(&ast).unwrap();
596        // Should return empty filter (abort pre-filtering due to negation)
597        assert_eq!(
598            repo_filter.patterns().len(),
599            0,
600            "Negated repo predicates should abort pre-filtering"
601        );
602    }
603
604    #[test]
605    fn test_extract_repo_filter_mixed_positive_and_negated() {
606        // repo:backend-* AND NOT repo:test-* AND kind:function
607        // Has both positive and negated repo predicates
608        // Should return empty filter (cannot pre-filter when negation present)
609        let ast = QueryAST {
610            root: Expr::And(vec![
611                Expr::Condition(Condition {
612                    field: Field::new("repo"),
613                    operator: Operator::Equal,
614                    value: Value::String("backend-*".to_string()),
615                    span: Span::new(0, 16),
616                }),
617                Expr::Not(Box::new(Expr::Condition(Condition {
618                    field: Field::new("repo"),
619                    operator: Operator::Equal,
620                    value: Value::String("test-*".to_string()),
621                    span: Span::new(25, 37),
622                }))),
623                Expr::Condition(Condition {
624                    field: Field::new("kind"),
625                    operator: Operator::Equal,
626                    value: Value::String("function".to_string()),
627                    span: Span::new(42, 55),
628                }),
629            ]),
630            span: Span::new(0, 55),
631        };
632
633        let repo_filter = extract_repo_filter(&ast).unwrap();
634        // Should return empty filter even though there's a positive repo predicate
635        assert_eq!(
636            repo_filter.patterns().len(),
637            0,
638            "Mixed positive and negated repo predicates should abort pre-filtering"
639        );
640    }
641
642    #[test]
643    fn test_extract_repo_filter_double_negation() {
644        // NOT (NOT repo:backend-*) AND kind:function
645        // Double negation = positive context
646        // Should extract backend-* pattern
647        let ast = QueryAST {
648            root: Expr::And(vec![
649                Expr::Not(Box::new(Expr::Not(Box::new(Expr::Condition(Condition {
650                    field: Field::new("repo"),
651                    operator: Operator::Equal,
652                    value: Value::String("backend-*".to_string()),
653                    span: Span::new(9, 25),
654                }))))),
655                Expr::Condition(Condition {
656                    field: Field::new("kind"),
657                    operator: Operator::Equal,
658                    value: Value::String("function".to_string()),
659                    span: Span::new(30, 43),
660                }),
661            ]),
662            span: Span::new(0, 43),
663        };
664
665        let repo_filter = extract_repo_filter(&ast).unwrap();
666        // Double negation = positive, should extract pattern
667        assert_eq!(
668            repo_filter.patterns().len(),
669            1,
670            "Double negation should be treated as positive context"
671        );
672        assert_eq!(repo_filter.patterns()[0], "backend-*");
673    }
674
675    #[test]
676    fn test_extract_repo_filter_or_with_negation() {
677        // (repo:A OR NOT repo:B) AND kind:function
678        // Has negated repo in OR - should abort pre-filtering
679        let ast = QueryAST {
680            root: Expr::And(vec![
681                Expr::Or(vec![
682                    Expr::Condition(Condition {
683                        field: Field::new("repo"),
684                        operator: Operator::Equal,
685                        value: Value::String("A".to_string()),
686                        span: Span::new(1, 7),
687                    }),
688                    Expr::Not(Box::new(Expr::Condition(Condition {
689                        field: Field::new("repo"),
690                        operator: Operator::Equal,
691                        value: Value::String("B".to_string()),
692                        span: Span::new(16, 22),
693                    }))),
694                ]),
695                Expr::Condition(Condition {
696                    field: Field::new("kind"),
697                    operator: Operator::Equal,
698                    value: Value::String("function".to_string()),
699                    span: Span::new(28, 41),
700                }),
701            ]),
702            span: Span::new(0, 41),
703        };
704
705        let repo_filter = extract_repo_filter(&ast).unwrap();
706        assert_eq!(
707            repo_filter.patterns().len(),
708            0,
709            "OR with negated repo should abort pre-filtering"
710        );
711    }
712
713    #[test]
714    fn test_strip_repo_predicates_none() {
715        let ast = QueryAST {
716            root: Expr::Condition(Condition {
717                field: Field::new("kind"),
718                operator: Operator::Equal,
719                value: Value::String("function".to_string()),
720                span: Span::new(0, 13),
721            }),
722            span: Span::new(0, 13),
723        };
724
725        let stripped = strip_repo_predicates(&ast).unwrap();
726        // Should be unchanged
727        if let Expr::Condition(cond) = &stripped.root {
728            assert_eq!(cond.field.as_str(), "kind");
729        } else {
730            panic!("Expected Condition, got {:?}", stripped.root);
731        }
732    }
733
734    #[test]
735    fn test_strip_repo_predicates_and() {
736        let ast = QueryAST {
737            root: Expr::And(vec![
738                Expr::Condition(Condition {
739                    field: Field::new("repo"),
740                    operator: Operator::Equal,
741                    value: Value::String("backend-*".to_string()),
742                    span: Span::new(0, 16),
743                }),
744                Expr::Condition(Condition {
745                    field: Field::new("kind"),
746                    operator: Operator::Equal,
747                    value: Value::String("function".to_string()),
748                    span: Span::new(21, 34),
749                }),
750            ]),
751            span: Span::new(0, 34),
752        };
753
754        let stripped = strip_repo_predicates(&ast).unwrap();
755        // Should simplify to just kind:function
756        if let Expr::Condition(cond) = &stripped.root {
757            assert_eq!(cond.field.as_str(), "kind");
758        } else {
759            panic!("Expected simplified Condition, got {:?}", stripped.root);
760        }
761    }
762
763    #[test]
764    fn test_strip_repo_predicates_all_repo() {
765        let ast = QueryAST {
766            root: Expr::Condition(Condition {
767                field: Field::new("repo"),
768                operator: Operator::Equal,
769                value: Value::String("backend-*".to_string()),
770                span: Span::new(0, 16),
771            }),
772            span: Span::new(0, 16),
773        };
774
775        let stripped = strip_repo_predicates(&ast);
776        // Should return None (query is only repo predicates)
777        assert!(stripped.is_none());
778    }
779
780    #[test]
781    fn test_serialize_simple_condition() {
782        let ast = QueryAST {
783            root: Expr::Condition(Condition {
784                field: Field::new("kind"),
785                operator: Operator::Equal,
786                value: Value::String("function".to_string()),
787                span: Span::new(0, 13),
788            }),
789            span: Span::new(0, 13),
790        };
791
792        let serialized = serialize_query(&ast);
793        assert_eq!(serialized, "kind:function");
794    }
795
796    #[test]
797    fn test_serialize_and_expression() {
798        let ast = QueryAST {
799            root: Expr::And(vec![
800                Expr::Condition(Condition {
801                    field: Field::new("kind"),
802                    operator: Operator::Equal,
803                    value: Value::String("function".to_string()),
804                    span: Span::new(0, 13),
805                }),
806                Expr::Condition(Condition {
807                    field: Field::new("name"),
808                    operator: Operator::Equal,
809                    value: Value::String("test".to_string()),
810                    span: Span::new(18, 28),
811                }),
812            ]),
813            span: Span::new(0, 28),
814        };
815
816        let serialized = serialize_query(&ast);
817        assert_eq!(serialized, "kind:function AND name:test");
818    }
819
820    #[test]
821    fn test_serialize_or_expression() {
822        let ast = QueryAST {
823            root: Expr::Or(vec![
824                Expr::Condition(Condition {
825                    field: Field::new("kind"),
826                    operator: Operator::Equal,
827                    value: Value::String("function".to_string()),
828                    span: Span::new(0, 13),
829                }),
830                Expr::Condition(Condition {
831                    field: Field::new("kind"),
832                    operator: Operator::Equal,
833                    value: Value::String("method".to_string()),
834                    span: Span::new(17, 28),
835                }),
836            ]),
837            span: Span::new(0, 28),
838        };
839
840        let serialized = serialize_query(&ast);
841        assert_eq!(serialized, "(kind:function OR kind:method)");
842    }
843
844    #[test]
845    fn test_serialize_regex_no_flags() {
846        assert_regex_serialization(
847            RegexFlags {
848                case_insensitive: false,
849                multiline: false,
850                dot_all: false,
851            },
852            "name~=/foo/",
853        );
854    }
855
856    #[test]
857    fn test_serialize_regex_case_insensitive() {
858        assert_regex_serialization(
859            RegexFlags {
860                case_insensitive: true,
861                multiline: false,
862                dot_all: false,
863            },
864            "name~=/foo/i",
865        );
866    }
867
868    #[test]
869    fn test_serialize_regex_multiline() {
870        assert_regex_serialization(
871            RegexFlags {
872                case_insensitive: false,
873                multiline: true,
874                dot_all: false,
875            },
876            "name~=/foo/m",
877        );
878    }
879
880    #[test]
881    fn test_serialize_regex_dot_all() {
882        assert_regex_serialization(
883            RegexFlags {
884                case_insensitive: false,
885                multiline: false,
886                dot_all: true,
887            },
888            "name~=/foo/s",
889        );
890    }
891
892    #[test]
893    fn test_serialize_regex_all_flags() {
894        assert_regex_serialization(
895            RegexFlags {
896                case_insensitive: true,
897                multiline: true,
898                dot_all: true,
899            },
900            "name~=/foo/ims",
901        );
902    }
903
904    #[test]
905    fn test_serialize_regex_flag_combinations_distinct() {
906        let no_flags = serialize_query(&build_regex_ast(RegexFlags {
907            case_insensitive: false,
908            multiline: false,
909            dot_all: false,
910        }));
911        let with_i = serialize_query(&build_regex_ast(RegexFlags {
912            case_insensitive: true,
913            multiline: false,
914            dot_all: false,
915        }));
916        let with_m = serialize_query(&build_regex_ast(RegexFlags {
917            case_insensitive: false,
918            multiline: true,
919            dot_all: false,
920        }));
921        let with_s = serialize_query(&build_regex_ast(RegexFlags {
922            case_insensitive: false,
923            multiline: false,
924            dot_all: true,
925        }));
926        let with_all = serialize_query(&build_regex_ast(RegexFlags {
927            case_insensitive: true,
928            multiline: true,
929            dot_all: true,
930        }));
931
932        assert_ne!(
933            no_flags, with_i,
934            "Cache collision: no flags vs case_insensitive"
935        );
936        assert_ne!(no_flags, with_m, "Cache collision: no flags vs multiline");
937        assert_ne!(no_flags, with_s, "Cache collision: no flags vs dot_all");
938        assert_ne!(
939            with_i, with_m,
940            "Cache collision: case_insensitive vs multiline"
941        );
942        assert_ne!(
943            with_i, with_all,
944            "Cache collision: case_insensitive vs all flags"
945        );
946    }
947
948    // D3 Advanced Query Feature tests: serialization
949
950    #[test]
951    fn test_serialize_variable() {
952        let ast = QueryAST {
953            root: Expr::Condition(Condition {
954                field: Field::new("kind"),
955                operator: Operator::Equal,
956                value: Value::Variable("type".to_string()),
957                span: Span::new(0, 10),
958            }),
959            span: Span::new(0, 10),
960        };
961        let serialized = serialize_query(&ast);
962        assert_eq!(serialized, "kind:$type");
963    }
964
965    #[test]
966    fn test_serialize_join() {
967        use crate::query::types::JoinEdgeKind;
968
969        let ast = QueryAST {
970            root: Expr::Join(crate::query::types::JoinExpr {
971                left: Box::new(Expr::Condition(Condition {
972                    field: Field::new("kind"),
973                    operator: Operator::Equal,
974                    value: Value::String("function".to_string()),
975                    span: Span::new(0, 13),
976                })),
977                edge: JoinEdgeKind::Calls,
978                right: Box::new(Expr::Condition(Condition {
979                    field: Field::new("kind"),
980                    operator: Operator::Equal,
981                    value: Value::String("function".to_string()),
982                    span: Span::new(20, 33),
983                })),
984                span: Span::new(0, 33),
985            }),
986            span: Span::new(0, 33),
987        };
988        let serialized = serialize_query(&ast);
989        assert_eq!(serialized, "(kind:function) CALLS (kind:function)");
990    }
991}