Skip to main content

opal/pipeline/
rules.rs

1// TODO: This should be a rules engine that can evaluate rules, but is split without any logic
2// between structs and free floating functions.
3use crate::git;
4use crate::gitlab::rules::{JobRule, RuleChangesRaw, RuleExistsRaw};
5use crate::gitlab::{Job, PipelineFilters};
6use crate::model::{JobSpec, PipelineFilterSpec};
7use crate::naming::job_name_slug;
8use anyhow::{Context, Result, anyhow, bail};
9use globset::{Glob, GlobSetBuilder};
10use regex::RegexBuilder;
11use std::collections::HashMap;
12use std::path::{Path, PathBuf};
13use std::str::FromStr;
14use std::time::Duration;
15
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
17pub enum RuleWhen {
18    #[default]
19    OnSuccess,
20    Manual,
21    Delayed,
22    Never,
23    Always,
24    OnFailure,
25}
26
27impl RuleWhen {
28    pub fn requires_success(self) -> bool {
29        matches!(self, RuleWhen::OnSuccess | RuleWhen::Delayed)
30    }
31
32    pub fn runs_when_failed(self) -> bool {
33        matches!(self, RuleWhen::Always | RuleWhen::OnFailure)
34    }
35}
36
37#[derive(Debug, Default, Clone)]
38pub struct RuleEvaluation {
39    pub included: bool,
40    pub when: RuleWhen,
41    pub allow_failure: bool,
42    pub start_in: Option<Duration>,
43    pub variables: HashMap<String, String>,
44    pub manual_auto_run: bool,
45    pub manual_reason: Option<String>,
46}
47
48impl RuleEvaluation {
49    fn default() -> Self {
50        Self {
51            included: true,
52            when: RuleWhen::OnSuccess,
53            allow_failure: false,
54            start_in: None,
55            variables: HashMap::new(),
56            manual_auto_run: false,
57            manual_reason: None,
58        }
59    }
60}
61
62#[derive(Debug, Clone)]
63pub struct RuleContext {
64    pub workspace: PathBuf,
65    pub env: HashMap<String, String>,
66    run_manual: bool,
67    default_compare_to: Option<String>,
68    tag_resolution_error: Option<String>,
69}
70
71pub trait RuleJob {
72    fn rules(&self) -> &[JobRule];
73}
74
75pub trait RuleFilters {
76    fn only_filters(&self) -> &[String];
77    fn except_filters(&self) -> &[String];
78}
79
80impl RuleJob for Job {
81    fn rules(&self) -> &[JobRule] {
82        &self.rules
83    }
84}
85
86impl RuleJob for JobSpec {
87    fn rules(&self) -> &[JobRule] {
88        &self.rules
89    }
90}
91
92impl RuleFilters for JobSpec {
93    fn only_filters(&self) -> &[String] {
94        &self.only
95    }
96
97    fn except_filters(&self) -> &[String] {
98        &self.except
99    }
100}
101
102impl RuleFilters for PipelineFilters {
103    fn only_filters(&self) -> &[String] {
104        &self.only
105    }
106
107    fn except_filters(&self) -> &[String] {
108        &self.except
109    }
110}
111
112impl RuleFilters for PipelineFilterSpec {
113    fn only_filters(&self) -> &[String] {
114        &self.only
115    }
116
117    fn except_filters(&self) -> &[String] {
118        &self.except
119    }
120}
121
122impl RuleContext {
123    pub fn new(workspace: &Path) -> Self {
124        let run_manual = std::env::var("OPAL_RUN_MANUAL").is_ok_and(|v| v == "1");
125        Self::from_env(workspace, std::env::vars().collect(), run_manual)
126    }
127
128    pub fn from_env(workspace: &Path, mut env: HashMap<String, String>, run_manual: bool) -> Self {
129        let mut tag_resolution_error = None;
130        if !env.contains_key("CI_PIPELINE_SOURCE") {
131            env.insert("CI_PIPELINE_SOURCE".into(), "push".into());
132        }
133        if env
134            .get("CI_COMMIT_TAG")
135            .is_none_or(|value| value.is_empty())
136            && let Some(tag) = env
137                .get("GIT_COMMIT_TAG")
138                .filter(|value| !value.is_empty())
139                .cloned()
140        {
141            env.insert("CI_COMMIT_TAG".into(), tag);
142        }
143        if !env.contains_key("CI_COMMIT_BRANCH")
144            && env.get("CI_COMMIT_TAG").is_none_or(|tag| tag.is_empty())
145            && let Ok(branch) = git::current_branch(workspace)
146        {
147            env.insert("CI_COMMIT_BRANCH".into(), branch);
148        }
149        if env.contains_key("CI_COMMIT_TAG")
150            && env.get("CI_COMMIT_TAG").is_none_or(|tag| tag.is_empty())
151        {
152            match git::current_tag(workspace) {
153                Ok(tag) => {
154                    env.insert("CI_COMMIT_TAG".into(), tag);
155                }
156                Err(err) => {
157                    let message = err.to_string();
158                    if message.contains("multiple tags point at HEAD") {
159                        tag_resolution_error = Some(message);
160                    }
161                }
162            }
163        }
164        if !env.contains_key("CI_COMMIT_REF_NAME") {
165            if let Some(tag) = env
166                .get("CI_COMMIT_TAG")
167                .filter(|tag| !tag.is_empty())
168                .cloned()
169            {
170                env.insert("CI_COMMIT_REF_NAME".into(), tag);
171            } else if let Some(branch) = env
172                .get("CI_COMMIT_BRANCH")
173                .filter(|branch| !branch.is_empty())
174                .cloned()
175            {
176                env.insert("CI_COMMIT_REF_NAME".into(), branch);
177            }
178        }
179        if !env.contains_key("CI_COMMIT_REF_SLUG")
180            && let Some(ref_name) = env.get("CI_COMMIT_REF_NAME").cloned()
181        {
182            let slug = job_name_slug(&ref_name);
183            if !slug.is_empty() {
184                env.insert("CI_COMMIT_REF_SLUG".into(), slug);
185            }
186        }
187        if !env.contains_key("CI_DEFAULT_BRANCH")
188            && let Ok(branch) = git::default_branch(workspace)
189        {
190            env.insert("CI_DEFAULT_BRANCH".into(), branch);
191        }
192        let default_compare_to = env.get("CI_DEFAULT_BRANCH").cloned();
193        Self {
194            workspace: workspace.to_path_buf(),
195            env,
196            run_manual,
197            default_compare_to,
198            tag_resolution_error,
199        }
200    }
201
202    pub fn env_value(&self, name: &str) -> Option<&str> {
203        self.env.get(name).map(|s| s.as_str())
204    }
205
206    pub fn var_value(&self, name: &str) -> String {
207        self.env
208            .get(name)
209            .cloned()
210            .unwrap_or_else(|| std::env::var(name).unwrap_or_default())
211    }
212
213    pub fn pipeline_source(&self) -> &str {
214        self.env_value("CI_PIPELINE_SOURCE").unwrap_or("push")
215    }
216
217    pub fn tag_resolution_error(&self) -> Option<&str> {
218        self.tag_resolution_error.as_deref()
219    }
220
221    pub fn ensure_valid_tag_context(&self) -> Result<()> {
222        if let Some(message) = self.tag_resolution_error() {
223            bail!("{message}");
224        }
225        Ok(())
226    }
227
228    pub fn compare_reference(&self, override_ref: Option<&str>) -> Option<String> {
229        if let Some(raw) = override_ref {
230            let expanded = self.expand_variables(raw);
231            if expanded.is_empty() {
232                None
233            } else {
234                Some(expanded)
235            }
236        } else {
237            self.inferred_compare_reference()
238        }
239    }
240
241    pub fn head_reference(&self) -> Option<String> {
242        self.env_value("CI_COMMIT_SHA")
243            .filter(|sha| !sha.is_empty())
244            .map(|sha| sha.to_string())
245            .or_else(|| git::head_ref(&self.workspace).ok())
246    }
247
248    fn expand_variables(&self, value: &str) -> String {
249        let mut output = String::new();
250        let chars: Vec<char> = value.chars().collect();
251        let mut idx = 0;
252        while idx < chars.len() {
253            let ch = chars[idx];
254            if ch == '$' {
255                if idx + 1 < chars.len() && chars[idx + 1] == '{' {
256                    let mut end = idx + 2;
257                    while end < chars.len() && chars[end] != '}' {
258                        end += 1;
259                    }
260                    if end < chars.len() {
261                        let name: String = chars[idx + 2..end].iter().collect();
262                        output.push_str(self.env_value(&name).unwrap_or(""));
263                        idx = end + 1;
264                        continue;
265                    }
266                } else {
267                    let mut end = idx + 1;
268                    while end < chars.len()
269                        && (chars[end].is_ascii_alphanumeric() || chars[end] == '_')
270                    {
271                        end += 1;
272                    }
273                    if end > idx + 1 {
274                        let name: String = chars[idx + 1..end].iter().collect();
275                        output.push_str(self.env_value(&name).unwrap_or(""));
276                        idx = end;
277                        continue;
278                    }
279                }
280            }
281            output.push(ch);
282            idx += 1;
283        }
284        output
285    }
286
287    fn inferred_compare_reference(&self) -> Option<String> {
288        let source = self.pipeline_source();
289        let inferred = match source {
290            "merge_request_event" => self
291                .env_value("CI_MERGE_REQUEST_DIFF_BASE_SHA")
292                .or_else(|| self.env_value("CI_MERGE_REQUEST_TARGET_BRANCH_SHA"))
293                .map(|s| s.to_string())
294                .or_else(|| {
295                    self.env_value("CI_MERGE_REQUEST_TARGET_BRANCH_NAME")
296                        .map(|branch| format!("origin/{branch}"))
297                }),
298            "push" | "schedule" | "pipeline" | "web" => {
299                if let Some(before) = self
300                    .env_value("CI_COMMIT_BEFORE_SHA")
301                    .filter(|sha| !Self::is_zero_sha(sha))
302                    .map(|s| s.to_string())
303                {
304                    Some(before)
305                } else if let Some(default_branch) = &self.default_compare_to {
306                    git::merge_base(
307                        &self.workspace,
308                        default_branch,
309                        self.head_reference().as_deref(),
310                    )
311                    .ok()
312                    .flatten()
313                } else {
314                    None
315                }
316            }
317            _ => None,
318        };
319        inferred.or_else(|| self.default_compare_to.clone())
320    }
321
322    fn is_zero_sha(value: &str) -> bool {
323        !value.is_empty() && value.chars().all(|ch| ch == '0')
324    }
325}
326
327pub fn evaluate_rules(job: &impl RuleJob, ctx: &RuleContext) -> Result<RuleEvaluation> {
328    if job.rules().is_empty() {
329        return Ok(RuleEvaluation::default());
330    }
331
332    for rule in job.rules() {
333        if !rule_matches(rule, ctx)? {
334            continue;
335        }
336        return Ok(apply_rule(rule, ctx));
337    }
338
339    Ok(RuleEvaluation {
340        included: false,
341        when: RuleWhen::Never,
342        ..RuleEvaluation::default()
343    })
344}
345
346pub fn evaluate_workflow(rules: &[JobRule], ctx: &RuleContext) -> Result<bool> {
347    if rules.is_empty() {
348        return Ok(true);
349    }
350    for rule in rules {
351        if !rule_matches(rule, ctx)? {
352            continue;
353        }
354        let evaluation = apply_rule(rule, ctx);
355        return Ok(evaluation.included);
356    }
357    Ok(false)
358}
359
360fn rule_matches(rule: &JobRule, ctx: &RuleContext) -> Result<bool> {
361    if let Some(if_expr) = &rule.if_expr
362        && !eval_if_expr(if_expr, ctx)?
363    {
364        return Ok(false);
365    }
366    if let Some(changes) = &rule.changes
367        && !matches_changes(changes, ctx)?
368    {
369        return Ok(false);
370    }
371    if let Some(exists) = &rule.exists
372        && !matches_exists(exists, ctx)?
373    {
374        return Ok(false);
375    }
376    Ok(true)
377}
378
379fn apply_rule(rule: &JobRule, ctx: &RuleContext) -> RuleEvaluation {
380    let mut result = RuleEvaluation::default();
381    result.variables = rule.variables.clone();
382    if let Some(allow) = rule.allow_failure {
383        result.allow_failure = allow;
384    }
385    result.manual_auto_run = ctx.run_manual;
386    apply_when_config(
387        &mut result,
388        rule.when.as_deref(),
389        rule.start_in.as_deref(),
390        Some("manual job (rules)"),
391    );
392
393    result
394}
395
396pub(crate) fn apply_when_config(
397    result: &mut RuleEvaluation,
398    when: Option<&str>,
399    start_in: Option<&str>,
400    manual_reason: Option<&str>,
401) {
402    if let Some(when) = when {
403        match when {
404            "manual" => {
405                result.when = RuleWhen::Manual;
406                result.manual_reason = manual_reason.map(str::to_string);
407            }
408            "delayed" => {
409                result.when = RuleWhen::Delayed;
410                if let Some(start) = start_in
411                    && let Some(dur) = parse_duration(start)
412                {
413                    result.start_in = Some(dur);
414                }
415            }
416            "never" => {
417                result.when = RuleWhen::Never;
418                result.included = false;
419            }
420            "always" => {
421                result.when = RuleWhen::Always;
422            }
423            "on_failure" => {
424                result.when = RuleWhen::OnFailure;
425            }
426            _ => {
427                result.when = RuleWhen::OnSuccess;
428            }
429        }
430    }
431}
432
433fn matches_changes(changes: &RuleChangesRaw, ctx: &RuleContext) -> Result<bool> {
434    let paths = changes.paths();
435    if paths.is_empty() {
436        return Ok(false);
437    }
438    let compare_ref = ctx.compare_reference(changes.compare_to());
439    let head_ref = ctx.head_reference();
440    let changed = git::changed_files(&ctx.workspace, compare_ref.as_deref(), head_ref.as_deref())?;
441    if changed.is_empty() {
442        return Ok(false);
443    }
444    let mut builder = GlobSetBuilder::new();
445    for pattern in paths {
446        builder.add(Glob::new(pattern).with_context(|| format!("invalid glob '{pattern}'"))?);
447    }
448    let glob = builder.build()?;
449    for path in changed {
450        if glob.is_match(&path) {
451            return Ok(true);
452        }
453    }
454    Ok(false)
455}
456
457fn matches_exists(exists: &RuleExistsRaw, ctx: &RuleContext) -> Result<bool> {
458    let paths = exists.paths();
459    if paths.is_empty() {
460        return Ok(false);
461    }
462    for pattern in paths {
463        let matched = if pattern.contains('*') || pattern.contains('?') {
464            let glob = Glob::new(pattern)
465                .with_context(|| format!("invalid exists pattern '{pattern}'"))?
466                .compile_matcher();
467            walk_paths(&ctx.workspace, &glob)?
468        } else {
469            vec![ctx.workspace.join(pattern)]
470        };
471        if matched.iter().any(|path| path.exists()) {
472            return Ok(true);
473        }
474    }
475    Ok(false)
476}
477
478fn walk_paths(root: &Path, matcher: &globset::GlobMatcher) -> Result<Vec<PathBuf>> {
479    let mut matches = Vec::new();
480    for entry in walkdir::WalkDir::new(root).follow_links(false) {
481        let entry = entry?;
482        let rel = entry
483            .path()
484            .strip_prefix(root)
485            .unwrap_or(entry.path())
486            .to_path_buf();
487        if matcher.is_match(rel) {
488            matches.push(entry.path().to_path_buf());
489        }
490    }
491    Ok(matches)
492}
493
494fn parse_duration(value: &str) -> Option<Duration> {
495    humantime::Duration::from_str(value).map(|d| d.into()).ok()
496}
497
498fn eval_if_expr(expr: &str, ctx: &RuleContext) -> Result<bool> {
499    let mut parser = ExprParser::new(expr, ctx);
500    let value = parser.parse_expression()?;
501    Ok(value)
502}
503
504#[cfg(test)]
505mod tests {
506    use super::*;
507    use crate::git::test_support::{init_repo_with_commit_and_tag, init_repo_with_commit_and_tags};
508    use anyhow::Result;
509    use std::collections::HashMap;
510    use tempfile::tempdir;
511
512    #[test]
513    fn does_not_infer_commit_tag_from_repo_when_env_is_missing() -> Result<()> {
514        let dir = init_repo_with_commit_and_tag("v1.2.3")?;
515
516        let ctx = RuleContext::from_env(dir.path(), HashMap::new(), false);
517
518        assert!(ctx.env_value("CI_COMMIT_TAG").is_none());
519        assert_ne!(ctx.env_value("CI_COMMIT_REF_NAME"), Some("v1.2.3"));
520        Ok(())
521    }
522
523    #[test]
524    fn maps_git_commit_tag_to_ci_commit_tag() {
525        let dir = tempdir().expect("tempdir");
526        let ctx = RuleContext::from_env(
527            dir.path(),
528            HashMap::from([("GIT_COMMIT_TAG".into(), "opal-recheck-123".into())]),
529            false,
530        );
531
532        assert_eq!(ctx.env_value("CI_COMMIT_TAG"), Some("opal-recheck-123"));
533        assert_eq!(
534            ctx.env_value("CI_COMMIT_REF_NAME"),
535            Some("opal-recheck-123")
536        );
537        assert_eq!(
538            ctx.env_value("CI_COMMIT_REF_SLUG"),
539            Some("opal-recheck-123")
540        );
541        assert!(ctx.env_value("CI_COMMIT_BRANCH").is_none());
542    }
543
544    #[test]
545    fn captures_error_for_ambiguous_git_tag_context() -> Result<()> {
546        let dir = init_repo_with_commit_and_tags(&["v0.1.2", "v0.1.3"])?;
547        let ctx = RuleContext::from_env(dir.path(), HashMap::new(), false);
548
549        assert!(ctx.env_value("CI_COMMIT_TAG").is_none());
550        assert!(ctx.tag_resolution_error().is_none());
551        Ok(())
552    }
553
554    #[test]
555    fn explicit_tag_overrides_ambiguous_git_tag_context() -> Result<()> {
556        let dir = init_repo_with_commit_and_tags(&["v0.1.2", "v0.1.3"])?;
557        let ctx = RuleContext::from_env(
558            dir.path(),
559            HashMap::from([("GIT_COMMIT_TAG".into(), "v0.1.3".into())]),
560            false,
561        );
562
563        assert_eq!(ctx.env_value("CI_COMMIT_TAG"), Some("v0.1.3"));
564        assert!(ctx.tag_resolution_error().is_none());
565        Ok(())
566    }
567
568    #[test]
569    fn empty_ci_commit_tag_still_records_ambiguous_git_tag_error() -> Result<()> {
570        let dir = init_repo_with_commit_and_tags(&["v0.1.2", "v0.1.3"])?;
571        let ctx = RuleContext::from_env(
572            dir.path(),
573            HashMap::from([("CI_COMMIT_TAG".into(), String::new())]),
574            false,
575        );
576
577        assert!(
578            ctx.env_value("CI_COMMIT_TAG")
579                .is_none_or(|tag| tag.is_empty())
580        );
581        assert!(
582            ctx.tag_resolution_error()
583                .is_some_and(|err| err.contains("multiple tags point at HEAD"))
584        );
585        Ok(())
586    }
587
588    #[test]
589    fn ensure_valid_tag_context_errors_for_ambiguous_git_tags() -> Result<()> {
590        let dir = init_repo_with_commit_and_tags(&["v0.1.2", "v0.1.3"])?;
591        let ctx = RuleContext::from_env(
592            dir.path(),
593            HashMap::from([("CI_COMMIT_TAG".into(), String::new())]),
594            false,
595        );
596
597        let err = ctx
598            .ensure_valid_tag_context()
599            .expect_err("ambiguous tag context should fail");
600        assert!(err.to_string().contains("multiple tags point at HEAD"));
601        Ok(())
602    }
603}
604
605struct ExprParser {
606    tokens: Vec<Token>,
607    pos: usize,
608}
609
610impl ExprParser {
611    fn new(input: &str, ctx: &RuleContext) -> Self {
612        let tokens = tokenize(input, ctx);
613        Self { tokens, pos: 0 }
614    }
615
616    fn parse_expression(&mut self) -> Result<bool> {
617        self.parse_or()
618    }
619
620    fn parse_or(&mut self) -> Result<bool> {
621        let mut value = self.parse_and()?;
622        while self.matches(TokenKind::Or) {
623            value = value || self.parse_and()?;
624        }
625        Ok(value)
626    }
627
628    fn parse_and(&mut self) -> Result<bool> {
629        let mut value = self.parse_not()?;
630        while self.matches(TokenKind::And) {
631            value = value && self.parse_not()?;
632        }
633        Ok(value)
634    }
635
636    fn parse_not(&mut self) -> Result<bool> {
637        if self.matches(TokenKind::Not) {
638            return Ok(!self.parse_not()?);
639        }
640        self.parse_comparison()
641    }
642
643    fn parse_comparison(&mut self) -> Result<bool> {
644        if self.matches(TokenKind::LParen) {
645            let value = self.parse_expression()?;
646            self.consume(TokenKind::RParen)?;
647            return Ok(value);
648        }
649        let left = self.parse_operand()?;
650        if let Some(op) = self.peek_operator() {
651            self.advance();
652            let right = self.parse_operand()?;
653            return self.evaluate_comparator(op, left, right);
654        }
655        Ok(!left.is_empty())
656    }
657
658    fn evaluate_comparator(&self, op: TokenKind, left: String, right: String) -> Result<bool> {
659        match op {
660            TokenKind::Eq => Ok(left == right),
661            TokenKind::Ne => Ok(left != right),
662            TokenKind::RegexEq => Ok(match_regex(&left, &right)?),
663            TokenKind::RegexNe => Ok(!match_regex(&left, &right)?),
664            _ => Err(anyhow!("unsupported comparator")),
665        }
666    }
667
668    fn parse_operand(&mut self) -> Result<String> {
669        if self.matches(TokenKind::Variable) {
670            return Ok(self.last_token_value().unwrap_or_default());
671        }
672        if self.matches(TokenKind::Literal) {
673            return Ok(self.last_token_value().unwrap_or_default());
674        }
675        Err(anyhow!("expected operand"))
676    }
677
678    fn matches(&mut self, kind: TokenKind) -> bool {
679        if self.check(kind) {
680            self.advance();
681            true
682        } else {
683            false
684        }
685    }
686
687    fn check(&self, kind: TokenKind) -> bool {
688        self.tokens
689            .get(self.pos)
690            .map(|t| t.kind == kind)
691            .unwrap_or(false)
692    }
693
694    fn advance(&mut self) {
695        self.pos += 1;
696    }
697
698    fn consume(&mut self, kind: TokenKind) -> Result<()> {
699        if self.check(kind) {
700            self.advance();
701            Ok(())
702        } else {
703            Err(anyhow!("expected token"))
704        }
705    }
706
707    fn peek_operator(&self) -> Option<TokenKind> {
708        self.tokens.get(self.pos).and_then(|t| match t.kind {
709            TokenKind::Eq | TokenKind::Ne | TokenKind::RegexEq | TokenKind::RegexNe => Some(t.kind),
710            _ => None,
711        })
712    }
713
714    fn last_token_value(&self) -> Option<String> {
715        self.tokens.get(self.pos - 1).and_then(|t| t.value.clone())
716    }
717}
718
719#[derive(Debug, Clone, Copy, PartialEq)]
720enum TokenKind {
721    And,
722    Or,
723    Not,
724    LParen,
725    RParen,
726    Eq,
727    Ne,
728    RegexEq,
729    RegexNe,
730    Variable,
731    Literal,
732}
733
734#[derive(Debug, Clone)]
735struct Token {
736    kind: TokenKind,
737    value: Option<String>,
738}
739
740fn tokenize(input: &str, ctx: &RuleContext) -> Vec<Token> {
741    let mut tokens = Vec::new();
742    let chars: Vec<char> = input.chars().collect();
743    let mut idx = 0;
744    while idx < chars.len() {
745        match chars[idx] {
746            ' ' | '\t' | '\n' => idx += 1,
747            '&' if idx + 1 < chars.len() && chars[idx + 1] == '&' => {
748                tokens.push(Token {
749                    kind: TokenKind::And,
750                    value: None,
751                });
752                idx += 2;
753            }
754            '|' if idx + 1 < chars.len() && chars[idx + 1] == '|' => {
755                tokens.push(Token {
756                    kind: TokenKind::Or,
757                    value: None,
758                });
759                idx += 2;
760            }
761            '!' if idx + 1 < chars.len() && chars[idx + 1] == '=' => {
762                tokens.push(Token {
763                    kind: TokenKind::Ne,
764                    value: None,
765                });
766                idx += 2;
767            }
768            '=' if idx + 1 < chars.len() && chars[idx + 1] == '=' => {
769                tokens.push(Token {
770                    kind: TokenKind::Eq,
771                    value: None,
772                });
773                idx += 2;
774            }
775            '=' if idx + 1 < chars.len() && chars[idx + 1] == '~' => {
776                tokens.push(Token {
777                    kind: TokenKind::RegexEq,
778                    value: None,
779                });
780                idx += 2;
781            }
782            '!' if idx + 1 < chars.len() && chars[idx + 1] == '~' => {
783                tokens.push(Token {
784                    kind: TokenKind::RegexNe,
785                    value: None,
786                });
787                idx += 2;
788            }
789            '!' => {
790                tokens.push(Token {
791                    kind: TokenKind::Not,
792                    value: None,
793                });
794                idx += 1;
795            }
796            '(' => {
797                tokens.push(Token {
798                    kind: TokenKind::LParen,
799                    value: None,
800                });
801                idx += 1;
802            }
803            ')' => {
804                tokens.push(Token {
805                    kind: TokenKind::RParen,
806                    value: None,
807                });
808                idx += 1;
809            }
810            '$' => {
811                let start = idx + 1;
812                idx = start;
813                while idx < chars.len()
814                    && (chars[idx].is_ascii_alphanumeric()
815                        || chars[idx] == '_'
816                        || chars[idx] == ':')
817                {
818                    idx += 1;
819                }
820                let name = input[start..idx].to_string();
821                let value = ctx.var_value(&name);
822                tokens.push(Token {
823                    kind: TokenKind::Variable,
824                    value: Some(value),
825                });
826            }
827            '\'' | '"' => {
828                let quote = chars[idx];
829                idx += 1;
830                let start = idx;
831                while idx < chars.len() && chars[idx] != quote {
832                    idx += 1;
833                }
834                let value = input[start..idx].to_string();
835                idx += 1;
836                tokens.push(Token {
837                    kind: TokenKind::Literal,
838                    value: Some(value),
839                });
840            }
841            _ => {
842                let start = idx;
843                while idx < chars.len()
844                    && !chars[idx].is_whitespace()
845                    && !matches!(chars[idx], '(' | ')' | '&' | '|' | '=' | '!')
846                {
847                    idx += 1;
848                }
849                let value = input[start..idx].to_string();
850                tokens.push(Token {
851                    kind: TokenKind::Literal,
852                    value: Some(value),
853                });
854            }
855        }
856    }
857    tokens
858}
859
860fn match_regex(value: &str, pattern: &str) -> Result<bool> {
861    let (body, flags) = if let Some(stripped) = pattern.strip_prefix('/') {
862        if let Some(end) = stripped.rfind('/') {
863            let body = &stripped[..end];
864            let flag = &stripped[end + 1..];
865            (body.to_string(), flag.to_string())
866        } else {
867            (pattern.to_string(), String::new())
868        }
869    } else {
870        (pattern.to_string(), String::new())
871    };
872    let mut builder = RegexBuilder::new(&body);
873    if flags.contains('i') {
874        builder.case_insensitive(true);
875    }
876    let regex = builder.build()?;
877    Ok(regex.is_match(value))
878}
879
880pub fn filters_allow(filters: &impl RuleFilters, ctx: &RuleContext) -> bool {
881    if filters.only_filters().is_empty() {
882        if filters
883            .except_filters()
884            .iter()
885            .any(|filter| filter_matches(filter, ctx))
886        {
887            return false;
888        }
889        return true;
890    }
891    let passes_only = filters
892        .only_filters()
893        .iter()
894        .any(|filter| filter_matches(filter, ctx));
895    if !passes_only {
896        return false;
897    }
898    !filters
899        .except_filters()
900        .iter()
901        .any(|filter| filter_matches(filter, ctx))
902}
903
904fn filter_matches(filter: &str, ctx: &RuleContext) -> bool {
905    if let Some(expr) = filter.strip_prefix("__opal_variables__:") {
906        return eval_if_expr(expr, ctx).unwrap_or(false);
907    }
908    match filter {
909        "branches" => ctx
910            .env_value("CI_COMMIT_BRANCH")
911            .map(|s| !s.is_empty())
912            .unwrap_or(false),
913        "tags" => ctx
914            .env_value("CI_COMMIT_TAG")
915            .map(|s| !s.is_empty())
916            .unwrap_or(false),
917        "merge_requests" => ctx.pipeline_source() == "merge_request_event",
918        "schedules" => ctx.pipeline_source() == "schedule",
919        "pushes" => ctx.pipeline_source() == "push",
920        "api" => ctx.pipeline_source() == "api",
921        "web" => ctx.pipeline_source() == "web",
922        "triggers" => ctx.pipeline_source() == "trigger",
923        "pipelines" => matches!(ctx.pipeline_source(), "pipeline" | "parent_pipeline"),
924        "external_pull_requests" => ctx.pipeline_source() == "external_pull_request_event",
925        pattern => {
926            if let Some(ref_name) = ctx
927                .env_value("CI_COMMIT_REF_NAME")
928                .filter(|s| !s.is_empty())
929                .or_else(|| ctx.env_value("CI_COMMIT_BRANCH").filter(|s| !s.is_empty()))
930                .or_else(|| ctx.env_value("CI_COMMIT_TAG").filter(|s| !s.is_empty()))
931            {
932                if pattern.starts_with('/') {
933                    match_regex(ref_name, pattern).unwrap_or_default()
934                } else {
935                    ref_name == pattern
936                }
937            } else {
938                false
939            }
940        }
941    }
942}