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