diff --git a/crates/workflow/src/trigger.rs b/crates/workflow/src/trigger.rs --- a/crates/workflow/src/trigger.rs +++ b/crates/workflow/src/trigger.rs @@ -1,238 +1,245 @@ -use std::fmt; - -use thiserror::Error; - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub enum EventKind { - Push, - PullRequest, - Manual, - Schedule, -} - -#[derive(Clone, Debug, Eq, PartialEq)] -pub enum GitReference { - Branch(String), - Tag(String), -} - -/// What happened, as much of it as deciding needs: which kind, on which Git -/// reference, and which paths it touched. -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct Event { - kind: EventKind, - reference: GitReference, - changed_paths: Vec, -} - -#[derive(Clone, Debug, Default, Eq, PartialEq)] -pub struct Filter { - include: Vec, - exclude: Vec, -} - -/// 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, - tags: Filter, - paths: Filter, -} - -#[derive(Clone, Debug, Default, Eq, PartialEq)] -pub struct Triggers(Vec); - -#[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, reference: GitReference, changed_paths: Vec) -> Self { - Self { - kind, - reference, - changed_paths, - } - } - - #[must_use] - pub const fn kind(&self) -> EventKind { - self.kind - } - - #[must_use] - pub const fn reference(&self) -> &GitReference { - &self.reference - } -} - -impl GitReference { - #[must_use] - pub fn name(&self) -> &str { - match self { - Self::Branch(name) | Self::Tag(name) => name, - } - } - - #[must_use] - pub fn full_name(&self) -> String { - match self { - Self::Branch(name) => format!("refs/heads/{name}"), - Self::Tag(name) => format!("refs/tags/{name}"), - } - } -} - -impl Pattern { - pub fn parse(value: &str) -> Result { - 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, exclude: Vec) -> 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, tags: Filter, paths: Filter) -> Self { - Self { - kind, - branches, - tags, - paths, - } - } - - fn matches_kind_and_reference(&self, event: &Event) -> bool { - if self.kind != event.kind { - return false; - } - match event.reference() { - GitReference::Branch(branch) => { - (self.tags.is_empty() || !self.branches.is_empty()) && self.branches.admits(branch) - } - GitReference::Tag(tag) => { - (self.branches.is_empty() || !self.tags.is_empty()) && self.tags.admits(tag) - } - } - } - - #[must_use] - pub fn fires_on(&self, event: &Event) -> bool { - if !self.matches_kind_and_reference(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) -> 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_reference(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, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum GitReference { + Branch(String), + Tag(String), +} + +/// What happened, as much of it as deciding needs: which kind, on which Git +/// reference, and which paths it touched. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Event { + kind: EventKind, + reference: GitReference, + changed_paths: Vec, +} + +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct Filter { + include: Vec, + exclude: Vec, +} + +/// 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, + tags: Filter, + paths: Filter, +} + +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct Triggers(Vec); + +#[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, reference: GitReference, changed_paths: Vec) -> Self { + Self { + kind, + reference, + changed_paths, + } + } + + #[must_use] + pub const fn kind(&self) -> EventKind { + self.kind + } + + #[must_use] + pub const fn reference(&self) -> &GitReference { + &self.reference + } +} + +impl GitReference { + #[must_use] + pub fn name(&self) -> &str { + match self { + Self::Branch(name) | Self::Tag(name) => name, + } + } + + #[must_use] + pub fn full_name(&self) -> String { + match self { + Self::Branch(name) => format!("refs/heads/{name}"), + Self::Tag(name) => format!("refs/tags/{name}"), + } + } +} + +impl Pattern { + pub fn parse(value: &str) -> Result { + 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, exclude: Vec) -> 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, tags: Filter, paths: Filter) -> Self { + Self { + kind, + branches, + tags, + paths, + } + } + + fn matches_kind_and_reference(&self, event: &Event) -> bool { + if self.kind != event.kind { + return false; + } + match event.reference() { + GitReference::Branch(branch) => { + (self.tags.is_empty() || !self.branches.is_empty()) && self.branches.admits(branch) + } + GitReference::Tag(tag) => { + (self.branches.is_empty() || !self.tags.is_empty()) && self.tags.admits(tag) + } + } + } + + #[must_use] + pub fn fires_on(&self, event: &Event) -> bool { + if !self.matches_kind_and_reference(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) -> 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_reference(event)) + } + + #[must_use] + pub fn needs_changed_paths(&self, event: &Event) -> bool { + self.0 + .iter() + .any(|trigger| trigger.matches_kind_and_reference(event) && !trigger.paths.is_empty()) + } + + #[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"), + } + } +} diff --git a/crates/workflow/tests/trigger.rs b/crates/workflow/tests/trigger.rs --- a/crates/workflow/tests/trigger.rs +++ b/crates/workflow/tests/trigger.rs @@ -1,189 +1,205 @@ -#![allow(clippy::expect_used, clippy::panic, clippy::unwrap_used)] - -use syncode_workflow::{Event, EventKind, Filter, GitReference, Pattern, Trigger, Triggers}; - -fn pattern(value: &str) -> Pattern { - Pattern::parse(value).unwrap_or_else(|error| panic!("pattern {value}: {error}")) -} - -fn patterns(values: &[&str]) -> Vec { - values.iter().copied().map(pattern).collect() -} - -fn push(branch: &str, paths: &[&str]) -> Event { - Event::new( - EventKind::Push, - GitReference::Branch(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(), - 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(), - 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(), - 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::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::default(), - 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::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(), - Filter::default(), - )]); - - assert!(!triggers.fire_on(&push("main", &["src/main.rs"]))); - assert!(triggers.fire_on(&Event::new( - EventKind::PullRequest, - GitReference::Branch("main".to_owned()), - vec!["src/main.rs".to_owned()], - ))); -} - -#[test] -fn branch_and_tag_filters_are_distinct() { - let triggers = Triggers::new(vec![Trigger::new( - EventKind::Push, - Filter::new(patterns(&["main"]), Vec::new()), - Filter::new(patterns(&["v*"]), Vec::new()), - Filter::default(), - )]); - - assert!(triggers.fire_on(&push("main", &[]))); - assert!(!triggers.fire_on(&push("feature/x", &[]))); - assert!(triggers.fire_on(&Event::new( - EventKind::Push, - GitReference::Tag("v0.4.0".to_owned()), - Vec::new(), - ))); - assert!(!triggers.fire_on(&Event::new( - EventKind::Push, - GitReference::Tag("release-candidate".to_owned()), - Vec::new(), - ))); -} - -#[test] -fn a_filter_for_one_reference_kind_excludes_the_other_kind() { - let branches = Triggers::new(vec![Trigger::new( - EventKind::Push, - Filter::new(patterns(&["main"]), Vec::new()), - Filter::default(), - Filter::default(), - )]); - let tags = Triggers::new(vec![Trigger::new( - EventKind::Push, - Filter::default(), - Filter::new(patterns(&["v*"]), Vec::new()), - Filter::default(), - )]); - - let tag = Event::new( - EventKind::Push, - GitReference::Tag("v0.4.0".to_owned()), - Vec::new(), - ); - assert!(!branches.fire_on(&tag)); - assert!(!tags.fire_on(&push("main", &[]))); -} - -#[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, GitReference, Pattern, Trigger, Triggers}; + +fn pattern(value: &str) -> Pattern { + Pattern::parse(value).unwrap_or_else(|error| panic!("pattern {value}: {error}")) +} + +fn patterns(values: &[&str]) -> Vec { + values.iter().copied().map(pattern).collect() +} + +fn push(branch: &str, paths: &[&str]) -> Event { + Event::new( + EventKind::Push, + GitReference::Branch(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(), + 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(), + 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(), + 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::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::default(), + Filter::new(patterns(&["crates/**"]), Vec::new()), + )]); + + assert!(!triggers.may_fire_on(&push("feature/x", &[]))); + assert!(triggers.may_fire_on(&push("main", &[]))); + assert!(!triggers.needs_changed_paths(&push("feature/x", &[]))); + assert!(triggers.needs_changed_paths(&push("main", &[]))); + assert!(!triggers.fire_on(&push("main", &[]))); +} + +#[test] +fn a_pathless_trigger_does_not_need_changed_paths() { + let triggers = Triggers::new(vec![Trigger::new( + EventKind::Push, + Filter::new(patterns(&["main"]), Vec::new()), + Filter::default(), + Filter::default(), + )]); + + assert!(triggers.may_fire_on(&push("main", &[]))); + assert!(!triggers.needs_changed_paths(&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::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(), + Filter::default(), + )]); + + assert!(!triggers.fire_on(&push("main", &["src/main.rs"]))); + assert!(triggers.fire_on(&Event::new( + EventKind::PullRequest, + GitReference::Branch("main".to_owned()), + vec!["src/main.rs".to_owned()], + ))); +} + +#[test] +fn branch_and_tag_filters_are_distinct() { + let triggers = Triggers::new(vec![Trigger::new( + EventKind::Push, + Filter::new(patterns(&["main"]), Vec::new()), + Filter::new(patterns(&["v*"]), Vec::new()), + Filter::default(), + )]); + + assert!(triggers.fire_on(&push("main", &[]))); + assert!(!triggers.fire_on(&push("feature/x", &[]))); + assert!(triggers.fire_on(&Event::new( + EventKind::Push, + GitReference::Tag("v0.4.0".to_owned()), + Vec::new(), + ))); + assert!(!triggers.fire_on(&Event::new( + EventKind::Push, + GitReference::Tag("release-candidate".to_owned()), + Vec::new(), + ))); +} + +#[test] +fn a_filter_for_one_reference_kind_excludes_the_other_kind() { + let branches = Triggers::new(vec![Trigger::new( + EventKind::Push, + Filter::new(patterns(&["main"]), Vec::new()), + Filter::default(), + Filter::default(), + )]); + let tags = Triggers::new(vec![Trigger::new( + EventKind::Push, + Filter::default(), + Filter::new(patterns(&["v*"]), Vec::new()), + Filter::default(), + )]); + + let tag = Event::new( + EventKind::Push, + GitReference::Tag("v0.4.0".to_owned()), + Vec::new(), + ); + assert!(!branches.fire_on(&tag)); + assert!(!tags.fire_on(&push("main", &[]))); +} + +#[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}" + ); +}