Skip to main content

sqry_db/planner/
ir.rs

1//! Query plan intermediate representation.
2//!
3//! The IR is **snapshot-independent**: no `StringId`, `NodeId`, or other
4//! interner-bound handles appear anywhere. This means a single plan can be
5//! compiled once and evaluated against any compatible graph snapshot, and two
6//! plans constructed independently but with identical semantics hash to the
7//! same value — a property the fuser in [`super::fuse`] relies on.
8//!
9//! # Types in this module
10//!
11//! | Type | Role |
12//! |------|------|
13//! | [`QueryPlan`] | Top-level wrapper produced by [`super::compile::QueryBuilder`] (DB10). |
14//! | [`PlanNode`] | Recursive operator tree. Every node produces a node-set. |
15//! | [`Direction`] | Edge traversal direction (outgoing/incoming/both). |
16//! | [`SetOperation`] | Pairwise set algebra (union/intersect/difference). |
17//! | [`Predicate`] | Filter condition — existence checks, relation predicates, boolean combinators. |
18//! | [`PredicateValue`] | Right-hand side of a relation predicate: pattern, regex, or nested subquery. |
19//! | [`StringPattern`] | Name-matching pattern with [`MatchMode`] discriminator. |
20//! | [`PathPattern`] | File-path glob matching, always glob-based. |
21//! | [`RegexPattern`] | Regex source + compilation flags. |
22//! | [`MatchMode`] | Exact / Glob / Prefix / Suffix / Contains modes for [`StringPattern`]. |
23//!
24//! # Design Notes
25//!
26//! ## Edge kind filtering
27//!
28//! [`PlanNode::EdgeTraversal`] carries `edge_kind: Option<EdgeKind>`, matching
29//! the spec (§3). Because [`EdgeKind`] contains metadata fields that are
30//! irrelevant to traversal (e.g., `Calls { argument_count, is_async }`), the
31//! [`QueryBuilder`] is expected to construct [`EdgeKind`] values with zeroed
32//! metadata (e.g., `EdgeKind::Calls { argument_count: 0, is_async: false }`)
33//! so that plan hashing remains stable for the fuser. The executor matches
34//! by `std::mem::discriminant`, matching the pattern used by
35//! [`crate::queries::ReachabilityQuery`].
36//!
37//! ## Scope filtering
38//!
39//! [`Predicate::InScope`] re-uses [`ScopeKind`] from the binding plane
40//! (`sqry-core::graph::unified::bind::scope::arena::ScopeKind`). The existing
41//! enum is already `Copy`, `Hash`, `Eq`, `Serialize`, `Deserialize`, so no
42//! wrapper type is introduced.
43//!
44//! ## Subqueries
45//!
46//! [`PredicateValue::Subquery`] is boxed to break the `Predicate`→`PlanNode`
47//! recursion cycle. Subqueries are evaluated on-demand by the executor,
48//! and their results are cached through [`crate::QueryDb::get`].
49//!
50//! ## Serialization
51//!
52//! Every IR type derives `Serialize` and `Deserialize`. This is required so
53//! that plans can be fingerprinted for the fuser, embedded in structured log
54//! output, and (in DB22) persisted in `.sqry/graph/derived.sqry` as part of
55//! hot-cache keys.
56//!
57//! [`EdgeKind`]: sqry_core::graph::unified::edge::kind::EdgeKind
58//! [`ScopeKind`]: sqry_core::graph::unified::bind::scope::arena::ScopeKind
59//! [`QueryBuilder`]: super::compile::QueryBuilder
60
61use serde::{Deserialize, Serialize};
62
63use sqry_core::graph::unified::bind::scope::arena::ScopeKind;
64use sqry_core::graph::unified::edge::kind::EdgeKind;
65use sqry_core::graph::unified::node::kind::NodeKind;
66use sqry_core::schema::Visibility;
67
68/// Top-level query plan produced by the compiler.
69///
70/// A plan is a rooted tree of [`PlanNode`] operators. The executor walks the
71/// tree from the root, evaluating each node's output into a node-set and
72/// feeding it forward through [`PlanNode::Chain`] and [`PlanNode::SetOp`]
73/// combinators.
74///
75/// The thin wrapper exists so that:
76///
77/// - the fuser in [`super::fuse`] can operate on a list of `QueryPlan`s
78///   without confusion about which `PlanNode` is the root,
79/// - plan-level metadata can be added later (e.g., execution hints, limits)
80///   without touching every [`PlanNode`] variant.
81#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
82pub struct QueryPlan {
83    /// The root operator of the plan tree.
84    pub root: PlanNode,
85}
86
87impl QueryPlan {
88    /// Creates a new plan wrapping the given root operator.
89    #[must_use]
90    pub fn new(root: PlanNode) -> Self {
91        Self { root }
92    }
93
94    /// Returns a reference to the plan's root operator.
95    #[inline]
96    #[must_use]
97    pub fn root(&self) -> &PlanNode {
98        &self.root
99    }
100
101    /// Returns the total number of [`PlanNode`] operators in the tree,
102    /// counting every node exactly once.
103    ///
104    /// Useful for plan-complexity metrics, fusion heuristics, and tests.
105    #[must_use]
106    pub fn operator_count(&self) -> usize {
107        self.root.operator_count()
108    }
109}
110
111/// Operator tree for a structural query.
112///
113/// Every variant produces a node-set. Consumers treat the resulting
114/// `Vec<NodeId>` as a sorted, deduplicated sequence.
115///
116/// # Variant semantics
117///
118/// - [`PlanNode::NodeScan`] scans the graph and emits every node matching the
119///   supplied filters. This is the only variant without an input set.
120/// - [`PlanNode::EdgeTraversal`] requires an input set (supplied by the
121///   enclosing [`PlanNode::Chain`]) and follows edges from those nodes.
122/// - [`PlanNode::Filter`] narrows its input set by evaluating the predicate.
123/// - [`PlanNode::SetOp`] evaluates two sub-plans independently and combines
124///   the results. Neither side inherits an input set from an enclosing chain.
125/// - [`PlanNode::Chain`] threads the output of each step into the next step's
126///   input. The first step must be context-free (a `NodeScan` or `SetOp`).
127#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
128#[serde(rename_all = "snake_case")]
129pub enum PlanNode {
130    /// Scan all nodes matching the given filters.
131    ///
132    /// Each filter is an optional narrowing condition; `None` means
133    /// "accept any value". `kind = None`, `visibility = None`,
134    /// `name_pattern = None` yields a full node scan.
135    NodeScan {
136        /// Optional kind filter (Function, Method, Class, ...).
137        kind: Option<NodeKind>,
138        /// Optional visibility filter (Public, Private).
139        visibility: Option<Visibility>,
140        /// Optional symbol-name pattern filter.
141        name_pattern: Option<StringPattern>,
142    },
143    /// Follow edges outward from the input set.
144    ///
145    /// `edge_kind = None` matches any edge kind. `max_depth = 1` performs a
146    /// single hop; values greater than one perform a bounded BFS.
147    EdgeTraversal {
148        /// Direction in which to follow edges.
149        direction: Direction,
150        /// Optional edge-kind filter. See module docs for how the executor
151        /// compares variants by discriminant.
152        edge_kind: Option<EdgeKind>,
153        /// Maximum hops to follow. Must be `>= 1` to produce any output.
154        max_depth: u32,
155    },
156    /// Narrow the input set with a predicate.
157    Filter {
158        /// The predicate to evaluate for each input node.
159        predicate: Predicate,
160    },
161    /// Combine two independently-evaluated sub-plans with a set operation.
162    SetOp {
163        /// The set operation: Union, Intersect, or Difference.
164        op: SetOperation,
165        /// Left-hand operand sub-plan.
166        left: Box<PlanNode>,
167        /// Right-hand operand sub-plan.
168        right: Box<PlanNode>,
169    },
170    /// Pipeline a sequence of steps, threading each step's output into the
171    /// next step's input.
172    ///
173    /// The first step must be context-free (a [`PlanNode::NodeScan`] or
174    /// [`PlanNode::SetOp`]); subsequent steps are applied to the running set.
175    Chain {
176        /// Steps to evaluate in order. An empty vec yields the empty set.
177        steps: Vec<PlanNode>,
178    },
179}
180
181impl PlanNode {
182    /// Returns the total number of operators in this subtree.
183    ///
184    /// Counts this node plus all descendants through
185    /// [`PlanNode::SetOp::left`], [`PlanNode::SetOp::right`], and
186    /// [`PlanNode::Chain::steps`]. [`PlanNode::Filter`]'s predicate tree
187    /// is *not* counted here — predicate trees have their own complexity.
188    #[must_use]
189    pub fn operator_count(&self) -> usize {
190        match self {
191            PlanNode::NodeScan { .. }
192            | PlanNode::EdgeTraversal { .. }
193            | PlanNode::Filter { .. } => 1,
194            PlanNode::SetOp { left, right, .. } => {
195                1 + left.operator_count() + right.operator_count()
196            }
197            PlanNode::Chain { steps } => {
198                1 + steps.iter().map(PlanNode::operator_count).sum::<usize>()
199            }
200        }
201    }
202
203    /// Returns `true` if this node has no input dependency (can be evaluated
204    /// in isolation).
205    ///
206    /// Used by the compiler to validate [`PlanNode::Chain::steps`] — the
207    /// first step must be context-free.
208    #[must_use]
209    pub fn is_context_free(&self) -> bool {
210        matches!(self, PlanNode::NodeScan { .. } | PlanNode::SetOp { .. })
211    }
212}
213
214/// Direction of an edge traversal relative to the input node set.
215///
216/// # Semantics
217///
218/// - [`Direction::Forward`] — follow outgoing edges (`source -> target`).
219///   `callees:X` is a forward traversal of `Calls` edges from `X`.
220/// - [`Direction::Reverse`] — follow incoming edges (`target -> source`).
221///   `callers:X` is a reverse traversal of `Calls` edges into `X`.
222/// - [`Direction::Both`] — follow edges in both directions. Useful for
223///   symmetric relations and for impact analysis that should include both
224///   callers and callees.
225#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
226#[serde(rename_all = "snake_case")]
227pub enum Direction {
228    /// Follow outgoing edges (`source -> target`).
229    Forward,
230    /// Follow incoming edges (`target -> source`).
231    Reverse,
232    /// Follow edges in both directions.
233    Both,
234}
235
236impl Direction {
237    /// Returns the inverse direction.
238    ///
239    /// `Forward <-> Reverse`, `Both` is its own inverse.
240    #[must_use]
241    pub const fn invert(self) -> Self {
242        match self {
243            Direction::Forward => Direction::Reverse,
244            Direction::Reverse => Direction::Forward,
245            Direction::Both => Direction::Both,
246        }
247    }
248}
249
250/// Set algebra on two node-set results.
251///
252/// All three operators are commutative in the output set, but order is
253/// preserved in the IR so that the executor can stream the smaller side
254/// first in the fuser pass (DB11).
255#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
256#[serde(rename_all = "snake_case")]
257pub enum SetOperation {
258    /// Union: `left ∪ right`.
259    Union,
260    /// Intersection: `left ∩ right`.
261    Intersect,
262    /// Difference: `left \ right` (elements in left but not right).
263    Difference,
264}
265
266/// Filter condition applied to a node-set.
267///
268/// Predicates fall into four groups:
269///
270/// 1. **Existence checks**: [`Predicate::HasCaller`], [`Predicate::HasCallee`],
271///    [`Predicate::IsUnused`]. Cheap — no value or subquery traversal needed.
272/// 2. **Value-bearing relation predicates**: [`Predicate::Callers`] through
273///    [`Predicate::Implements`]. These accept a [`PredicateValue`] on the
274///    right-hand side — a literal pattern, a regex, or a nested subquery.
275///    They map 1:1 to the six relation handlers in
276///    `sqry-core::query::executor::graph_eval` (`match_callers`,
277///    `match_callees`, `match_imports`, `match_exports`, `match_references`,
278///    `match_implements`) and their `_subquery` variants.
279/// 3. **Attribute filters**: [`Predicate::InFile`], [`Predicate::InScope`],
280///    [`Predicate::MatchesName`].
281/// 4. **Boolean combinators**: [`Predicate::And`], [`Predicate::Or`],
282///    [`Predicate::Not`].
283///
284/// # Semantic alignment with `graph_eval`
285///
286/// The six relation variants exactly mirror the value-bearing operators in
287/// `sqry-core::query::types::Value`:
288///
289/// | This IR | `graph_eval` handler | Text syntax |
290/// |---------|----------------------|-------------|
291/// | `Callers(v)`     | `match_callers` / `match_callers_subquery`       | `callers:v`    |
292/// | `Callees(v)`     | `match_callees` / `match_callees_subquery`       | `callees:v`    |
293/// | `Imports(v)`     | `match_imports` / `match_imports_subquery`       | `imports:v`    |
294/// | `Exports(v)`     | `match_exports` / `match_exports_subquery`       | `exports:v`    |
295/// | `References(v)`  | `match_references` / `match_references_subquery` | `references:v` (supports `~=` regex) |
296/// | `Implements(v)`  | `match_implements` / `match_implements_subquery` | `impl:v` / `implements:v` |
297///
298/// The text frontend in DB13 must accept both `impl:` and `implements:`
299/// aliases for [`Predicate::Implements`] (spec §M8).
300#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
301#[serde(rename_all = "snake_case")]
302pub enum Predicate {
303    // --- Existence checks ---
304    /// True iff the node has at least one incoming `Calls` edge.
305    HasCaller,
306    /// True iff the node has at least one outgoing `Calls` edge.
307    HasCallee,
308    /// True iff the node is not reachable from any entry point.
309    IsUnused,
310
311    // --- Value-bearing relation predicates ---
312    /// `callers:<value>`: node's callers match the pattern / subquery.
313    Callers(PredicateValue),
314    /// `callees:<value>`: node's callees match the pattern / subquery.
315    Callees(PredicateValue),
316    /// `imports:<value>`: node's imports match the pattern / subquery.
317    Imports(PredicateValue),
318    /// `exports:<value>`: node's exports match the pattern / subquery.
319    Exports(PredicateValue),
320    /// `references:<value>` (also supports `~=` regex via
321    /// [`PredicateValue::Regex`]).
322    References(PredicateValue),
323    /// `impl:<value>` / `implements:<value>`: matches nodes implementing
324    /// the referenced trait / interface.
325    Implements(PredicateValue),
326
327    // --- Attribute filters ---
328    /// `in:<path-glob>`: true iff the node's file path matches the glob.
329    InFile(PathPattern),
330    /// `scope:<kind>`: true iff the node's enclosing scope kind matches.
331    InScope(ScopeKind),
332    /// `name:<pattern>`: true iff the node's name matches the pattern.
333    MatchesName(StringPattern),
334
335    // --- Boolean combinators ---
336    /// Logical AND over a list of predicates. Empty list is vacuously true.
337    And(Vec<Predicate>),
338    /// Logical OR over a list of predicates. Empty list is vacuously false.
339    Or(Vec<Predicate>),
340    /// Logical NOT of a single predicate.
341    Not(Box<Predicate>),
342}
343
344impl Predicate {
345    /// Returns `true` if this predicate (or any nested predicate through
346    /// boolean combinators) references a [`PredicateValue::Subquery`].
347    ///
348    /// The executor uses this hint to decide whether to allocate subquery
349    /// evaluation scratch space up front.
350    #[must_use]
351    pub fn has_subquery(&self) -> bool {
352        match self {
353            Predicate::HasCaller
354            | Predicate::HasCallee
355            | Predicate::IsUnused
356            | Predicate::InFile(_)
357            | Predicate::InScope(_)
358            | Predicate::MatchesName(_) => false,
359
360            Predicate::Callers(v)
361            | Predicate::Callees(v)
362            | Predicate::Imports(v)
363            | Predicate::Exports(v)
364            | Predicate::References(v)
365            | Predicate::Implements(v) => v.is_subquery(),
366
367            Predicate::And(list) | Predicate::Or(list) => list.iter().any(Predicate::has_subquery),
368            Predicate::Not(inner) => inner.has_subquery(),
369        }
370    }
371}
372
373/// Right-hand side of a relation predicate.
374///
375/// Mirrors `sqry-core::query::types::Value`'s String / Regex / Subquery
376/// arms that actually flow into the six relation handlers. The IR variant
377/// is deliberately narrower than the general-purpose [`Value`] — numeric
378/// and boolean values never appear on the right of a relation predicate,
379/// so they are omitted here.
380///
381/// [`Value`]: sqry_core::query::types::Value
382#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
383#[serde(rename_all = "snake_case")]
384pub enum PredicateValue {
385    /// Literal string pattern — exact match, glob, prefix, suffix, or
386    /// contains. Maps to `Operator::Equal` in `graph_eval`.
387    Pattern(StringPattern),
388    /// Regex pattern. Maps to `Operator::Regex` in `graph_eval`.
389    ///
390    /// Only [`Predicate::References`] uses this in the current text
391    /// syntax (`references ~= /foo.*/i`), but any relation predicate
392    /// may accept it at the IR level.
393    Regex(RegexPattern),
394    /// Nested subquery: `callers:(kind:function AND async:true)`.
395    /// Evaluated as a distinct [`PlanNode`] before the outer relation
396    /// predicate runs.
397    Subquery(Box<PlanNode>),
398}
399
400impl PredicateValue {
401    /// Returns `true` if this value is a [`PredicateValue::Subquery`].
402    #[must_use]
403    pub const fn is_subquery(&self) -> bool {
404        matches!(self, PredicateValue::Subquery(_))
405    }
406
407    /// Returns the inner [`PlanNode`] if this value is a subquery.
408    #[must_use]
409    pub fn as_subquery(&self) -> Option<&PlanNode> {
410        match self {
411            PredicateValue::Subquery(plan) => Some(plan),
412            _ => None,
413        }
414    }
415}
416
417/// Symbol-name matching pattern.
418///
419/// The [`MatchMode`] discriminator selects between exact, glob, prefix,
420/// suffix, and contains semantics. The raw pattern is kept as a `String`
421/// so the executor can compile it lazily.
422///
423/// # Why not `Arc<str>`?
424///
425/// The IR is a data description — compact and easy to clone. Patterns are
426/// small (typically under 64 bytes), and cloning the IR is rare (once per
427/// plan submission). The `Arc<str>` savings do not justify the added
428/// complexity at this layer.
429#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
430pub struct StringPattern {
431    /// The raw pattern string.
432    pub raw: String,
433    /// How the pattern should be interpreted by the executor.
434    pub mode: MatchMode,
435    /// Whether matching is case-insensitive. Defaults to `false` (case
436    /// sensitive) to match `graph_eval` semantics.
437    #[serde(default)]
438    pub case_insensitive: bool,
439}
440
441impl StringPattern {
442    /// Creates an exact-match pattern (case-sensitive).
443    #[must_use]
444    pub fn exact(raw: impl Into<String>) -> Self {
445        Self {
446            raw: raw.into(),
447            mode: MatchMode::Exact,
448            case_insensitive: false,
449        }
450    }
451
452    /// Creates a glob pattern (case-sensitive).
453    #[must_use]
454    pub fn glob(raw: impl Into<String>) -> Self {
455        Self {
456            raw: raw.into(),
457            mode: MatchMode::Glob,
458            case_insensitive: false,
459        }
460    }
461
462    /// Creates a prefix-match pattern (case-sensitive).
463    #[must_use]
464    pub fn prefix(raw: impl Into<String>) -> Self {
465        Self {
466            raw: raw.into(),
467            mode: MatchMode::Prefix,
468            case_insensitive: false,
469        }
470    }
471
472    /// Creates a suffix-match pattern (case-sensitive).
473    #[must_use]
474    pub fn suffix(raw: impl Into<String>) -> Self {
475        Self {
476            raw: raw.into(),
477            mode: MatchMode::Suffix,
478            case_insensitive: false,
479        }
480    }
481
482    /// Creates a substring-contains pattern (case-sensitive).
483    #[must_use]
484    pub fn contains(raw: impl Into<String>) -> Self {
485        Self {
486            raw: raw.into(),
487            mode: MatchMode::Contains,
488            case_insensitive: false,
489        }
490    }
491
492    /// Returns a case-insensitive copy of this pattern.
493    #[must_use]
494    pub fn case_insensitive(mut self) -> Self {
495        self.case_insensitive = true;
496        self
497    }
498}
499
500/// Match semantics for a [`StringPattern`].
501#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
502#[serde(rename_all = "snake_case")]
503pub enum MatchMode {
504    /// Exact string equality.
505    Exact,
506    /// Shell-style glob matching (`*`, `?`, `[abc]`).
507    Glob,
508    /// Prefix match (`str.starts_with(raw)`).
509    Prefix,
510    /// Suffix match (`str.ends_with(raw)`).
511    Suffix,
512    /// Substring match (`str.contains(raw)`).
513    Contains,
514}
515
516/// File-path matching pattern. Always interpreted as a shell-style glob,
517/// matching `graph_eval`'s `Operator::Equal` semantics on path fields.
518///
519/// The raw glob is kept as a `String`; the executor compiles the glob
520/// lazily using `globset` or the project's existing glob helpers.
521#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
522pub struct PathPattern {
523    /// The raw glob string, e.g. `src/**/*.rs`.
524    pub glob: String,
525}
526
527impl PathPattern {
528    /// Creates a new path glob pattern.
529    #[must_use]
530    pub fn new(glob: impl Into<String>) -> Self {
531        Self { glob: glob.into() }
532    }
533
534    /// Returns the raw glob string.
535    #[inline]
536    #[must_use]
537    pub fn as_str(&self) -> &str {
538        &self.glob
539    }
540}
541
542impl<S: Into<String>> From<S> for PathPattern {
543    fn from(s: S) -> Self {
544        Self::new(s)
545    }
546}
547
548/// Regex pattern with compilation flags.
549///
550/// Mirrors `sqry-core::query::types::RegexValue` so that `graph_eval`
551/// `Operator::Regex` semantics round-trip losslessly through the IR.
552/// Compilation is deferred to the executor — a failed regex compile is
553/// reported as a query error, not a plan-construction error.
554#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
555pub struct RegexPattern {
556    /// The regex source string.
557    pub pattern: String,
558    /// Flags that influence compilation.
559    #[serde(default)]
560    pub flags: RegexFlags,
561}
562
563impl RegexPattern {
564    /// Creates a new regex pattern with default flags.
565    #[must_use]
566    pub fn new(pattern: impl Into<String>) -> Self {
567        Self {
568            pattern: pattern.into(),
569            flags: RegexFlags::default(),
570        }
571    }
572
573    /// Creates a new regex pattern with the given flags.
574    #[must_use]
575    pub fn with_flags(pattern: impl Into<String>, flags: RegexFlags) -> Self {
576        Self {
577            pattern: pattern.into(),
578            flags,
579        }
580    }
581}
582
583/// Regex compilation flags, mirroring
584/// `sqry-core::query::types::RegexFlags`.
585///
586/// # Serialization
587///
588/// All three fields default to `false`; the `#[serde(default)]` attribute
589/// keeps the JSON encoding compact when the flags are at their defaults.
590#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
591pub struct RegexFlags {
592    /// Case-insensitive matching (regex flag `i`).
593    #[serde(default)]
594    pub case_insensitive: bool,
595    /// Multiline mode: `^` and `$` match line boundaries (regex flag `m`).
596    #[serde(default)]
597    pub multiline: bool,
598    /// Dot matches newlines (regex flag `s`).
599    #[serde(default)]
600    pub dot_all: bool,
601}
602
603// ============================================================================
604// Inline unit tests
605// ============================================================================
606
607#[cfg(test)]
608mod tests {
609    use super::*;
610
611    #[test]
612    fn query_plan_wraps_root() {
613        let root = PlanNode::NodeScan {
614            kind: Some(NodeKind::Function),
615            visibility: None,
616            name_pattern: None,
617        };
618        let plan = QueryPlan::new(root.clone());
619        assert_eq!(plan.root(), &root);
620    }
621
622    #[test]
623    fn operator_count_single_node() {
624        let scan = PlanNode::NodeScan {
625            kind: None,
626            visibility: None,
627            name_pattern: None,
628        };
629        assert_eq!(scan.operator_count(), 1);
630    }
631
632    #[test]
633    fn operator_count_nested_setop_and_chain() {
634        let scan = PlanNode::NodeScan {
635            kind: None,
636            visibility: None,
637            name_pattern: None,
638        };
639        let traverse = PlanNode::EdgeTraversal {
640            direction: Direction::Forward,
641            edge_kind: None,
642            max_depth: 1,
643        };
644        let set = PlanNode::SetOp {
645            op: SetOperation::Union,
646            left: Box::new(scan.clone()),
647            right: Box::new(scan.clone()),
648        };
649        let chain = PlanNode::Chain {
650            steps: vec![set.clone(), traverse],
651        };
652        // chain(1) + setop(1) + scan(1) + scan(1) + traverse(1) = 5
653        assert_eq!(chain.operator_count(), 5);
654        assert_eq!(set.operator_count(), 3);
655    }
656
657    #[test]
658    fn context_free_variants() {
659        let scan = PlanNode::NodeScan {
660            kind: None,
661            visibility: None,
662            name_pattern: None,
663        };
664        let setop = PlanNode::SetOp {
665            op: SetOperation::Intersect,
666            left: Box::new(scan.clone()),
667            right: Box::new(scan.clone()),
668        };
669        let traverse = PlanNode::EdgeTraversal {
670            direction: Direction::Reverse,
671            edge_kind: None,
672            max_depth: 1,
673        };
674        let filter = PlanNode::Filter {
675            predicate: Predicate::HasCaller,
676        };
677
678        assert!(scan.is_context_free());
679        assert!(setop.is_context_free());
680        assert!(!traverse.is_context_free());
681        assert!(!filter.is_context_free());
682    }
683
684    #[test]
685    fn direction_invert_is_involution() {
686        assert_eq!(Direction::Forward.invert(), Direction::Reverse);
687        assert_eq!(Direction::Reverse.invert(), Direction::Forward);
688        assert_eq!(Direction::Both.invert(), Direction::Both);
689        for d in [Direction::Forward, Direction::Reverse, Direction::Both] {
690            assert_eq!(d.invert().invert(), d);
691        }
692    }
693
694    #[test]
695    fn predicate_has_subquery_detects_nested_calls() {
696        let leaf = Predicate::HasCaller;
697        assert!(!leaf.has_subquery());
698
699        let attr = Predicate::InFile(PathPattern::new("src/api/**"));
700        assert!(!attr.has_subquery());
701
702        let sub = Predicate::Callers(PredicateValue::Subquery(Box::new(PlanNode::NodeScan {
703            kind: Some(NodeKind::Method),
704            visibility: None,
705            name_pattern: None,
706        })));
707        assert!(sub.has_subquery());
708
709        let pattern = Predicate::Callers(PredicateValue::Pattern(StringPattern::exact("foo")));
710        assert!(!pattern.has_subquery());
711
712        let nested_in_and = Predicate::And(vec![leaf.clone(), sub.clone()]);
713        assert!(nested_in_and.has_subquery());
714
715        let nested_in_not = Predicate::Not(Box::new(sub));
716        assert!(nested_in_not.has_subquery());
717
718        let and_no_sub = Predicate::And(vec![leaf.clone(), attr.clone()]);
719        assert!(!and_no_sub.has_subquery());
720    }
721
722    #[test]
723    fn predicate_value_is_subquery() {
724        let plan = PlanNode::NodeScan {
725            kind: None,
726            visibility: None,
727            name_pattern: None,
728        };
729        let sub = PredicateValue::Subquery(Box::new(plan.clone()));
730        let pat = PredicateValue::Pattern(StringPattern::exact("foo"));
731        let re = PredicateValue::Regex(RegexPattern::new("^foo$"));
732
733        assert!(sub.is_subquery());
734        assert!(!pat.is_subquery());
735        assert!(!re.is_subquery());
736
737        assert_eq!(sub.as_subquery(), Some(&plan));
738        assert_eq!(pat.as_subquery(), None);
739        assert_eq!(re.as_subquery(), None);
740    }
741
742    #[test]
743    fn string_pattern_builders_preserve_raw() {
744        let raw = "parse_*";
745        assert_eq!(StringPattern::exact(raw).raw, raw);
746        assert_eq!(StringPattern::glob(raw).mode, MatchMode::Glob);
747        assert_eq!(StringPattern::prefix(raw).mode, MatchMode::Prefix);
748        assert_eq!(StringPattern::suffix(raw).mode, MatchMode::Suffix);
749        assert_eq!(StringPattern::contains(raw).mode, MatchMode::Contains);
750    }
751
752    #[test]
753    fn string_pattern_case_insensitive_toggle() {
754        let p = StringPattern::exact("Foo").case_insensitive();
755        assert!(p.case_insensitive);
756    }
757
758    #[test]
759    fn path_pattern_from_str_and_as_str() {
760        let p: PathPattern = "src/**/*.rs".into();
761        assert_eq!(p.as_str(), "src/**/*.rs");
762
763        let p2 = PathPattern::new(String::from("docs/**"));
764        assert_eq!(p2.as_str(), "docs/**");
765    }
766
767    #[test]
768    fn regex_pattern_default_flags_are_false() {
769        let r = RegexPattern::new("^foo$");
770        assert_eq!(r.pattern, "^foo$");
771        assert!(!r.flags.case_insensitive);
772        assert!(!r.flags.multiline);
773        assert!(!r.flags.dot_all);
774    }
775
776    #[test]
777    fn regex_pattern_with_flags() {
778        let flags = RegexFlags {
779            case_insensitive: true,
780            multiline: true,
781            dot_all: false,
782        };
783        let r = RegexPattern::with_flags("foo", flags);
784        assert!(r.flags.case_insensitive);
785        assert!(r.flags.multiline);
786        assert!(!r.flags.dot_all);
787    }
788}