use std::path::{Path, PathBuf};
#[derive(Debug)]
pub(crate) enum PrefilterDecision {
Proceed,
Skip { reason: String },
}
pub(crate) fn effective_strict_filter(strict: bool, no_strict: bool) -> bool {
strict && !no_strict
}
pub(crate) fn validate_event_requires_base_branch(
event_name: &str,
strict_filter: bool,
) -> Result<(), String> {
if !matches!(event_name, "pull_request" | "pull_request_target") {
return Ok(());
}
if strict_filter {
return Err(format!(
"event `{}` without --base-branch is rejected under --strict-filter: \
`branches:` filters on pull_request events evaluate against the PR target \
branch, and without one every such workflow is silently reported as not \
triggering. Pass --base-branch <name>, or use --no-strict-filter to proceed.",
event_name
));
}
wrkflw_logging::warning(&format!(
"event `{}` without --base-branch: workflows that use `branches:` to constrain \
the PR target branch will be reported as not triggering. \
--no-strict-filter allowed this to proceed.",
event_name,
));
Ok(())
}
pub(crate) struct PrefilterRequest<'a> {
pub(crate) workflow_path: &'a Path,
pub(crate) event: Option<&'a String>,
pub(crate) diff: bool,
pub(crate) changed_files: Option<&'a Vec<String>>,
pub(crate) diff_base: Option<&'a str>,
pub(crate) diff_head: Option<&'a String>,
pub(crate) base_branch: Option<&'a String>,
pub(crate) activity_type: Option<&'a String>,
pub(crate) verbose: bool,
pub(crate) strict_filter: bool,
}
pub(crate) async fn run_trigger_prefilter(
req: PrefilterRequest<'_>,
) -> Result<PrefilterDecision, String> {
if !req.workflow_path.is_file() {
if req.workflow_path.is_dir() {
return Err(format!(
"--diff/--event/--changed-files require a single workflow file, not a directory.\n\
Hint: point at a specific .yml file, or use `wrkflw watch {}` for directory-wide watching.",
req.workflow_path.display()
));
} else {
return Err(format!(
"workflow file not found: {}",
req.workflow_path.display()
));
}
}
let event_name = req.event.cloned().unwrap_or_else(|| "push".to_string());
let repo_root: Option<PathBuf> =
match tokio::task::spawn_blocking(wrkflw_trigger_filter::find_repo_root_detailed).await {
Ok(Ok(p)) => Some(p),
Ok(Err(wrkflw_trigger_filter::FindRepoRootError::NotInRepository)) => None,
Ok(Err(e)) => return Err(e.to_string()),
Err(join_err) => return Err(format!("find_repo_root task panicked: {}", join_err)),
};
let cwd_for_git: Option<&Path> = repo_root.as_deref();
let mut event_context = build_event_context(&req, &event_name, cwd_for_git).await?;
apply_base_branch(
&mut event_context,
&event_name,
req.base_branch,
req.strict_filter,
)?;
if let Some(activity) = req.activity_type {
event_context.activity_type = Some(activity.clone());
}
for w in event_context.warnings.take() {
wrkflw_logging::warning(&w);
}
if req.verbose {
wrkflw_logging::info(&format!(
"Trigger filter: event={}, branch={:?}, base_branch={:?}, activity_type={:?}, changed_files={:?}",
event_context.event_name,
event_context.branch,
event_context.base_branch,
event_context.activity_type,
event_context.changed_files
));
}
let workflow_path_owned = req.workflow_path.to_path_buf();
let tf_config = wrkflw_trigger_filter::TriggerFilterConfig::default();
let mut trigger_config = tokio::task::spawn_blocking(move || {
wrkflw_trigger_filter::load_trigger_config_cached(&workflow_path_owned, &tf_config)
})
.await
.map_err(|e| format!("workflow parse task panicked: {}", e))?
.map_err(|e| format!("parsing workflow: {}", e))?;
for w in trigger_config.warnings.take() {
wrkflw_logging::warning(&w);
}
let match_result = wrkflw_trigger_filter::evaluate_trigger(&trigger_config, &event_context);
if !match_result.matches {
return Ok(PrefilterDecision::Skip {
reason: match_result.reason,
});
}
wrkflw_logging::info(&format!("Trigger matched: {}", match_result.reason));
Ok(PrefilterDecision::Proceed)
}
pub(crate) async fn build_event_context(
req: &PrefilterRequest<'_>,
event_name: &str,
cwd_for_git: Option<&Path>,
) -> Result<wrkflw_trigger_filter::EventContext, String> {
if let Some(files) = req.changed_files {
let normalized = wrkflw_trigger_filter::normalize_user_changed_files(files)
.map_err(|e| format!("invalid --changed-files entry: {}", e))?;
return wrkflw_trigger_filter::context_from_changed_files(
event_name,
normalized,
cwd_for_git,
)
.await
.map_err(|e| format!("failed to build event context: {}", e));
}
if req.diff {
return if let Some(head) = req.diff_head {
let base = req.diff_base.unwrap_or("HEAD");
wrkflw_trigger_filter::context_from_diff_range(event_name, base, head, cwd_for_git)
.await
} else if let Some(base) = req.diff_base {
wrkflw_trigger_filter::auto_detect_context(event_name, base, cwd_for_git).await
} else {
wrkflw_trigger_filter::auto_detect_context_default_base(
event_name,
cwd_for_git,
req.verbose,
)
.await
}
.map_err(|e| format!("failed to get git diff: {}", e));
}
if req.strict_filter {
return Err(
"--event was supplied without --diff or --changed-files, so no changed files \
are known and any workflow with a `paths:` filter would be silently skipped. \
Pass --diff to auto-detect from git, --changed-files to supply them \
explicitly, or --no-strict-filter to proceed anyway."
.to_string(),
);
}
wrkflw_logging::warning(
"--event was supplied without --diff or --changed-files; \
path filters will not match because no changed files are known. \
--no-strict-filter allowed this to proceed.",
);
wrkflw_trigger_filter::context_from_changed_files(event_name, vec![], cwd_for_git)
.await
.map_err(|e| format!("failed to build event context: {}", e))
}
pub(crate) fn apply_base_branch(
ctx: &mut wrkflw_trigger_filter::EventContext,
event_name: &str,
base_branch: Option<&String>,
strict_filter: bool,
) -> Result<(), String> {
if let Some(base) = base_branch {
ctx.base_branch = Some(base.clone());
return Ok(());
}
validate_event_requires_base_branch(event_name, strict_filter)
}
#[cfg(test)]
mod prefilter_tests {
use super::*;
#[tokio::test(flavor = "current_thread")]
async fn directory_path_returns_err_with_watch_hint() {
let tmp = tempfile::TempDir::new().expect("tempdir");
let dir = tmp.path().to_path_buf();
let empty_files: Option<Vec<String>> = None;
let event = "push".to_string();
let req = PrefilterRequest {
workflow_path: &dir,
event: Some(&event),
diff: false,
changed_files: empty_files.as_ref(),
diff_base: None,
diff_head: None,
base_branch: None,
activity_type: None,
verbose: false,
strict_filter: false,
};
let err = run_trigger_prefilter(req)
.await
.expect_err("directory path must produce an Err");
assert!(
err.contains("single workflow file"),
"err must explain the single-file constraint, got: {}",
err
);
assert!(
err.contains("wrkflw watch"),
"err must suggest `wrkflw watch` for directory-wide watching, got: {}",
err
);
}
#[tokio::test(flavor = "current_thread")]
async fn missing_path_returns_err_with_not_found() {
let tmp = tempfile::TempDir::new().expect("tempdir");
let missing = tmp.path().join("does-not-exist.yml");
let event = "push".to_string();
let req = PrefilterRequest {
workflow_path: &missing,
event: Some(&event),
diff: false,
changed_files: None,
diff_base: None,
diff_head: None,
base_branch: None,
activity_type: None,
verbose: false,
strict_filter: false,
};
let err = run_trigger_prefilter(req)
.await
.expect_err("missing path must produce an Err");
assert!(
err.contains("not found"),
"err must name the not-found case, got: {}",
err
);
}
fn init_repo_for_test(dir: &Path) -> bool {
use std::process::Command as StdCommand;
let status = StdCommand::new("git")
.args(["-C", dir.to_str().unwrap(), "init", "--initial-branch=main"])
.status();
if !status.map(|s| s.success()).unwrap_or(false) {
return false;
}
for (k, v) in [("user.email", "t@t.t"), ("user.name", "t")] {
if StdCommand::new("git")
.args(["-C", dir.to_str().unwrap(), "config", k, v])
.status()
.map(|s| !s.success())
.unwrap_or(true)
{
return false;
}
}
let path = dir.join("a.txt");
if std::fs::write(&path, "1").is_err() {
return false;
}
if StdCommand::new("git")
.args(["-C", dir.to_str().unwrap(), "add", "a.txt"])
.status()
.map(|s| !s.success())
.unwrap_or(true)
{
return false;
}
if StdCommand::new("git")
.args([
"-C",
dir.to_str().unwrap(),
"commit",
"-m",
"init",
"--no-gpg-sign",
])
.status()
.map(|s| !s.success())
.unwrap_or(true)
{
return false;
}
true
}
fn git_available() -> bool {
use std::process::Command as StdCommand;
StdCommand::new("git")
.arg("--version")
.status()
.map(|s| s.success())
.unwrap_or(false)
}
#[tokio::test(flavor = "current_thread")]
async fn build_event_context_defaults_diff_base_to_head_when_only_diff_head_set() {
if !git_available() {
return;
}
let tmp = tempfile::TempDir::new().expect("tempdir");
let repo = tmp.path().to_path_buf();
if !init_repo_for_test(&repo) {
return;
}
let wf = repo.join("ci.yml");
std::fs::write(
&wf,
"name: ci\non: push\njobs:\n b:\n runs-on: ubuntu-latest\n steps:\n - run: echo hi\n",
)
.expect("write ci.yml");
let event = "push".to_string();
let head = "HEAD".to_string();
let req = PrefilterRequest {
workflow_path: &wf,
event: Some(&event),
diff: true,
changed_files: None,
diff_base: None,
diff_head: Some(&head),
base_branch: None,
activity_type: None,
verbose: false,
strict_filter: false,
};
let ctx = build_event_context(&req, "push", Some(&repo)).await.expect(
"build_event_context must succeed when --diff-head=HEAD and --diff-base is absent",
);
assert!(
ctx.changed_files_explicit,
"two-ref diff must mark changed_files as explicit"
);
assert!(
ctx.changed_files.is_empty(),
"HEAD..HEAD diff must be empty, got {:?}",
ctx.changed_files
);
let mut ctx = ctx;
let _ = ctx.warnings.take();
}
#[tokio::test(flavor = "current_thread")]
async fn skip_decision_returned_when_trigger_does_not_match() {
let tmp = tempfile::TempDir::new().expect("tempdir");
let wf = tmp.path().join("ci.yml");
std::fs::write(
&wf,
"name: ci\n\
on:\n push:\n paths:\n - 'irrelevant/**'\n\
jobs:\n build:\n runs-on: ubuntu-latest\n steps:\n - run: echo hi\n",
)
.expect("write workflow");
let event = "push".to_string();
let changed: Vec<String> = vec!["src/main.rs".to_string()];
let req = PrefilterRequest {
workflow_path: &wf,
event: Some(&event),
diff: false,
changed_files: Some(&changed),
diff_base: None,
diff_head: None,
base_branch: None,
activity_type: None,
verbose: false,
strict_filter: false,
};
let decision = run_trigger_prefilter(req)
.await
.expect("should not error on a valid workflow");
match decision {
PrefilterDecision::Skip { reason } => {
assert!(
reason.contains("paths"),
"skip reason must mention the paths filter, got: {}",
reason
);
}
PrefilterDecision::Proceed => {
panic!("expected Skip for non-matching paths, got Proceed");
}
}
}
#[tokio::test(flavor = "current_thread")]
async fn strict_filter_rejects_event_alone_without_diff_or_changed_files() {
let tmp = tempfile::TempDir::new().expect("tempdir");
let wf = tmp.path().join("ci.yml");
std::fs::write(
&wf,
"name: ci\n\
on:\n push:\n paths:\n - 'src/**'\n\
jobs:\n build:\n runs-on: ubuntu-latest\n steps:\n - run: echo hi\n",
)
.expect("write workflow");
let event = "push".to_string();
let req = PrefilterRequest {
workflow_path: &wf,
event: Some(&event),
diff: false,
changed_files: None,
diff_base: None,
diff_head: None,
base_branch: None,
activity_type: None,
verbose: false,
strict_filter: true,
};
let err = run_trigger_prefilter(req)
.await
.expect_err("strict mode must reject --event without --diff/--changed-files");
assert!(
err.contains("--diff") && err.contains("--changed-files"),
"error must point the user at the three escape hatches, got: {}",
err
);
assert!(
err.contains("--no-strict-filter"),
"error must name the legacy opt-out, got: {}",
err
);
}
#[tokio::test(flavor = "current_thread")]
async fn non_strict_filter_allows_event_alone_with_warning_and_empty_change_set() {
let tmp = tempfile::TempDir::new().expect("tempdir");
let wf = tmp.path().join("ci.yml");
std::fs::write(
&wf,
"name: ci\n\
on:\n push:\n paths:\n - 'src/**'\n\
jobs:\n build:\n runs-on: ubuntu-latest\n steps:\n - run: echo hi\n",
)
.expect("write workflow");
let event = "push".to_string();
let req = PrefilterRequest {
workflow_path: &wf,
event: Some(&event),
diff: false,
changed_files: None,
diff_base: None,
diff_head: None,
base_branch: None,
activity_type: None,
verbose: false,
strict_filter: false,
};
let decision = run_trigger_prefilter(req)
.await
.expect("non-strict mode must not error on --event alone");
match decision {
PrefilterDecision::Skip { reason } => {
assert!(
reason.contains("paths"),
"non-strict empty change set must Skip on a paths-gated \
workflow, got reason: {}",
reason
);
}
PrefilterDecision::Proceed => {
panic!(
"non-strict mode with empty change set must Skip a \
paths-gated workflow, got Proceed"
);
}
}
}
#[tokio::test(flavor = "current_thread")]
async fn strict_filter_rejects_pull_request_without_base_branch() {
let tmp = tempfile::TempDir::new().expect("tempdir");
let wf = tmp.path().join("ci.yml");
std::fs::write(
&wf,
"name: ci\n\
on:\n pull_request:\n branches:\n - main\n\
jobs:\n build:\n runs-on: ubuntu-latest\n steps:\n - run: echo hi\n",
)
.expect("write workflow");
let event = "pull_request".to_string();
let changed: Vec<String> = vec!["src/main.rs".to_string()];
let req = PrefilterRequest {
workflow_path: &wf,
event: Some(&event),
diff: false,
changed_files: Some(&changed),
diff_base: None,
diff_head: None,
base_branch: None,
activity_type: None,
verbose: false,
strict_filter: true,
};
let err = run_trigger_prefilter(req)
.await
.expect_err("strict mode must reject pull_request without --base-branch");
assert!(
err.contains("--base-branch"),
"error must point the user at --base-branch, got: {}",
err
);
assert!(
err.contains("pull_request"),
"error must name the offending event, got: {}",
err
);
}
#[test]
fn validate_event_requires_base_branch_is_noop_for_non_pr_events() {
for event in ["push", "workflow_dispatch", "schedule", "release"] {
assert!(
validate_event_requires_base_branch(event, true).is_ok(),
"strict mode must not reject non-PR event `{}`",
event
);
assert!(
validate_event_requires_base_branch(event, false).is_ok(),
"non-strict mode must not reject non-PR event `{}`",
event
);
}
}
#[test]
fn validate_event_requires_base_branch_rejects_pull_request_target_under_strict() {
let err = validate_event_requires_base_branch("pull_request_target", true)
.expect_err("strict mode must reject pull_request_target without --base-branch");
assert!(
err.contains("pull_request_target"),
"error must name the pull_request_target event explicitly, got: {}",
err
);
assert!(
err.contains("--base-branch"),
"error must point the user at --base-branch, got: {}",
err
);
}
#[test]
fn validate_event_requires_base_branch_wording_is_host_neutral() {
let err = validate_event_requires_base_branch("pull_request", true)
.expect_err("strict mode must reject pull_request without --base-branch");
assert!(
!err.to_lowercase().contains("simulating"),
"error text must not use the host-specific verb `simulating`, got: {}",
err
);
assert!(
!err.to_lowercase().contains("watching"),
"error text must not use the host-specific verb `watching`, got: {}",
err
);
assert!(validate_event_requires_base_branch("pull_request", false).is_ok());
}
}