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.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}