Identify triggers that need changed paths #18

Manually merged
day01 merged 1 commits from feat/0.6-lazy-trigger-paths into develop 2026-08-31 08:26:12 +00:00
2 changed files with 23 additions and 0 deletions
+7
View File
@@ -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<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,
tags: 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, reference: GitReference, changed_paths: Vec<String>) -> 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<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, 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<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_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<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,
tags: 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, reference: GitReference, changed_paths: Vec<String>) -> 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<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, 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<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_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"),
}
}
}
+16
View File
@@ -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<Pattern> {
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<Pattern> {
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}"
);
}