use bpaf::{Parser, construct, long, positional};
use crate::cli::{GitFilter, OutputMode, Scope, TaskRef};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RunArgs {
pub targets: Vec<TaskRef>,
pub scope: Scope,
pub git_filter: Option<GitFilter>,
pub dry_run: bool,
pub output_mode: Option<OutputMode>,
}
pub(super) fn run_command() -> impl Parser<RunArgs> {
let all = long("all")
.help("Run every task in the bearing scope (EXEC-024)")
.switch();
let workspace = long("workspace")
.help("Run every task in every project, regardless of cwd (EXEC-025)")
.switch();
let dry_run = long("dry-run")
.help("Enumerate the target set without spawning any task (EXEC-030)")
.switch();
let git_filter = git_filter_parser();
let output_mode = long("output-mode")
.help("Output presentation mode (live | buffered)")
.argument::<String>("MODE")
.parse(|s| match s.as_ref() {
"live" => Ok(OutputMode::Live),
"buffered" => Ok(OutputMode::Buffered),
other => Err(format!(
"unknown output mode `{other}` (expected `live` or `buffered`)"
)),
})
.optional();
let targets = positional::<String>("TASK")
.help("Task reference (e.g. `proj:task`, `^:task`)")
.parse(|s| TaskRef::parse(&s).map_err(|e| e.to_string()))
.many();
construct!(all, workspace, git_filter, dry_run, output_mode, targets)
.parse(
|(all, workspace, git_filter, dry_run, output_mode, targets)| {
let has_positionals = !targets.is_empty();
let has_git_filter = git_filter.is_some();
let scope = Scope::from_flags(all, workspace)
.ok_or_else(|| "`--all` and `--workspace` are mutually exclusive".to_owned())?;
match (scope, has_git_filter, has_positionals) {
(Scope::All, _, true) => {
return Err(
"`--all` cannot be combined with positional task references".to_owned()
);
}
(Scope::Workspace, _, true) => {
return Err(
"`--workspace` cannot be combined with positional task references"
.to_owned(),
);
}
(_, true, true) => {
return Err(
"git-changeset filters cannot be combined with positional task references"
.to_owned(),
);
}
_ => {}
}
Ok(RunArgs {
targets,
scope,
git_filter,
dry_run,
output_mode,
})
},
)
}
fn git_filter_parser() -> impl Parser<Option<GitFilter>> {
let changed = long("git-changed")
.help("Restrict targets to tasks affected by working-tree changes vs HEAD (EXEC-027)")
.switch();
let staged = long("git-staged")
.help("Restrict targets to tasks affected by staged changes vs HEAD (EXEC-028)")
.switch();
let changed_since = long("git-changed-since")
.help("Restrict targets to tasks affected by changes since <REV> (EXEC-029)")
.argument::<String>("REV")
.optional();
construct!(changed, staged, changed_since).parse(|(changed, staged, changed_since)| {
match (changed, staged, changed_since) {
(false, false, None) => Ok(None),
(true, false, None) => Ok(Some(GitFilter::Changed)),
(false, true, None) => Ok(Some(GitFilter::Staged)),
(false, false, Some(rev)) => Ok(Some(GitFilter::ChangedSince(rev))),
_ => Err("git-changeset filters are mutually exclusive".to_owned()),
}
})
}
#[cfg(test)]
mod tests {
use super::RunArgs;
use crate::cli::{Cmd, GitFilter, OutputMode, Scope, TaskRef, parse};
fn bare_run_args() -> RunArgs {
RunArgs {
targets: Vec::new(),
scope: Scope::Default,
git_filter: None,
dry_run: false,
output_mode: None,
}
}
fn task_ref(s: &str) -> TaskRef {
TaskRef::parse(s).unwrap()
}
#[test]
fn run_with_no_targets_parses() {
let parsed = parse(&["run"]).unwrap();
assert_eq!(parsed, Cmd::Run(bare_run_args()));
}
#[test]
fn run_with_one_target_parses() {
let parsed = parse(&["run", "lib:build"]).unwrap();
assert_eq!(
parsed,
Cmd::Run(RunArgs {
targets: vec![task_ref("lib:build")],
..bare_run_args()
}),
);
}
#[test]
fn run_with_multiple_targets_parses_in_order() {
let parsed = parse(&["run", "lib:build", "app:test", "^:lint"]).unwrap();
assert_eq!(
parsed,
Cmd::Run(RunArgs {
targets: vec![
task_ref("lib:build"),
task_ref("app:test"),
task_ref("^:lint"),
],
..bare_run_args()
}),
);
}
#[test]
fn run_with_output_mode_live() {
let parsed = parse(&["run", "--output-mode", "live", "lib:build"]).unwrap();
assert_eq!(
parsed,
Cmd::Run(RunArgs {
targets: vec![task_ref("lib:build")],
output_mode: Some(OutputMode::Live),
..bare_run_args()
}),
);
}
#[test]
fn run_with_output_mode_buffered() {
let parsed = parse(&["run", "--output-mode", "buffered", "lib:build"]).unwrap();
assert_eq!(
parsed,
Cmd::Run(RunArgs {
targets: vec![task_ref("lib:build")],
output_mode: Some(OutputMode::Buffered),
..bare_run_args()
}),
);
}
#[test]
fn run_with_all_flag_parses() {
let parsed = parse(&["run", "--all"]).unwrap();
assert_eq!(
parsed,
Cmd::Run(RunArgs {
scope: Scope::All,
..bare_run_args()
}),
);
}
#[test]
fn run_with_workspace_flag_parses() {
let parsed = parse(&["run", "--workspace"]).unwrap();
assert_eq!(
parsed,
Cmd::Run(RunArgs {
scope: Scope::Workspace,
..bare_run_args()
}),
);
}
#[test]
fn run_with_dry_run_flag_parses() {
let parsed = parse(&["run", "--dry-run"]).unwrap();
assert_eq!(
parsed,
Cmd::Run(RunArgs {
dry_run: true,
..bare_run_args()
}),
);
}
#[test]
fn run_with_dry_run_composes_with_positional() {
let parsed = parse(&["run", "--dry-run", "lib:build"]).unwrap();
assert_eq!(
parsed,
Cmd::Run(RunArgs {
targets: vec![task_ref("lib:build")],
dry_run: true,
..bare_run_args()
}),
);
}
#[test]
fn run_with_dry_run_composes_with_all() {
let parsed = parse(&["run", "--dry-run", "--all"]).unwrap();
assert_eq!(
parsed,
Cmd::Run(RunArgs {
scope: Scope::All,
dry_run: true,
..bare_run_args()
}),
);
}
#[test]
fn run_with_dry_run_composes_with_workspace() {
let parsed = parse(&["run", "--dry-run", "--workspace"]).unwrap();
assert_eq!(
parsed,
Cmd::Run(RunArgs {
scope: Scope::Workspace,
dry_run: true,
..bare_run_args()
}),
);
}
#[test]
fn run_all_plus_workspace_rejects() {
let err = parse(&["run", "--all", "--workspace"]).unwrap_err();
assert!(
err.contains("mutually exclusive"),
"diagnostic should name the mutex: {err}",
);
}
#[test]
fn run_all_plus_positional_rejects() {
let err = parse(&["run", "--all", "lib:build"]).unwrap_err();
assert!(
err.contains("`--all`") && err.contains("positional"),
"diagnostic should mention --all and positional refs: {err}",
);
}
#[test]
fn run_workspace_plus_positional_rejects() {
let err = parse(&["run", "--workspace", "lib:build"]).unwrap_err();
assert!(
err.contains("`--workspace`") && err.contains("positional"),
"diagnostic should mention --workspace and positional refs: {err}",
);
}
#[test]
fn run_with_git_changed_parses() {
let parsed = parse(&["run", "--git-changed"]).unwrap();
assert_eq!(
parsed,
Cmd::Run(RunArgs {
git_filter: Some(GitFilter::Changed),
..bare_run_args()
}),
);
}
#[test]
fn run_with_git_staged_parses() {
let parsed = parse(&["run", "--git-staged"]).unwrap();
assert_eq!(
parsed,
Cmd::Run(RunArgs {
git_filter: Some(GitFilter::Staged),
..bare_run_args()
}),
);
}
#[test]
fn run_with_git_changed_since_parses() {
let parsed = parse(&["run", "--git-changed-since", "main"]).unwrap();
assert_eq!(
parsed,
Cmd::Run(RunArgs {
git_filter: Some(GitFilter::ChangedSince("main".to_owned())),
..bare_run_args()
}),
);
}
#[test]
fn run_git_changed_plus_all_composes() {
let parsed = parse(&["run", "--git-changed", "--all"]).unwrap();
assert_eq!(
parsed,
Cmd::Run(RunArgs {
scope: Scope::All,
git_filter: Some(GitFilter::Changed),
..bare_run_args()
}),
);
}
#[test]
fn run_git_changed_plus_workspace_composes() {
let parsed = parse(&["run", "--git-changed", "--workspace"]).unwrap();
assert_eq!(
parsed,
Cmd::Run(RunArgs {
scope: Scope::Workspace,
git_filter: Some(GitFilter::Changed),
..bare_run_args()
}),
);
}
#[test]
fn run_git_changed_plus_staged_rejects() {
let err = parse(&["run", "--git-changed", "--git-staged"]).unwrap_err();
assert!(
err.contains("mutually exclusive"),
"diagnostic should name the intra-family mutex: {err}",
);
}
#[test]
fn run_git_changed_plus_changed_since_rejects() {
let err = parse(&["run", "--git-changed", "--git-changed-since", "main"]).unwrap_err();
assert!(
err.contains("mutually exclusive"),
"diagnostic should name the intra-family mutex: {err}",
);
}
#[test]
fn run_git_staged_plus_changed_since_rejects() {
let err = parse(&["run", "--git-staged", "--git-changed-since", "main"]).unwrap_err();
assert!(
err.contains("mutually exclusive"),
"diagnostic should name the intra-family mutex: {err}",
);
}
#[test]
fn run_git_changed_plus_positional_rejects() {
let err = parse(&["run", "--git-changed", "lib:build"]).unwrap_err();
assert!(
err.contains("git-changeset") && err.contains("positional"),
"diagnostic should mention the filter-vs-positional mutex: {err}",
);
}
#[test]
fn run_git_staged_plus_positional_rejects() {
let err = parse(&["run", "--git-staged", "lib:build"]).unwrap_err();
assert!(
err.contains("git-changeset") && err.contains("positional"),
"diagnostic should mention the filter-vs-positional mutex: {err}",
);
}
#[test]
fn run_git_changed_since_plus_positional_rejects() {
let err = parse(&["run", "--git-changed-since", "main", "lib:build"]).unwrap_err();
assert!(
err.contains("git-changeset") && err.contains("positional"),
"diagnostic should mention the filter-vs-positional mutex: {err}",
);
}
#[test]
fn run_with_unknown_output_mode_rejects() {
let err = parse(&["run", "--output-mode", "noisy", "lib:build"]).unwrap_err();
assert!(
err.contains("unknown output mode"),
"diagnostic should name the offending value: {err}",
);
}
#[test]
fn run_rejects_malformed_target_at_parse_time() {
let err = parse(&["run", "no-colon"]).unwrap_err();
assert!(
!err.is_empty(),
"malformed reference must produce a parse diagnostic: {err}",
);
}
}