1use 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}