Align trigger preflight with released workflow API #17

Manually merged
day01 merged 8 commits from feat/0.6-trigger-preflight-aligned into develop 2026-08-31 07:59:37 +00:00
5 changed files with 183 additions and 46 deletions
Showing only changes of commit 263e4aec9a - Show all commits
@@ -1,182 +1,217 @@
//! What a workflow says fires it. `on:` has three shapes in this dialect and
//! each event has its own, so reading one the way another is written refuses a
//! workflow that is spelled exactly as the dialect says to spell it.

#![allow(clippy::expect_used, clippy::panic, clippy::unwrap_used)]

use syncode_workflow_github_actions::workflow::{Workflow, parse};

#[test]
fn reads_the_events_a_workflow_declares() {
let node = parse(
r#"
on:
push:
branches: [main, "release/**"]
paths-ignore: ["**.md"]
jobs:
build:
runs-on: ubuntu-latest
steps:
- run: echo one
"#,
)
.unwrap_or_else(|error| panic!("parse: {error}"));
let workflow = Workflow::from_node(&node).unwrap_or_else(|error| panic!("model: {error}"));

let touched = syncode_workflow::Event::new(
syncode_workflow::EventKind::Push,
"release/0.3".to_owned(),
vec!["crates/workflow/src/lib.rs".to_owned()],
);
let docs_only = syncode_workflow::Event::new(
syncode_workflow::EventKind::Push,
"main".to_owned(),
vec!["README.md".to_owned()],
);
let other_branch = syncode_workflow::Event::new(
syncode_workflow::EventKind::Push,
"wip/x".to_owned(),
vec!["src/main.rs".to_owned()],
);

assert!(workflow.triggers.fire_on(&touched));
assert!(!workflow.triggers.fire_on(&docs_only));
assert!(!workflow.triggers.fire_on(&other_branch));
}

#[test]
fn reads_a_bare_list_of_events() {
let node = parse(
r#"
on: [push, workflow_dispatch]
jobs:
build:
runs-on: ubuntu-latest
steps:
- run: echo one
"#,
)
.unwrap_or_else(|error| panic!("parse: {error}"));
let workflow = Workflow::from_node(&node).unwrap_or_else(|error| panic!("model: {error}"));

assert!(workflow.triggers.fire_on(&syncode_workflow::Event::new(
syncode_workflow::EventKind::Manual,
"anything".to_owned(),
Vec::new(),
)));
}

#[test]
fn refuses_an_event_this_control_plane_does_not_act_on() {
let node = parse(
r#"
on: [deployment_status]
jobs:
build:
runs-on: ubuntu-latest
steps:
- run: echo one
"#,
)
.unwrap_or_else(|error| panic!("parse: {error}"));
let error = Workflow::from_node(&node)
.expect_err("an event nobody handles must not read as triggering nothing");

assert!(
error.to_string().contains("cannot act on"),
"unexpected error: {error}"
);
}

#[test]
fn a_callable_workflow_declares_itself_without_declaring_an_event() {
let node = parse(
r#"
on:
workflow_call:
inputs:
target:
type: string
jobs:
build:
runs-on: ubuntu-latest
steps:
- run: echo one
"#,
)
.unwrap_or_else(|error| panic!("parse: {error}"));
let workflow = Workflow::from_node(&node).unwrap_or_else(|error| panic!("model: {error}"));

assert!(
workflow.triggers.is_empty(),
"being callable is not an event that fires a run"
);
assert!(!workflow.triggers.fire_on(&syncode_workflow::Event::new(
syncode_workflow::EventKind::Push,
"main".to_owned(),
vec!["src/main.rs".to_owned()],
)));
}

#[test]
fn a_schedule_is_read_as_the_sequence_of_cron_entries_it_is_written_as() {
let node = parse(
r#"
on:
schedule:
- cron: "17 3 * * *"
push:
branches: [main]
jobs:
build:
runs-on: ubuntu-latest
steps:
- run: echo one
"#,
)
.unwrap_or_else(|error| panic!("parse: {error}"));
let workflow = Workflow::from_node(&node).unwrap_or_else(|error| panic!("model: {error}"));

assert!(
!workflow.triggers.is_empty(),
"a schedule alongside a push is a trigger, not a refusal"
);
assert!(workflow.triggers.fire_on(&syncode_workflow::Event::new(
syncode_workflow::EventKind::Schedule,
"main".to_owned(),
Vec::new(),
)));
assert!(
workflow.triggers.fire_on(&syncode_workflow::Event::new(
syncode_workflow::EventKind::Push,
"main".to_owned(),
vec!["src/main.rs".to_owned()],
)),
"reading the schedule must not cost the push its filters"
);
}

#[test]
fn a_schedule_written_as_a_mapping_is_refused_rather_than_guessed_at() {
let node = parse(
r#"
on:
schedule:
cron: "17 3 * * *"
jobs:
build:
runs-on: ubuntu-latest
steps:
- run: echo one
"#,
)
.unwrap_or_else(|error| panic!("parse: {error}"));
let error = Workflow::from_node(&node)
.expect_err("a schedule that is not a sequence is not a schedule");

assert!(
error.to_string().contains("$.on.schedule"),
"unexpected error: {error}"
);
}
//! What a workflow says fires it. `on:` has three shapes in this dialect and
//! each event has its own, so reading one the way another is written refuses a
//! workflow that is spelled exactly as the dialect says to spell it.

#![allow(clippy::expect_used, clippy::panic, clippy::unwrap_used)]

use syncode_workflow_github_actions::workflow::{Workflow, parse};

#[test]
fn reads_the_events_a_workflow_declares() {
let node = parse(
r#"
on:
push:
branches: [main, "release/**"]
paths-ignore: ["**.md"]
jobs:
build:
runs-on: ubuntu-latest
steps:
- run: echo one
"#,
)
.unwrap_or_else(|error| panic!("parse: {error}"));
let workflow = Workflow::from_node(&node).unwrap_or_else(|error| panic!("model: {error}"));

let touched = syncode_workflow::Event::new(
syncode_workflow::EventKind::Push,
syncode_workflow::GitReference::Branch("release/0.3".to_owned()),
vec!["crates/workflow/src/lib.rs".to_owned()],
);
let docs_only = syncode_workflow::Event::new(
syncode_workflow::EventKind::Push,
syncode_workflow::GitReference::Branch("main".to_owned()),
vec!["README.md".to_owned()],
);
let other_branch = syncode_workflow::Event::new(
syncode_workflow::EventKind::Push,
syncode_workflow::GitReference::Branch("wip/x".to_owned()),
vec!["src/main.rs".to_owned()],
);

assert!(workflow.triggers.fire_on(&touched));
assert!(!workflow.triggers.fire_on(&docs_only));
assert!(!workflow.triggers.fire_on(&other_branch));
}

#[test]
fn reads_a_bare_list_of_events() {
let node = parse(
r#"
on: [push, workflow_dispatch]
jobs:
build:
runs-on: ubuntu-latest
steps:
- run: echo one
"#,
)
.unwrap_or_else(|error| panic!("parse: {error}"));
let workflow = Workflow::from_node(&node).unwrap_or_else(|error| panic!("model: {error}"));

assert!(workflow.triggers.fire_on(&syncode_workflow::Event::new(
syncode_workflow::EventKind::Manual,
syncode_workflow::GitReference::Branch("anything".to_owned()),
Vec::new(),
)));
}

#[test]
fn refuses_an_event_this_control_plane_does_not_act_on() {
let node = parse(
r#"
on: [deployment_status]
jobs:
build:
runs-on: ubuntu-latest
steps:
- run: echo one
"#,
)
.unwrap_or_else(|error| panic!("parse: {error}"));
let error = Workflow::from_node(&node)
.expect_err("an event nobody handles must not read as triggering nothing");

assert!(
error.to_string().contains("cannot act on"),
"unexpected error: {error}"
);
}

#[test]
fn a_callable_workflow_declares_itself_without_declaring_an_event() {
let node = parse(
r#"
on:
workflow_call:
inputs:
target:
type: string
jobs:
build:
runs-on: ubuntu-latest
steps:
- run: echo one
"#,
)
.unwrap_or_else(|error| panic!("parse: {error}"));
let workflow = Workflow::from_node(&node).unwrap_or_else(|error| panic!("model: {error}"));

assert!(
workflow.triggers.is_empty(),
"being callable is not an event that fires a run"
);
assert!(!workflow.triggers.fire_on(&syncode_workflow::Event::new(
syncode_workflow::EventKind::Push,
syncode_workflow::GitReference::Branch("main".to_owned()),
vec!["src/main.rs".to_owned()],
)));
}

#[test]
fn a_schedule_is_read_as_the_sequence_of_cron_entries_it_is_written_as() {
let node = parse(
r#"
on:
schedule:
- cron: "17 3 * * *"
push:
branches: [main]
jobs:
build:
runs-on: ubuntu-latest
steps:
- run: echo one
"#,
)
.unwrap_or_else(|error| panic!("parse: {error}"));
let workflow = Workflow::from_node(&node).unwrap_or_else(|error| panic!("model: {error}"));

assert!(
!workflow.triggers.is_empty(),
"a schedule alongside a push is a trigger, not a refusal"
);
assert!(workflow.triggers.fire_on(&syncode_workflow::Event::new(
syncode_workflow::EventKind::Schedule,
syncode_workflow::GitReference::Branch("main".to_owned()),
Vec::new(),
)));
assert!(
workflow.triggers.fire_on(&syncode_workflow::Event::new(
syncode_workflow::EventKind::Push,
syncode_workflow::GitReference::Branch("main".to_owned()),
vec!["src/main.rs".to_owned()],
)),
"reading the schedule must not cost the push its filters"
);
}

#[test]
fn reads_tag_filters_without_treating_them_as_branches() {
let node = parse(
r#"
on:
push:
tags: ["v*"]
tags-ignore: ["v*-rc*"]
jobs:
release:
runs-on: ubuntu-latest
steps:
- run: echo release
"#,
)
.unwrap_or_else(|error| panic!("parse: {error}"));
let workflow = Workflow::from_node(&node).unwrap_or_else(|error| panic!("model: {error}"));

assert!(workflow.triggers.fire_on(&syncode_workflow::Event::new(
syncode_workflow::EventKind::Push,
syncode_workflow::GitReference::Tag("v0.4.0".to_owned()),
Vec::new(),
)));
assert!(!workflow.triggers.fire_on(&syncode_workflow::Event::new(
syncode_workflow::EventKind::Push,
syncode_workflow::GitReference::Tag("v0.4.0-rc1".to_owned()),
Vec::new(),
)));
assert!(!workflow.triggers.fire_on(&syncode_workflow::Event::new(
syncode_workflow::EventKind::Push,
syncode_workflow::GitReference::Branch("v0.4.0".to_owned()),
Vec::new(),
)));
}

#[test]
fn a_schedule_written_as_a_mapping_is_refused_rather_than_guessed_at() {
let node = parse(
r#"
on:
schedule:
cron: "17 3 * * *"
jobs:
build:
runs-on: ubuntu-latest
steps:
- run: echo one
"#,
)
.unwrap_or_else(|error| panic!("parse: {error}"));
let error = Workflow::from_node(&node)
.expect_err("a schedule that is not a sequence is not a schedule");

assert!(
error.to_string().contains("$.on.schedule"),
"unexpected error: {error}"
);
}
+3 -1
View File
@@ -1,46 +1,48 @@
mod action;
mod compiler;
mod container;
mod dynamic;
mod hir;
mod identifier;
mod plan;
mod repository;
mod reusable;
mod runtime;
mod source;
mod strategy;
mod template;
mod trigger;
mod value;

pub use action::{
ActionIdentityError, ActionPath, ActionReference, GitCommit, OciDigest, PlannedAction,
ResolvedAction, ResolvedLocalAction, ResolvedOciAction, ResolvedRemoteAction, Sha256Digest,
};
pub use compiler::WorkflowCompiler;
pub use container::{ContainerCredentials, ContainerDefinition, ServiceDefinition};
pub use dynamic::{DynamicObject, Value};
pub use hir::{
ActionInvocation, HirError, JobHir, JobHirParts, ShellOperation, StepHir, StepHirParts,
StepOperation, WorkflowHir,
};
pub use identifier::{
EnvironmentKey, IdentifierError, InputName, JobKey, OutputName, PropertyName, SecretName,
ServiceKey, StepOrdinal, StepReference, VariableName, WorkflowName,
};
pub use plan::{
ActionStep, ExecutionPlan, JobPlan, JobPlanParts, PlanError, PlanSchemaError,
PlanSchemaVersion, ShellStep, StepKind, StepPlan, StepPlanParts, VersionedPlan, expand, plans,
};
pub use repository::{RepositoryUrl, Revision, SourceValueError};
pub use reusable::{ReusableWorkflow, ReusableWorkflowError};
pub use runtime::JavaScriptRuntime;
pub use source::{CompiledPlan, WorkflowDialect, WorkflowSource};
pub use strategy::{JobStrategy, PositiveIntegerValue};
pub use template::{Template, TemplateSegment};
pub use trigger::{Event, EventKind, Filter, Pattern, Trigger, TriggerError, Triggers};
pub use value::{
BooleanValue, Defaults, DurationValue, DurationValueError, EnvironmentBinding, InputBinding,
OutputBinding, PositiveDuration, RunnerSelection, RunnerSelectionError, Timeout,
};
mod action;
mod compiler;
mod container;
mod dynamic;
mod hir;
mod identifier;
mod plan;
mod repository;
mod reusable;
mod runtime;
mod source;
mod strategy;
mod template;
mod trigger;
mod value;

pub use action::{
ActionIdentityError, ActionPath, ActionReference, GitCommit, OciDigest, PlannedAction,
ResolvedAction, ResolvedLocalAction, ResolvedOciAction, ResolvedRemoteAction, Sha256Digest,
};
pub use compiler::WorkflowCompiler;
pub use container::{ContainerCredentials, ContainerDefinition, ServiceDefinition};
pub use dynamic::{DynamicObject, Value};
pub use hir::{
ActionInvocation, HirError, JobHir, JobHirParts, ShellOperation, StepHir, StepHirParts,
StepOperation, WorkflowHir,
};
pub use identifier::{
EnvironmentKey, IdentifierError, InputName, JobKey, OutputName, PropertyName, SecretName,
ServiceKey, StepOrdinal, StepReference, VariableName, WorkflowName,
};
pub use plan::{
ActionStep, ExecutionPlan, JobPlan, JobPlanParts, PlanError, PlanSchemaError,
PlanSchemaVersion, ShellStep, StepKind, StepPlan, StepPlanParts, VersionedPlan, expand, plans,
};
pub use repository::{RepositoryUrl, Revision, SourceValueError};
pub use reusable::{ReusableWorkflow, ReusableWorkflowError};
pub use runtime::JavaScriptRuntime;
pub use source::{CompiledPlan, WorkflowDialect, WorkflowSource};
pub use strategy::{JobStrategy, PositiveIntegerValue};
pub use template::{Template, TemplateSegment};
pub use trigger::{
Event, EventKind, Filter, GitReference, Pattern, Trigger, TriggerError, Triggers,
};
pub use value::{
BooleanValue, Defaults, DurationValue, DurationValueError, EnvironmentBinding, InputBinding,
OutputBinding, PositiveDuration, RunnerSelection, RunnerSelectionError, Timeout,
};
+42 -9
View File
@@ -1,195 +1,228 @@
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,
}

#[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,
}
}

#[must_use]
pub fn fires_on(&self, event: &Event) -> bool {
if self.kind != event.kind {
return false;
}
let reference_admitted = 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)
}
};
if !reference_admitted {
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"),
}
}
}
+56 -3
View File
@@ -1,122 +1,175 @@
#![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, 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 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}"
);
}
@@ -1,119 +1,133 @@
use syncode_workflow::{EventKind, Filter, Pattern, Trigger, Triggers};

use super::{Node, WorkflowModelError};

/// Read `on:` in the three shapes the dialect allows: a single event, a
/// sequence of events, or a mapping of events to their filters.
pub fn read(root: &Node) -> Result<Triggers, WorkflowModelError> {
let Some(node) = root.get("on") else {
return Ok(Triggers::default());
};
let triggers =
match node {
Node::String(name) => kind(name)?
.map(|kind| Trigger::new(kind, Filter::default(), Filter::default()))
.into_iter()
.collect(),
Node::Sequence(names) => names
.iter()
.map(|node| {
let name = node.as_str().ok_or_else(|| unsupported("$.on"))?;
Ok(kind(name)?
.map(|kind| Trigger::new(kind, Filter::default(), Filter::default())))
})
.collect::<Result<Vec<_>, WorkflowModelError>>()?
.into_iter()
.flatten()
.collect(),
Node::Mapping(entries) => entries
.iter()
.map(|(name, node)| read_trigger(name, node))
.collect::<Result<Vec<_>, WorkflowModelError>>()?
.into_iter()
.flatten()
.collect(),
_ => return Err(unsupported("$.on")),
};
Ok(Triggers::new(triggers))
}

fn read_trigger(name: &str, node: &Node) -> Result<Option<Trigger>, WorkflowModelError> {
let Some(kind) = kind(name)? else {
return Ok(None);
};
let path = format!("$.on.{name}");

// A schedule is written as a sequence of cron entries, because it says when
// it fires rather than what it fires for. There is no branch or path to
// filter on, and reading it as a mapping like the others refuses a workflow
// that is written exactly as the dialect says to write one.
if kind == EventKind::Schedule {
node.as_sequence().ok_or_else(|| unsupported(&path))?;
return Ok(Some(Trigger::new(
kind,
Filter::default(),
Filter::default(),
)));
}

let (Node::Mapping(_) | Node::Null) = node else {
return Err(unsupported(&path));
};
Ok(Some(Trigger::new(
kind,
filter(node, "branches", "branches-ignore", &path)?,
filter(node, "paths", "paths-ignore", &path)?,
)))
}

fn filter(
node: &Node,
include: &str,
exclude: &str,
path: &str,
) -> Result<Filter, WorkflowModelError> {
Ok(Filter::new(
patterns(node.get(include), &format!("{path}.{include}"))?,
patterns(node.get(exclude), &format!("{path}.{exclude}"))?,
))
}

fn patterns(node: Option<&Node>, path: &str) -> Result<Vec<Pattern>, WorkflowModelError> {
let Some(node) = node else {
return Ok(Vec::new());
};
node.as_sequence()
.ok_or_else(|| unsupported(path))?
.iter()
.map(|value| {
let value = value.as_str().ok_or_else(|| unsupported(path))?;
Pattern::parse(value).map_err(|error| WorkflowModelError::UnsupportedTrigger {
path: path.to_owned(),
reason: error.to_string(),
})
})
.collect()
}

/// `workflow_call` says the workflow can be called, not that something happened,
/// so it reads as no trigger at all rather than as an unknown event.
fn kind(name: &str) -> Result<Option<EventKind>, WorkflowModelError> {
match name {
"push" => Ok(Some(EventKind::Push)),
"pull_request" => Ok(Some(EventKind::PullRequest)),
"workflow_dispatch" => Ok(Some(EventKind::Manual)),
"schedule" => Ok(Some(EventKind::Schedule)),
"workflow_call" => Ok(None),
other => Err(WorkflowModelError::UnsupportedTrigger {
path: "$.on".to_owned(),
reason: format!("{other:?} is not an event this control plane acts on"),
}),
}
}

fn unsupported(path: &str) -> WorkflowModelError {
WorkflowModelError::UnsupportedTrigger {
path: path.to_owned(),
reason: "the declaration has a shape this compiler does not read".to_owned(),
}
}
use syncode_workflow::{EventKind, Filter, Pattern, Trigger, Triggers};

use super::{Node, WorkflowModelError};

/// Read `on:` in the three shapes the dialect allows: a single event, a
/// sequence of events, or a mapping of events to their filters.
pub fn read(root: &Node) -> Result<Triggers, WorkflowModelError> {
let Some(node) = root.get("on") else {
return Ok(Triggers::default());
};
let triggers = match node {
Node::String(name) => kind(name)?
.map(|kind| {
Trigger::new(
kind,
Filter::default(),
Filter::default(),
Filter::default(),
)
})
.into_iter()
.collect(),
Node::Sequence(names) => names
.iter()
.map(|node| {
let name = node.as_str().ok_or_else(|| unsupported("$.on"))?;
Ok(kind(name)?.map(|kind| {
Trigger::new(
kind,
Filter::default(),
Filter::default(),
Filter::default(),
)
}))
})
.collect::<Result<Vec<_>, WorkflowModelError>>()?
.into_iter()
.flatten()
.collect(),
Node::Mapping(entries) => entries
.iter()
.map(|(name, node)| read_trigger(name, node))
.collect::<Result<Vec<_>, WorkflowModelError>>()?
.into_iter()
.flatten()
.collect(),
_ => return Err(unsupported("$.on")),
};
Ok(Triggers::new(triggers))
}

fn read_trigger(name: &str, node: &Node) -> Result<Option<Trigger>, WorkflowModelError> {
let Some(kind) = kind(name)? else {
return Ok(None);
};
let path = format!("$.on.{name}");

// A schedule is written as a sequence of cron entries, because it says when
// it fires rather than what it fires for. There is no branch or path to
// filter on, and reading it as a mapping like the others refuses a workflow
// that is written exactly as the dialect says to write one.
if kind == EventKind::Schedule {
node.as_sequence().ok_or_else(|| unsupported(&path))?;
return Ok(Some(Trigger::new(
kind,
Filter::default(),
Filter::default(),
Filter::default(),
)));
}

let (Node::Mapping(_) | Node::Null) = node else {
return Err(unsupported(&path));
};
Ok(Some(Trigger::new(
kind,
filter(node, "branches", "branches-ignore", &path)?,
filter(node, "tags", "tags-ignore", &path)?,
filter(node, "paths", "paths-ignore", &path)?,
)))
}

fn filter(
node: &Node,
include: &str,
exclude: &str,
path: &str,
) -> Result<Filter, WorkflowModelError> {
Ok(Filter::new(
patterns(node.get(include), &format!("{path}.{include}"))?,
patterns(node.get(exclude), &format!("{path}.{exclude}"))?,
))
}

fn patterns(node: Option<&Node>, path: &str) -> Result<Vec<Pattern>, WorkflowModelError> {
let Some(node) = node else {
return Ok(Vec::new());
};
node.as_sequence()
.ok_or_else(|| unsupported(path))?
.iter()
.map(|value| {
let value = value.as_str().ok_or_else(|| unsupported(path))?;
Pattern::parse(value).map_err(|error| WorkflowModelError::UnsupportedTrigger {
path: path.to_owned(),
reason: error.to_string(),
})
})
.collect()
}

/// `workflow_call` says the workflow can be called, not that something happened,
/// so it reads as no trigger at all rather than as an unknown event.
fn kind(name: &str) -> Result<Option<EventKind>, WorkflowModelError> {
match name {
"push" => Ok(Some(EventKind::Push)),
"pull_request" => Ok(Some(EventKind::PullRequest)),
"workflow_dispatch" => Ok(Some(EventKind::Manual)),
"schedule" => Ok(Some(EventKind::Schedule)),
"workflow_call" => Ok(None),
other => Err(WorkflowModelError::UnsupportedTrigger {
path: "$.on".to_owned(),
reason: format!("{other:?} is not an event this control plane acts on"),
}),
}
}

fn unsupported(path: &str) -> WorkflowModelError {
WorkflowModelError::UnsupportedTrigger {
path: path.to_owned(),
reason: "the declaration has a shape this compiler does not read".to_owned(),
}
}