Skip to main content

rsigma_convert/
backend.rs

1use std::collections::HashMap;
2use std::sync::Mutex;
3
4use rsigma_eval::pipeline::state::PipelineState;
5use rsigma_ir::{IrDetection, IrDetectionItem, IrPattern, IrPatternPart, IrStrOp};
6use rsigma_parser::*;
7
8use crate::error::{ConvertError, Result};
9use crate::state::{ConversionState, ConvertResult};
10
11/// Process-wide cache for compiled regexes keyed by pattern string.
12static REGEX_CACHE: Mutex<Option<HashMap<&'static str, regex::Regex>>> = Mutex::new(None);
13
14fn get_cached_regex(pattern: &'static str) -> Option<regex::Regex> {
15    let mut guard = REGEX_CACHE.lock().unwrap();
16    let cache = guard.get_or_insert_with(HashMap::new);
17    if let Some(re) = cache.get(pattern) {
18        return Some(re.clone());
19    }
20    match regex::Regex::new(pattern) {
21        Ok(re) => {
22            cache.insert(pattern, re.clone());
23            Some(re)
24        }
25        Err(_) => None,
26    }
27}
28
29// =============================================================================
30// Token precedence
31// =============================================================================
32
33/// Boolean operator token type, used for precedence-aware grouping.
34#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
35pub enum TokenType {
36    /// Highest precedence (binds tightest).
37    NOT = 0,
38    AND = 1,
39    OR = 2,
40}
41
42/// Numeric comparison operator for IR-native field comparison.
43#[derive(Debug, Clone, Copy, PartialEq, Eq)]
44pub enum CompareOp {
45    Gt,
46    Gte,
47    Lt,
48    Lte,
49}
50
51/// Regex match flags for [`Backend::convert_field_regex`].
52///
53/// A Sigma regex is case-sensitive unless it has the `|i` flag
54/// (`case_insensitive`). `cased` records a redundant `|cased` modifier, which
55/// pySigma rejects on a regex.
56#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
57pub struct RegexFlags {
58    pub case_insensitive: bool,
59    pub multiline: bool,
60    pub dotall: bool,
61    pub cased: bool,
62}
63
64impl RegexFlags {
65    /// The `i`, `m`, and `s` flags as an inline group such as `(?im)`, or an
66    /// empty string when none is set. RE2, Rust `regex`, PCRE, and Python
67    /// read this prefix the same way.
68    pub fn inline_prefix(&self) -> String {
69        let flags: String = [
70            (self.case_insensitive, 'i'),
71            (self.multiline, 'm'),
72            (self.dotall, 's'),
73        ]
74        .into_iter()
75        .filter_map(|(set, flag)| set.then_some(flag))
76        .collect();
77        if flags.is_empty() {
78            flags
79        } else {
80            format!("(?{flags})")
81        }
82    }
83}
84
85/// Reconstruct a parser `SigmaString` from a faithful [`IrPattern`].
86///
87/// The canonical implementation lives in `rsigma-ir` (shared with the reverse
88/// raise path); it is re-exported here so backends keep referring to
89/// `crate::backend::ir_pattern_to_sigma`.
90pub(crate) use rsigma_ir::ir_pattern_to_sigma;
91
92/// Reject a value containing a NUL character (for example from `|wide`
93/// without `|base64`), which `backend` cannot carry in its query text.
94pub(crate) fn reject_nul(backend: &str, value: &str) -> Result<()> {
95    if value.contains('\0') {
96        return Err(ConvertError::UnsupportedValue(format!(
97            "{backend} cannot represent a NUL character in a query value; \
98             combine |wide/|utf16 with |base64 or |base64offset"
99        )));
100    }
101    Ok(())
102}
103
104/// [`reject_nul`] over the literal parts of an [`IrPattern`].
105pub(crate) fn reject_nul_pattern(backend: &str, pattern: &IrPattern) -> Result<()> {
106    pattern.parts.iter().try_for_each(|part| match part {
107        IrPatternPart::Literal(s) => reject_nul(backend, s),
108        _ => Ok(()),
109    })
110}
111
112// =============================================================================
113// Backend trait
114// =============================================================================
115
116/// Core conversion trait.
117///
118/// Backends implement this to convert a rule's intermediate representation
119/// (`rsigma-ir`) into backend-native query strings. The value leaves consume
120/// the faithful HIR (`IrPattern`, `IrStrOp`, `IrNumber`, `RegexFlags`,
121/// `CompareOp`) rather than parser types or compiled matchers, so a backend
122/// never touches `rsigma-parser` to emit a value match.
123pub trait Backend: Send + Sync {
124    fn name(&self) -> &str;
125    fn formats(&self) -> &[(&str, &str)];
126
127    fn default_format(&self) -> &str {
128        "default"
129    }
130
131    fn requires_pipeline(&self) -> bool {
132        false
133    }
134
135    // --- Detection rule conversion ---
136
137    fn convert_rule(
138        &self,
139        rule: &SigmaRule,
140        output_format: &str,
141        pipeline_state: &PipelineState,
142    ) -> Result<Vec<String>>;
143
144    // --- Condition combinators ---
145
146    fn convert_condition_and(&self, exprs: &[String]) -> Result<String>;
147    fn convert_condition_or(&self, exprs: &[String]) -> Result<String>;
148    fn convert_condition_not(&self, expr: &str) -> Result<String>;
149
150    /// Group `expr`, an operand whose top-level operator is `inner`, for use
151    /// under `outer`.
152    ///
153    /// The condition and detection walkers call this for every compound
154    /// operand before passing it to a combinator. The default parenthesizes
155    /// when `inner` binds looser than `outer` under the standard precedence
156    /// (`NOT` > `AND` > `OR`), and always under `NOT`, as pySigma does.
157    fn convert_condition_group(
158        &self,
159        expr: &str,
160        outer: TokenType,
161        inner: TokenType,
162    ) -> Result<String> {
163        if outer == TokenType::NOT || inner > outer {
164            Ok(format!("({expr})"))
165        } else {
166            Ok(expr.to_string())
167        }
168    }
169
170    /// Negate a field-to-field comparison.
171    ///
172    /// `|neq` negates the whole item, so the negation matches when either
173    /// field is missing. The default is [`Backend::convert_condition_not`].
174    /// PostgreSQL overrides this because `NOT (NULL)` is not true.
175    fn convert_negated_field_ref(&self, _field: &str, expr: &str) -> Result<String> {
176        self.convert_condition_not(expr)
177    }
178
179    /// Whether this backend can lower a positional array index (`field[N]`) in
180    /// a field path. Backends that cannot must not silently emit a literal
181    /// field reference (which would diverge from the evaluator's element-`N`
182    /// semantics); the default item conversion rejects indexed fields with
183    /// `UnsupportedArrayMatching`. PostgreSQL overrides this for JSONB mode.
184    fn supports_field_index(&self) -> bool {
185        false
186    }
187
188    // --- IR-native detection dispatch ---
189
190    /// Convert a lowered [`IrDetection`] into a query fragment.
191    fn convert_ir_detection(
192        &self,
193        det: &IrDetection,
194        state: &mut ConversionState,
195    ) -> Result<String> {
196        crate::ir_convert::default_convert_ir_detection(self, det, state)
197    }
198
199    /// Convert a single lowered [`IrDetectionItem`] into a query fragment.
200    fn convert_ir_detection_item(
201        &self,
202        item: &IrDetectionItem,
203        state: &mut ConversionState,
204    ) -> Result<String> {
205        crate::ir_convert::default_convert_ir_detection_item(self, item, state)
206    }
207
208    /// Convert an array object-scope match (`field[any]:` / `field[all]:`) over
209    /// a lowered body. Default reports the construct unsupported; JSONB-capable
210    /// backends override it.
211    fn convert_ir_array_match(
212        &self,
213        field: &str,
214        quantifier: ArrayQuantifier,
215        body: &IrDetection,
216        state: &mut ConversionState,
217    ) -> Result<String> {
218        let _ = (field, quantifier, body, state);
219        Err(ConvertError::UnsupportedArrayMatching)
220    }
221
222    // --- Field/value escaping ---
223
224    fn escape_and_quote_field(&self, field: &str) -> String;
225
226    // --- Value-type-specific leaves (IR-native) ---
227    //
228    // These consume the faithful HIR (`IrPattern` / `IrStrOp` / flags) with no
229    // parser types, so a backend (or a pack renderer) never touches
230    // `rsigma-parser` to emit a value match.
231
232    /// String match over a wildcard-aware, original-case pattern.
233    fn convert_field_str(
234        &self,
235        field: &str,
236        op: IrStrOp,
237        pattern: &IrPattern,
238        case_insensitive: bool,
239        state: &mut ConversionState,
240    ) -> Result<ConvertResult>;
241
242    /// Numeric equality.
243    fn convert_field_eq_num(
244        &self,
245        field: &str,
246        value: f64,
247        state: &mut ConversionState,
248    ) -> Result<String>;
249
250    /// Boolean equality.
251    fn convert_field_eq_bool(
252        &self,
253        field: &str,
254        value: bool,
255        state: &mut ConversionState,
256    ) -> Result<String>;
257
258    /// Null match.
259    fn convert_field_eq_null(&self, field: &str, state: &mut ConversionState) -> Result<String>;
260
261    /// Regex match with raw pattern and explicit [`RegexFlags`].
262    fn convert_field_regex(
263        &self,
264        field: &str,
265        pattern: &str,
266        flags: RegexFlags,
267        state: &mut ConversionState,
268    ) -> Result<ConvertResult>;
269
270    /// CIDR containment match.
271    fn convert_field_eq_cidr(
272        &self,
273        field: &str,
274        cidr: &str,
275        state: &mut ConversionState,
276    ) -> Result<ConvertResult>;
277
278    /// Field existence / non-existence.
279    fn convert_field_exists(
280        &self,
281        field: &str,
282        exists: bool,
283        state: &mut ConversionState,
284    ) -> Result<String>;
285
286    /// Backend-specific query-expression placeholder substitution.
287    fn convert_field_eq_query_expr(
288        &self,
289        field: &str,
290        expr: &str,
291        id: &str,
292        state: &mut ConversionState,
293    ) -> Result<String>;
294
295    /// Field-to-field comparison (`|fieldref`, optionally with
296    /// `contains`, `startswith`, or `endswith`).
297    fn convert_field_ref(
298        &self,
299        field1: &str,
300        field2: &str,
301        op: IrStrOp,
302        case_insensitive: bool,
303        state: &mut ConversionState,
304    ) -> Result<ConvertResult>;
305
306    /// Numeric comparison (`gt`/`gte`/`lt`/`lte`).
307    fn convert_field_compare_op(
308        &self,
309        field: &str,
310        op: CompareOp,
311        value: f64,
312        state: &mut ConversionState,
313    ) -> Result<String>;
314
315    /// Keyword (unbound) string term over a wildcard-aware pattern.
316    fn convert_keyword_str(
317        &self,
318        pattern: &IrPattern,
319        state: &mut ConversionState,
320    ) -> Result<String>;
321
322    /// Keyword (unbound) numeric term.
323    fn convert_keyword_num(&self, value: f64, state: &mut ConversionState) -> Result<String>;
324
325    // --- Query finalization ---
326
327    fn finish_query(
328        &self,
329        rule: &SigmaRule,
330        query: String,
331        state: &ConversionState,
332    ) -> Result<String>;
333
334    fn finalize_query(
335        &self,
336        rule: &SigmaRule,
337        query: String,
338        index: usize,
339        state: &ConversionState,
340        output_format: &str,
341    ) -> Result<String>;
342
343    fn finalize_output(&self, queries: Vec<String>, output_format: &str) -> Result<String>;
344
345    /// File extension (no leading dot) for per-rule output files when
346    /// `rsigma backend convert` writes one file per rule into a directory.
347    ///
348    /// The default is `txt`; backends override it so the split files land
349    /// with the extension their target loader expects (`sql` for
350    /// PostgreSQL, `yml` for Fibratus rule YAML). The `output_format`
351    /// argument lets a backend pick a different extension per format (e.g.
352    /// Fibratus emits `.txt` for the bare-expression `expr` format and
353    /// `.yml` for the YAML rule envelope).
354    fn output_file_extension(&self, _output_format: &str) -> &str {
355        "txt"
356    }
357
358    // --- Correlation rule conversion (optional) ---
359
360    fn supports_correlation(&self) -> bool {
361        false
362    }
363
364    /// Correlation generation methods this backend offers, as
365    /// `(name, description)` pairs, mirroring pySigma's `correlation_methods`.
366    ///
367    /// The converting user selects one with the `correlation_method` backend
368    /// option, which overrides a rule's own `window` hint for that conversion.
369    /// An empty slice (the default) means the backend exposes no per-conversion
370    /// choice.
371    fn correlation_methods(&self) -> &[(&str, &str)] {
372        &[]
373    }
374
375    /// The correlation method used when the converting user selects none.
376    fn default_correlation_method(&self) -> &str {
377        "default"
378    }
379
380    /// Convert a correlation rule, discarding any non-fatal warnings.
381    ///
382    /// Convenience wrapper over [`convert_correlation_rule_with_warnings`]; the
383    /// `convert_collection` entry point uses the warnings-aware form so it can
384    /// surface diagnostics. Backends should override the warnings-aware method,
385    /// not this one.
386    ///
387    /// [`convert_correlation_rule_with_warnings`]: Backend::convert_correlation_rule_with_warnings
388    fn convert_correlation_rule(
389        &self,
390        rule: &CorrelationRule,
391        output_format: &str,
392        pipeline_state: &PipelineState,
393    ) -> Result<Vec<String>> {
394        let mut warnings = Vec::new();
395        self.convert_correlation_rule_with_warnings(
396            rule,
397            output_format,
398            pipeline_state,
399            &mut warnings,
400        )
401    }
402
403    /// Convert a correlation rule, appending any non-fatal diagnostics to
404    /// `warnings`.
405    ///
406    /// A backend pushes a warning when it can only approximate a requested
407    /// feature but still emits a usable query (the Sigma "should warn" case),
408    /// and returns [`ConvertError`] when a feature cannot be represented at all
409    /// (the "must error" case).
410    fn convert_correlation_rule_with_warnings(
411        &self,
412        _rule: &CorrelationRule,
413        _output_format: &str,
414        _pipeline_state: &PipelineState,
415        _warnings: &mut Vec<String>,
416    ) -> Result<Vec<String>> {
417        Err(ConvertError::UnsupportedCorrelation(
418            "correlation rules not supported by this backend".into(),
419        ))
420    }
421}
422
423// =============================================================================
424// TextQueryConfig
425// =============================================================================
426
427/// Configuration tokens for text-based query backends.
428///
429/// Mirrors pySigma's `TextQueryBackend` class variables. Backends create a
430/// `const` or `static` instance of this struct and delegate to the
431/// `text_convert_*` free functions for the default conversion logic.
432pub struct TextQueryConfig {
433    // --- Precedence and grouping ---
434    pub precedence: (TokenType, TokenType, TokenType),
435    pub group_expression: &'static str,
436    pub token_separator: &'static str,
437
438    // --- Boolean operators ---
439    pub and_token: &'static str,
440    pub or_token: &'static str,
441    pub not_token: &'static str,
442    pub eq_token: &'static str,
443
444    // --- Negation expressions ---
445    pub not_eq_token: Option<&'static str>,
446    pub eq_expression: Option<&'static str>,
447    pub not_eq_expression: Option<&'static str>,
448    pub convert_not_as_not_eq: bool,
449
450    // --- Wildcards ---
451    pub wildcard_multi: &'static str,
452    pub wildcard_single: &'static str,
453
454    // --- String quoting and escaping ---
455    pub str_quote: &'static str,
456    pub str_quote_pattern: Option<&'static str>,
457    pub str_quote_pattern_negation: bool,
458    pub escape_char: &'static str,
459    pub add_escaped: &'static [&'static str],
460    pub filter_chars: &'static [&'static str],
461
462    // --- Field name quoting and escaping ---
463    pub field_quote: Option<&'static str>,
464    pub field_quote_pattern: Option<&'static str>,
465    pub field_quote_pattern_negation: bool,
466    pub field_escape: Option<&'static str>,
467    pub field_escape_pattern: Option<&'static str>,
468
469    // --- String match expressions ---
470    pub startswith_expression: Option<&'static str>,
471    pub not_startswith_expression: Option<&'static str>,
472    pub startswith_expression_allow_special: bool,
473    pub endswith_expression: Option<&'static str>,
474    pub not_endswith_expression: Option<&'static str>,
475    pub endswith_expression_allow_special: bool,
476    pub contains_expression: Option<&'static str>,
477    pub not_contains_expression: Option<&'static str>,
478    pub contains_expression_allow_special: bool,
479    pub wildcard_match_expression: Option<&'static str>,
480
481    // --- Case-sensitive match expressions ---
482    pub case_sensitive_match_expression: Option<&'static str>,
483    pub case_sensitive_startswith_expression: Option<&'static str>,
484    pub case_sensitive_endswith_expression: Option<&'static str>,
485    pub case_sensitive_contains_expression: Option<&'static str>,
486
487    // --- Regex ---
488    pub re_expression: Option<&'static str>,
489    pub not_re_expression: Option<&'static str>,
490    pub re_escape_char: Option<&'static str>,
491    pub re_escape: &'static [&'static str],
492    pub re_escape_escape_char: Option<&'static str>,
493
494    // --- CIDR ---
495    pub cidr_expression: Option<&'static str>,
496    pub not_cidr_expression: Option<&'static str>,
497
498    // --- Null / field existence ---
499    pub field_null_expression: &'static str,
500    pub field_exists_expression: Option<&'static str>,
501    pub field_not_exists_expression: Option<&'static str>,
502
503    // --- Compare operators ---
504    pub compare_op_expression: Option<&'static str>,
505    pub compare_ops: &'static [(&'static str, &'static str)],
506
507    // --- IN-list optimization ---
508    pub convert_or_as_in: bool,
509    pub convert_and_as_in: bool,
510    pub in_expressions_allow_wildcards: bool,
511    pub field_in_list_expression: Option<&'static str>,
512    pub or_in_operator: Option<&'static str>,
513    pub and_in_operator: Option<&'static str>,
514    pub list_separator: &'static str,
515
516    // --- Unbound/keyword ---
517    pub unbound_value_str_expression: Option<&'static str>,
518    pub unbound_value_num_expression: Option<&'static str>,
519    pub unbound_value_re_expression: Option<&'static str>,
520
521    // --- Field-to-field comparison ---
522    pub field_eq_field_expression: Option<&'static str>,
523    pub field_eq_field_escaping_quoting: bool,
524
525    // --- Deferred query parts ---
526    pub deferred_start: Option<&'static str>,
527    pub deferred_separator: Option<&'static str>,
528    pub deferred_only_query: &'static str,
529
530    // --- Bool values ---
531    pub bool_true: &'static str,
532    pub bool_false: &'static str,
533
534    // --- Query envelope ---
535    pub query_expression: &'static str,
536    pub state_defaults: &'static [(&'static str, &'static str)],
537}
538
539impl TextQueryConfig {
540    /// Check if `inner` needs parenthesisation when nested inside `outer`:
541    /// when it binds looser than `outer` under `precedence`, or when `outer`
542    /// is `NOT`.
543    pub fn needs_grouping(&self, outer: TokenType, inner: TokenType) -> bool {
544        if outer == TokenType::NOT {
545            return true;
546        }
547        let rank = |t: TokenType| -> u8 {
548            if t == self.precedence.0 {
549                0
550            } else if t == self.precedence.1 {
551                1
552            } else {
553                2
554            }
555        };
556        rank(inner) > rank(outer)
557    }
558}
559
560// =============================================================================
561// Text-backend free functions
562// =============================================================================
563
564/// Escape and optionally quote a field name according to the config.
565pub fn text_escape_and_quote_field(cfg: &TextQueryConfig, field: &str) -> String {
566    let mut escaped = field.to_string();
567
568    if let Some(esc) = cfg.field_escape
569        && let Some(pat) = cfg.field_escape_pattern
570        && let Some(re) = get_cached_regex(pat)
571    {
572        escaped = re
573            .replace_all(&escaped, |_: &regex::Captures| esc)
574            .to_string();
575    }
576
577    if let Some(quote) = cfg.field_quote {
578        let should_quote = match cfg.field_quote_pattern {
579            Some(pat) => {
580                let matches = get_cached_regex(pat)
581                    .map(|re| re.is_match(&escaped))
582                    .unwrap_or(false);
583                if cfg.field_quote_pattern_negation {
584                    !matches
585                } else {
586                    matches
587                }
588            }
589            None => true,
590        };
591        if should_quote {
592            return format!("{quote}{escaped}{quote}");
593        }
594    }
595
596    escaped
597}
598
599/// Escape a regex pattern according to the config.
600pub fn text_convert_value_re(cfg: &TextQueryConfig, regex_str: &str) -> String {
601    let mut result = regex_str.to_string();
602
603    if let Some(esc_esc) = cfg.re_escape_escape_char
604        && let Some(esc) = cfg.re_escape_char
605    {
606        result = result.replace(esc, &format!("{esc_esc}{esc}"));
607    }
608
609    if let Some(esc) = cfg.re_escape_char {
610        for pattern in cfg.re_escape {
611            result = result.replace(pattern, &format!("{esc}{pattern}"));
612        }
613    }
614
615    result
616}
617
618/// Precedence-aware grouping.
619pub fn text_convert_condition_group(
620    cfg: &TextQueryConfig,
621    expr: &str,
622    outer: TokenType,
623    inner: TokenType,
624) -> String {
625    if cfg.needs_grouping(outer, inner) {
626        cfg.group_expression.replace("{expr}", expr)
627    } else {
628        expr.to_string()
629    }
630}
631
632/// Join expressions with the AND token.
633pub fn text_convert_condition_and(cfg: &TextQueryConfig, exprs: &[String]) -> String {
634    let sep = if cfg.and_token.is_empty() {
635        cfg.token_separator.to_string()
636    } else {
637        format!(
638            "{}{}{}",
639            cfg.token_separator, cfg.and_token, cfg.token_separator
640        )
641    };
642    exprs.join(&sep)
643}
644
645/// Join expressions with the OR token.
646pub fn text_convert_condition_or(cfg: &TextQueryConfig, exprs: &[String]) -> String {
647    let sep = format!(
648        "{}{}{}",
649        cfg.token_separator, cfg.or_token, cfg.token_separator
650    );
651    exprs.join(&sep)
652}
653
654/// Negate an expression with the NOT token.
655pub fn text_convert_condition_not(cfg: &TextQueryConfig, expr: &str) -> String {
656    format!("{}{}{expr}", cfg.not_token, cfg.token_separator)
657}
658
659/// Assemble the final query from the main condition string and any deferred parts.
660pub fn text_finish_query(
661    cfg: &TextQueryConfig,
662    query: &str,
663    state: &ConversionState,
664    rule: &SigmaRule,
665) -> String {
666    let main_query = if state.has_deferred() && query.is_empty() {
667        cfg.deferred_only_query
668    } else {
669        query
670    };
671
672    let mut result = cfg.query_expression.replace("{query}", main_query);
673
674    // Substitute state defaults first, then actual state values
675    for (key, default) in cfg.state_defaults {
676        let placeholder = format!("{{{key}}}");
677        result = result.replace(&placeholder, default);
678    }
679    for (key, val) in &state.processing_state {
680        if let Some(s) = val.as_str() {
681            let placeholder = format!("{{{key}}}");
682            result = result.replace(&placeholder, s);
683        }
684    }
685
686    // Substitute rule metadata
687    result = result.replace("{rule.title}", &rule.title);
688    if let Some(id) = &rule.id {
689        result = result.replace("{rule.id}", id);
690    }
691
692    // Append deferred parts
693    if state.has_deferred() {
694        let deferred_start = cfg.deferred_start.unwrap_or("");
695        let deferred_sep = cfg.deferred_separator.unwrap_or("");
696        let parts: Vec<String> = state.deferred.iter().map(|d| d.finalize()).collect();
697        result = format!("{result}{deferred_start}{}", parts.join(deferred_sep));
698    }
699
700    result
701}
702
703// =============================================================================
704// IR-native text rendering
705// =============================================================================
706//
707// These read the faithful `IrPattern` (wildcard-aware, original case) and an
708// explicit `IrStrOp`, never a parser `SigmaString` + `&[Modifier]`. They are
709// the rendering primitives the IR-native `Backend` leaves build on, so a
710// backend never needs a parser type to emit a string match.
711
712/// Convert an [`IrPattern`] to its text representation, applying escaping and
713/// quoting.
714pub fn text_convert_ir_pattern(cfg: &TextQueryConfig, pattern: &IrPattern) -> String {
715    let mut result = String::new();
716    let mut has_wildcards = false;
717
718    for part in &pattern.parts {
719        match part {
720            IrPatternPart::Literal(s) => {
721                let mut escaped = String::with_capacity(s.len());
722                for ch in s.chars() {
723                    let ch_str = ch.to_string();
724                    if cfg.filter_chars.contains(&ch_str.as_str()) {
725                        continue;
726                    }
727                    if ch_str == cfg.escape_char
728                        || ch_str == cfg.str_quote
729                        || cfg.add_escaped.contains(&ch_str.as_str())
730                    {
731                        escaped.push_str(cfg.escape_char);
732                    }
733                    escaped.push(ch);
734                }
735                result.push_str(&escaped);
736            }
737            IrPatternPart::WildcardMulti => {
738                result.push_str(cfg.wildcard_multi);
739                has_wildcards = true;
740            }
741            IrPatternPart::WildcardSingle => {
742                result.push_str(cfg.wildcard_single);
743                has_wildcards = true;
744            }
745        }
746    }
747
748    if !has_wildcards {
749        let should_quote = match cfg.str_quote_pattern {
750            Some(pat) => {
751                let matches = get_cached_regex(pat)
752                    .map(|re| re.is_match(&result))
753                    .unwrap_or(false);
754                if cfg.str_quote_pattern_negation {
755                    !matches
756                } else {
757                    matches
758                }
759            }
760            None => true,
761        };
762        if should_quote {
763            return format!("{}{result}{}", cfg.str_quote, cfg.str_quote);
764        }
765    }
766
767    result
768}
769
770/// Dispatch an IR string match (`op` + wildcard-aware `pattern`) to a query
771/// fragment, with `op` and `case_insensitive` driving operator selection.
772pub fn text_convert_field_str_ir(
773    cfg: &TextQueryConfig,
774    field: &str,
775    op: IrStrOp,
776    pattern: &IrPattern,
777    case_insensitive: bool,
778) -> Result<ConvertResult> {
779    let escaped_field = text_escape_and_quote_field(cfg, field);
780    let is_cased = !case_insensitive;
781    let is_contains = op == IrStrOp::Contains;
782    let is_startswith = op == IrStrOp::StartsWith;
783    let is_endswith = op == IrStrOp::EndsWith;
784
785    let value_str = text_convert_ir_pattern(cfg, pattern);
786
787    if is_cased {
788        if is_contains && let Some(expr) = cfg.case_sensitive_contains_expression {
789            return Ok(ConvertResult::Query(
790                expr.replace("{field}", &escaped_field)
791                    .replace("{value}", &value_str),
792            ));
793        }
794        if is_startswith && let Some(expr) = cfg.case_sensitive_startswith_expression {
795            return Ok(ConvertResult::Query(
796                expr.replace("{field}", &escaped_field)
797                    .replace("{value}", &value_str),
798            ));
799        }
800        if is_endswith && let Some(expr) = cfg.case_sensitive_endswith_expression {
801            return Ok(ConvertResult::Query(
802                expr.replace("{field}", &escaped_field)
803                    .replace("{value}", &value_str),
804            ));
805        }
806        if let Some(expr) = cfg.case_sensitive_match_expression {
807            return Ok(ConvertResult::Query(
808                expr.replace("{field}", &escaped_field)
809                    .replace("{value}", &value_str),
810            ));
811        }
812    }
813
814    if is_contains && let Some(expr) = cfg.contains_expression {
815        return Ok(ConvertResult::Query(
816            expr.replace("{field}", &escaped_field)
817                .replace("{value}", &value_str),
818        ));
819    }
820    if is_startswith && let Some(expr) = cfg.startswith_expression {
821        return Ok(ConvertResult::Query(
822            expr.replace("{field}", &escaped_field)
823                .replace("{value}", &value_str),
824        ));
825    }
826    if is_endswith && let Some(expr) = cfg.endswith_expression {
827        return Ok(ConvertResult::Query(
828            expr.replace("{field}", &escaped_field)
829                .replace("{value}", &value_str),
830        ));
831    }
832
833    if pattern.has_wildcards()
834        && let Some(expr) = cfg.wildcard_match_expression
835    {
836        return Ok(ConvertResult::Query(
837            expr.replace("{field}", &escaped_field)
838                .replace("{value}", &value_str),
839        ));
840    }
841
842    let result = if let Some(expr) = cfg.eq_expression {
843        expr.replace("{field}", &escaped_field)
844            .replace("{value}", &value_str)
845    } else {
846        format!("{escaped_field}{}{value_str}", cfg.eq_token)
847    };
848    Ok(ConvertResult::Query(result))
849}