Expose workflow trigger preflight #15

Manually merged
day01 merged 1 commits from feat/0.6-trigger-preflight into develop 2026-08-31 07:55:06 +00:00
2 changed files with 25 additions and 4 deletions
+12 -4
View File
@@ -1,195 +1,203 @@
use std::fmt;

use thiserror::Error;

#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum EventKind {
Push,
PullRequest,
Manual,
Schedule,
}

/// What happened, as much of it as deciding needs: which kind, on which branch,
/// and which paths it touched.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct Event {
kind: EventKind,
branch: String,
changed_paths: Vec<String>,
}

#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct Filter {
include: Vec<Pattern>,
exclude: Vec<Pattern>,
}

/// A subset of the glob syntax the dialect allows: literals, `*` within one
/// segment, and `**` across segments. Anything else is refused rather than
/// matched by accident.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct Pattern(String);

#[derive(Clone, Debug, Eq, PartialEq)]
pub struct Trigger {
kind: EventKind,
branches: Filter,
paths: Filter,
}

#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct Triggers(Vec<Trigger>);

#[derive(Debug, Error)]
pub enum TriggerError {
#[error("pattern {0:?} uses glob syntax this compiler does not support")]
UnsupportedPattern(String),
}

impl Event {
#[must_use]
pub fn new(kind: EventKind, branch: String, changed_paths: Vec<String>) -> Self {
Self {
kind,
branch,
changed_paths,
}
}

#[must_use]
pub const fn kind(&self) -> EventKind {
self.kind
}

#[must_use]
pub fn branch(&self) -> &str {
&self.branch
}
}

impl Pattern {
pub fn parse(value: &str) -> Result<Self, TriggerError> {
if value.contains(['?', '[', ']', '+', '!']) {
return Err(TriggerError::UnsupportedPattern(value.to_owned()));
}
Ok(Self(value.to_owned()))
}

#[must_use]
pub fn matches(&self, candidate: &str) -> bool {
matches_from(&self.0, candidate)
}
}

fn matches_from(pattern: &str, candidate: &str) -> bool {
match pattern.find('*') {
None => pattern == candidate,
Some(position) => {
let (literal, rest) = pattern.split_at(position);
if !candidate.starts_with(literal) {
return false;
}
let candidate = &candidate[literal.len()..];
if let Some(rest) = rest.strip_prefix("**") {
(0..=candidate.len()).any(|skip| matches_from(rest, &candidate[skip..]))
} else {
let rest = &rest[1..];
candidate
.char_indices()
.take_while(|(_, character)| *character != '/')
.map(|(index, character)| index + character.len_utf8())
.chain(std::iter::once(0))
.any(|skip| matches_from(rest, &candidate[skip..]))
}
}
}
}

impl Filter {
#[must_use]
pub const fn new(include: Vec<Pattern>, exclude: Vec<Pattern>) -> Self {
Self { include, exclude }
}

/// Nothing stated means everything passes. An exclusion always wins, which
/// is what makes `paths-ignore` mean what it says.
#[must_use]
pub fn admits(&self, candidate: &str) -> bool {
if self
.exclude
.iter()
.any(|pattern| pattern.matches(candidate))
{
return false;
}
self.include.is_empty()
|| self
.include
.iter()
.any(|pattern| pattern.matches(candidate))
}

#[must_use]
pub fn is_empty(&self) -> bool {
self.include.is_empty() && self.exclude.is_empty()
}
}

impl Trigger {
#[must_use]
pub const fn new(kind: EventKind, branches: Filter, paths: Filter) -> Self {
Self {
kind,
branches,
paths,
}
}

#[must_use]
pub fn fires_on(&self, event: &Event) -> bool {
if self.kind != event.kind {
return false;
}
if !self.branches.admits(&event.branch) {
return false;
}
// A path filter on an event that touched nothing has nothing to admit,
// so it does not fire.
self.paths.is_empty()
|| event
.changed_paths
.iter()
.any(|path| self.paths.admits(path))
}
}

impl Triggers {
#[must_use]
pub const fn new(triggers: Vec<Trigger>) -> Self {
Self(triggers)
}

/// A workflow with no `on:` is never triggered by an event. Saying nothing
/// is not saying everything.
#[must_use]
pub fn fire_on(&self, event: &Event) -> bool {
self.0.iter().any(|trigger| trigger.fires_on(event))
}

#[must_use]
pub fn is_empty(&self) -> bool {
self.0.is_empty()
}
}

impl fmt::Display for EventKind {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Push => formatter.write_str("push"),
Self::PullRequest => formatter.write_str("pull_request"),
Self::Manual => formatter.write_str("workflow_dispatch"),
Self::Schedule => formatter.write_str("schedule"),
}
}
}
use std::fmt;

use thiserror::Error;

#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum EventKind {
Push,
PullRequest,
Manual,
Schedule,
}

/// What happened, as much of it as deciding needs: which kind, on which branch,
/// and which paths it touched.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct Event {
kind: EventKind,
branch: String,
changed_paths: Vec<String>,
}

#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct Filter {
include: Vec<Pattern>,
exclude: Vec<Pattern>,
}

/// A subset of the glob syntax the dialect allows: literals, `*` within one
/// segment, and `**` across segments. Anything else is refused rather than
/// matched by accident.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct Pattern(String);

#[derive(Clone, Debug, Eq, PartialEq)]
pub struct Trigger {
kind: EventKind,
branches: Filter,
paths: Filter,
}

#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct Triggers(Vec<Trigger>);

#[derive(Debug, Error)]
pub enum TriggerError {
#[error("pattern {0:?} uses glob syntax this compiler does not support")]
UnsupportedPattern(String),
}

impl Event {
#[must_use]
pub fn new(kind: EventKind, branch: String, changed_paths: Vec<String>) -> Self {
Self {
kind,
branch,
changed_paths,
}
}

#[must_use]
pub const fn kind(&self) -> EventKind {
self.kind
}

#[must_use]
pub fn branch(&self) -> &str {
&self.branch
}
}

impl Pattern {
pub fn parse(value: &str) -> Result<Self, TriggerError> {
if value.contains(['?', '[', ']', '+', '!']) {
return Err(TriggerError::UnsupportedPattern(value.to_owned()));
}
Ok(Self(value.to_owned()))
}

#[must_use]
pub fn matches(&self, candidate: &str) -> bool {
matches_from(&self.0, candidate)
}
}

fn matches_from(pattern: &str, candidate: &str) -> bool {
match pattern.find('*') {
None => pattern == candidate,
Some(position) => {
let (literal, rest) = pattern.split_at(position);
if !candidate.starts_with(literal) {
return false;
}
let candidate = &candidate[literal.len()..];
if let Some(rest) = rest.strip_prefix("**") {
(0..=candidate.len()).any(|skip| matches_from(rest, &candidate[skip..]))
} else {
let rest = &rest[1..];
candidate
.char_indices()
.take_while(|(_, character)| *character != '/')
.map(|(index, character)| index + character.len_utf8())
.chain(std::iter::once(0))
.any(|skip| matches_from(rest, &candidate[skip..]))
}
}
}
}

impl Filter {
#[must_use]
pub const fn new(include: Vec<Pattern>, exclude: Vec<Pattern>) -> Self {
Self { include, exclude }
}

/// Nothing stated means everything passes. An exclusion always wins, which
/// is what makes `paths-ignore` mean what it says.
#[must_use]
pub fn admits(&self, candidate: &str) -> bool {
if self
.exclude
.iter()
.any(|pattern| pattern.matches(candidate))
{
return false;
}
self.include.is_empty()
|| self
.include
.iter()
.any(|pattern| pattern.matches(candidate))
}

#[must_use]
pub fn is_empty(&self) -> bool {
self.include.is_empty() && self.exclude.is_empty()
}
}

impl Trigger {
#[must_use]
pub const fn new(kind: EventKind, branches: Filter, paths: Filter) -> Self {
Self {
kind,
branches,
paths,
}
}

fn matches_kind_and_branch(&self, event: &Event) -> bool {
self.kind == event.kind && self.branches.admits(&event.branch)
}

#[must_use]
pub fn fires_on(&self, event: &Event) -> bool {
if !self.matches_kind_and_branch(event) {
return false;
}
// A path filter on an event that touched nothing has nothing to admit,
// so it does not fire.
self.paths.is_empty()
|| event
.changed_paths
.iter()
.any(|path| self.paths.admits(path))
}
}

impl Triggers {
#[must_use]
pub const fn new(triggers: Vec<Trigger>) -> Self {
Self(triggers)
}

/// A workflow with no `on:` is never triggered by an event. Saying nothing
/// is not saying everything.
#[must_use]
pub fn fire_on(&self, event: &Event) -> bool {
self.0.iter().any(|trigger| trigger.fires_on(event))
}

#[must_use]
pub fn may_fire_on(&self, event: &Event) -> bool {
self.0
.iter()
.any(|trigger| trigger.matches_kind_and_branch(event))
}

#[must_use]
pub fn is_empty(&self) -> bool {
self.0.is_empty()
}
}

impl fmt::Display for EventKind {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Push => formatter.write_str("push"),
Self::PullRequest => formatter.write_str("pull_request"),
Self::Manual => formatter.write_str("workflow_dispatch"),
Self::Schedule => formatter.write_str("schedule"),
}
}
}
+13
View File
@@ -1,122 +1,135 @@
#![allow(clippy::expect_used, clippy::panic, clippy::unwrap_used)]

use syncode_workflow::{Event, EventKind, Filter, Pattern, Trigger, Triggers};

fn pattern(value: &str) -> Pattern {
Pattern::parse(value).unwrap_or_else(|error| panic!("pattern {value}: {error}"))
}

fn patterns(values: &[&str]) -> Vec<Pattern> {
values.iter().copied().map(pattern).collect()
}

fn push(branch: &str, paths: &[&str]) -> Event {
Event::new(
EventKind::Push,
branch.to_owned(),
paths.iter().map(|path| (*path).to_owned()).collect(),
)
}

#[test]
fn a_workflow_that_says_nothing_is_never_triggered() {
assert!(!Triggers::default().fire_on(&push("main", &["src/main.rs"])));
}

#[test]
fn a_branch_filter_decides_which_pushes_count() {
let triggers = Triggers::new(vec![Trigger::new(
EventKind::Push,
Filter::new(patterns(&["main", "release/*"]), Vec::new()),
Filter::default(),
)]);

assert!(triggers.fire_on(&push("main", &[])));
assert!(triggers.fire_on(&push("release/0.3", &[])));
assert!(!triggers.fire_on(&push("feature/x", &[])));
assert!(
!triggers.fire_on(&push("release/0.3/hotfix", &[])),
"a single star does not cross a slash"
);
}

#[test]
fn a_double_star_crosses_slashes() {
let triggers = Triggers::new(vec![Trigger::new(
EventKind::Push,
Filter::new(patterns(&["release/**"]), Vec::new()),
Filter::default(),
)]);

assert!(triggers.fire_on(&push("release/0.3", &[])));
assert!(triggers.fire_on(&push("release/0.3/hotfix", &[])));
}

#[test]
fn an_exclusion_wins_over_an_inclusion() {
let triggers = Triggers::new(vec![Trigger::new(
EventKind::Push,
Filter::new(patterns(&["**"]), patterns(&["wip/**"])),
Filter::default(),
)]);

assert!(triggers.fire_on(&push("main", &[])));
assert!(!triggers.fire_on(&push("wip/experiment", &[])));
}

#[test]
fn a_path_filter_needs_a_touched_path_to_admit() {
let triggers = Triggers::new(vec![Trigger::new(
EventKind::Push,
Filter::default(),
Filter::new(patterns(&["crates/**"]), Vec::new()),
)]);

assert!(triggers.fire_on(&push("main", &["crates/workflow/src/lib.rs"])));
assert!(!triggers.fire_on(&push("main", &["README.md"])));
assert!(
!triggers.fire_on(&push("main", &[])),
"an event that touched nothing has nothing for a path filter to admit"
);
}

#[test]
fn ignored_paths_do_not_trigger_on_their_own() {
let triggers = Triggers::new(vec![Trigger::new(
EventKind::Push,
Filter::default(),
Filter::new(Vec::new(), patterns(&["**.md"])),
)]);

assert!(!triggers.fire_on(&push("main", &["README.md"])));
assert!(
triggers.fire_on(&push("main", &["README.md", "src/main.rs"])),
"one path outside the ignore list is enough"
);
}

#[test]
fn an_event_of_another_kind_does_not_fire_the_trigger() {
let triggers = Triggers::new(vec![Trigger::new(
EventKind::PullRequest,
Filter::default(),
Filter::default(),
)]);

assert!(!triggers.fire_on(&push("main", &["src/main.rs"])));
assert!(triggers.fire_on(&Event::new(
EventKind::PullRequest,
"main".to_owned(),
vec!["src/main.rs".to_owned()],
)));
}

#[test]
fn glob_syntax_this_compiler_does_not_support_is_refused() {
let error = Pattern::parse("release/[0-9]*").expect_err("character classes are not supported");

assert!(
error.to_string().contains("does not support"),
"unexpected error: {error}"
);
}
#![allow(clippy::expect_used, clippy::panic, clippy::unwrap_used)]

use syncode_workflow::{Event, EventKind, Filter, Pattern, Trigger, Triggers};

fn pattern(value: &str) -> Pattern {
Pattern::parse(value).unwrap_or_else(|error| panic!("pattern {value}: {error}"))
}

fn patterns(values: &[&str]) -> Vec<Pattern> {
values.iter().copied().map(pattern).collect()
}

fn push(branch: &str, paths: &[&str]) -> Event {
Event::new(
EventKind::Push,
branch.to_owned(),
paths.iter().map(|path| (*path).to_owned()).collect(),
)
}

#[test]
fn a_workflow_that_says_nothing_is_never_triggered() {
assert!(!Triggers::default().fire_on(&push("main", &["src/main.rs"])));
}

#[test]
fn a_branch_filter_decides_which_pushes_count() {
let triggers = Triggers::new(vec![Trigger::new(
EventKind::Push,
Filter::new(patterns(&["main", "release/*"]), Vec::new()),
Filter::default(),
)]);

assert!(triggers.fire_on(&push("main", &[])));
assert!(triggers.fire_on(&push("release/0.3", &[])));
assert!(!triggers.fire_on(&push("feature/x", &[])));
assert!(
!triggers.fire_on(&push("release/0.3/hotfix", &[])),
"a single star does not cross a slash"
);
}

#[test]
fn a_double_star_crosses_slashes() {
let triggers = Triggers::new(vec![Trigger::new(
EventKind::Push,
Filter::new(patterns(&["release/**"]), Vec::new()),
Filter::default(),
)]);

assert!(triggers.fire_on(&push("release/0.3", &[])));
assert!(triggers.fire_on(&push("release/0.3/hotfix", &[])));
}

#[test]
fn an_exclusion_wins_over_an_inclusion() {
let triggers = Triggers::new(vec![Trigger::new(
EventKind::Push,
Filter::new(patterns(&["**"]), patterns(&["wip/**"])),
Filter::default(),
)]);

assert!(triggers.fire_on(&push("main", &[])));
assert!(!triggers.fire_on(&push("wip/experiment", &[])));
}

#[test]
fn a_path_filter_needs_a_touched_path_to_admit() {
let triggers = Triggers::new(vec![Trigger::new(
EventKind::Push,
Filter::default(),
Filter::new(patterns(&["crates/**"]), Vec::new()),
)]);

assert!(triggers.fire_on(&push("main", &["crates/workflow/src/lib.rs"])));
assert!(!triggers.fire_on(&push("main", &["README.md"])));
assert!(
!triggers.fire_on(&push("main", &[])),
"an event that touched nothing has nothing for a path filter to admit"
);
}

#[test]
fn kind_and_branch_can_reject_an_event_before_paths_are_loaded() {
let triggers = Triggers::new(vec![Trigger::new(
EventKind::Push,
Filter::new(patterns(&["main"]), Vec::new()),
Filter::new(patterns(&["crates/**"]), Vec::new()),
)]);

assert!(!triggers.may_fire_on(&push("feature/x", &[])));
assert!(triggers.may_fire_on(&push("main", &[])));
assert!(!triggers.fire_on(&push("main", &[])));
}

#[test]
fn ignored_paths_do_not_trigger_on_their_own() {
let triggers = Triggers::new(vec![Trigger::new(
EventKind::Push,
Filter::default(),
Filter::new(Vec::new(), patterns(&["**.md"])),
)]);

assert!(!triggers.fire_on(&push("main", &["README.md"])));
assert!(
triggers.fire_on(&push("main", &["README.md", "src/main.rs"])),
"one path outside the ignore list is enough"
);
}

#[test]
fn an_event_of_another_kind_does_not_fire_the_trigger() {
let triggers = Triggers::new(vec![Trigger::new(
EventKind::PullRequest,
Filter::default(),
Filter::default(),
)]);

assert!(!triggers.fire_on(&push("main", &["src/main.rs"])));
assert!(triggers.fire_on(&Event::new(
EventKind::PullRequest,
"main".to_owned(),
vec!["src/main.rs".to_owned()],
)));
}

#[test]
fn glob_syntax_this_compiler_does_not_support_is_refused() {
let error = Pattern::parse("release/[0-9]*").expect_err("character classes are not supported");

assert!(
error.to_string().contains("does not support"),
"unexpected error: {error}"
);
}