1use bpaf::{Parser, construct, long, positional};
9
10use crate::cli::{GitFilter, OutputMode, Scope, TaskRef};
11
12#[derive(Debug, Clone, PartialEq, Eq)]
19pub struct RunArgs {
20 pub targets: Vec<TaskRef>,
25 pub scope: Scope,
34 pub git_filter: Option<GitFilter>,
41 pub dry_run: bool,
44 pub output_mode: Option<OutputMode>,
48}
49
50pub(super) fn run_command() -> impl Parser<RunArgs> {
51 let all = long("all")
52 .help("Run every task in the bearing scope (EXEC-024)")
53 .switch();
54 let workspace = long("workspace")
55 .help("Run every task in every project, regardless of cwd (EXEC-025)")
56 .switch();
57 let dry_run = long("dry-run")
58 .help("Enumerate the target set without spawning any task (EXEC-030)")
59 .switch();
60 let git_filter = git_filter_parser();
61 let output_mode = long("output-mode")
62 .help("Output presentation mode (live | buffered)")
63 .argument::<String>("MODE")
64 .parse(|s| match s.as_ref() {
65 "live" => Ok(OutputMode::Live),
66 "buffered" => Ok(OutputMode::Buffered),
67 other => Err(format!(
68 "unknown output mode `{other}` (expected `live` or `buffered`)"
69 )),
70 })
71 .optional();
72 let targets = positional::<String>("TASK")
73 .help("Task reference (e.g. `proj:task`, `^:task`)")
74 .parse(|s| TaskRef::parse(&s).map_err(|e| e.to_string()))
75 .many();
76 construct!(all, workspace, git_filter, dry_run, output_mode, targets)
81 .parse(
88 |(all, workspace, git_filter, dry_run, output_mode, targets)| {
89 let has_positionals = !targets.is_empty();
90 let has_git_filter = git_filter.is_some();
91 let scope = Scope::from_flags(all, workspace)
92 .ok_or_else(|| "`--all` and `--workspace` are mutually exclusive".to_owned())?;
93 match (scope, has_git_filter, has_positionals) {
94 (Scope::All, _, true) => {
95 return Err(
96 "`--all` cannot be combined with positional task references".to_owned()
97 );
98 }
99 (Scope::Workspace, _, true) => {
100 return Err(
101 "`--workspace` cannot be combined with positional task references"
102 .to_owned(),
103 );
104 }
105 (_, true, true) => {
106 return Err(
107 "git-changeset filters cannot be combined with positional task references"
108 .to_owned(),
109 );
110 }
111 _ => {}
112 }
113 Ok(RunArgs {
114 targets,
115 scope,
116 git_filter,
117 dry_run,
118 output_mode,
119 })
120 },
121 )
122}
123
124fn git_filter_parser() -> impl Parser<Option<GitFilter>> {
132 let changed = long("git-changed")
133 .help("Restrict targets to tasks affected by working-tree changes vs HEAD (EXEC-027)")
134 .switch();
135 let staged = long("git-staged")
136 .help("Restrict targets to tasks affected by staged changes vs HEAD (EXEC-028)")
137 .switch();
138 let changed_since = long("git-changed-since")
139 .help("Restrict targets to tasks affected by changes since <REV> (EXEC-029)")
140 .argument::<String>("REV")
141 .optional();
142 construct!(changed, staged, changed_since).parse(|(changed, staged, changed_since)| {
143 match (changed, staged, changed_since) {
144 (false, false, None) => Ok(None),
145 (true, false, None) => Ok(Some(GitFilter::Changed)),
146 (false, true, None) => Ok(Some(GitFilter::Staged)),
147 (false, false, Some(rev)) => Ok(Some(GitFilter::ChangedSince(rev))),
148 _ => Err("git-changeset filters are mutually exclusive".to_owned()),
149 }
150 })
151}
152
153#[cfg(test)]
154mod tests {
155 use super::RunArgs;
156 use crate::cli::{Cmd, GitFilter, OutputMode, Scope, TaskRef, parse};
157
158 fn bare_run_args() -> RunArgs {
159 RunArgs {
160 targets: Vec::new(),
161 scope: Scope::Default,
162 git_filter: None,
163 dry_run: false,
164 output_mode: None,
165 }
166 }
167
168 fn task_ref(s: &str) -> TaskRef {
169 TaskRef::parse(s).unwrap()
170 }
171
172 #[test]
173 fn run_with_no_targets_parses() {
174 let parsed = parse(&["run"]).unwrap();
175 assert_eq!(parsed, Cmd::Run(bare_run_args()));
176 }
177
178 #[test]
179 fn run_with_one_target_parses() {
180 let parsed = parse(&["run", "lib:build"]).unwrap();
181 assert_eq!(
182 parsed,
183 Cmd::Run(RunArgs {
184 targets: vec![task_ref("lib:build")],
185 ..bare_run_args()
186 }),
187 );
188 }
189
190 #[test]
191 fn run_with_multiple_targets_parses_in_order() {
192 let parsed = parse(&["run", "lib:build", "app:test", "^:lint"]).unwrap();
193 assert_eq!(
194 parsed,
195 Cmd::Run(RunArgs {
196 targets: vec![
197 task_ref("lib:build"),
198 task_ref("app:test"),
199 task_ref("^:lint"),
200 ],
201 ..bare_run_args()
202 }),
203 );
204 }
205
206 #[test]
207 fn run_with_output_mode_live() {
208 let parsed = parse(&["run", "--output-mode", "live", "lib:build"]).unwrap();
209 assert_eq!(
210 parsed,
211 Cmd::Run(RunArgs {
212 targets: vec![task_ref("lib:build")],
213 output_mode: Some(OutputMode::Live),
214 ..bare_run_args()
215 }),
216 );
217 }
218
219 #[test]
220 fn run_with_output_mode_buffered() {
221 let parsed = parse(&["run", "--output-mode", "buffered", "lib:build"]).unwrap();
222 assert_eq!(
223 parsed,
224 Cmd::Run(RunArgs {
225 targets: vec![task_ref("lib:build")],
226 output_mode: Some(OutputMode::Buffered),
227 ..bare_run_args()
228 }),
229 );
230 }
231
232 #[test]
233 fn run_with_all_flag_parses() {
234 let parsed = parse(&["run", "--all"]).unwrap();
235 assert_eq!(
236 parsed,
237 Cmd::Run(RunArgs {
238 scope: Scope::All,
239 ..bare_run_args()
240 }),
241 );
242 }
243
244 #[test]
245 fn run_with_workspace_flag_parses() {
246 let parsed = parse(&["run", "--workspace"]).unwrap();
247 assert_eq!(
248 parsed,
249 Cmd::Run(RunArgs {
250 scope: Scope::Workspace,
251 ..bare_run_args()
252 }),
253 );
254 }
255
256 #[test]
257 fn run_with_dry_run_flag_parses() {
258 let parsed = parse(&["run", "--dry-run"]).unwrap();
259 assert_eq!(
260 parsed,
261 Cmd::Run(RunArgs {
262 dry_run: true,
263 ..bare_run_args()
264 }),
265 );
266 }
267
268 #[test]
269 fn run_with_dry_run_composes_with_positional() {
270 let parsed = parse(&["run", "--dry-run", "lib:build"]).unwrap();
271 assert_eq!(
272 parsed,
273 Cmd::Run(RunArgs {
274 targets: vec![task_ref("lib:build")],
275 dry_run: true,
276 ..bare_run_args()
277 }),
278 );
279 }
280
281 #[test]
282 fn run_with_dry_run_composes_with_all() {
283 let parsed = parse(&["run", "--dry-run", "--all"]).unwrap();
284 assert_eq!(
285 parsed,
286 Cmd::Run(RunArgs {
287 scope: Scope::All,
288 dry_run: true,
289 ..bare_run_args()
290 }),
291 );
292 }
293
294 #[test]
295 fn run_with_dry_run_composes_with_workspace() {
296 let parsed = parse(&["run", "--dry-run", "--workspace"]).unwrap();
297 assert_eq!(
298 parsed,
299 Cmd::Run(RunArgs {
300 scope: Scope::Workspace,
301 dry_run: true,
302 ..bare_run_args()
303 }),
304 );
305 }
306
307 #[test]
308 fn run_all_plus_workspace_rejects() {
309 let err = parse(&["run", "--all", "--workspace"]).unwrap_err();
311 assert!(
312 err.contains("mutually exclusive"),
313 "diagnostic should name the mutex: {err}",
314 );
315 }
316
317 #[test]
318 fn run_all_plus_positional_rejects() {
319 let err = parse(&["run", "--all", "lib:build"]).unwrap_err();
321 assert!(
322 err.contains("`--all`") && err.contains("positional"),
323 "diagnostic should mention --all and positional refs: {err}",
324 );
325 }
326
327 #[test]
328 fn run_workspace_plus_positional_rejects() {
329 let err = parse(&["run", "--workspace", "lib:build"]).unwrap_err();
331 assert!(
332 err.contains("`--workspace`") && err.contains("positional"),
333 "diagnostic should mention --workspace and positional refs: {err}",
334 );
335 }
336
337 #[test]
338 fn run_with_git_changed_parses() {
339 let parsed = parse(&["run", "--git-changed"]).unwrap();
340 assert_eq!(
341 parsed,
342 Cmd::Run(RunArgs {
343 git_filter: Some(GitFilter::Changed),
344 ..bare_run_args()
345 }),
346 );
347 }
348
349 #[test]
350 fn run_with_git_staged_parses() {
351 let parsed = parse(&["run", "--git-staged"]).unwrap();
352 assert_eq!(
353 parsed,
354 Cmd::Run(RunArgs {
355 git_filter: Some(GitFilter::Staged),
356 ..bare_run_args()
357 }),
358 );
359 }
360
361 #[test]
362 fn run_with_git_changed_since_parses() {
363 let parsed = parse(&["run", "--git-changed-since", "main"]).unwrap();
364 assert_eq!(
365 parsed,
366 Cmd::Run(RunArgs {
367 git_filter: Some(GitFilter::ChangedSince("main".to_owned())),
368 ..bare_run_args()
369 }),
370 );
371 }
372
373 #[test]
374 fn run_git_changed_plus_all_composes() {
375 let parsed = parse(&["run", "--git-changed", "--all"]).unwrap();
377 assert_eq!(
378 parsed,
379 Cmd::Run(RunArgs {
380 scope: Scope::All,
381 git_filter: Some(GitFilter::Changed),
382 ..bare_run_args()
383 }),
384 );
385 }
386
387 #[test]
388 fn run_git_changed_plus_workspace_composes() {
389 let parsed = parse(&["run", "--git-changed", "--workspace"]).unwrap();
390 assert_eq!(
391 parsed,
392 Cmd::Run(RunArgs {
393 scope: Scope::Workspace,
394 git_filter: Some(GitFilter::Changed),
395 ..bare_run_args()
396 }),
397 );
398 }
399
400 #[test]
401 fn run_git_changed_plus_staged_rejects() {
402 let err = parse(&["run", "--git-changed", "--git-staged"]).unwrap_err();
404 assert!(
405 err.contains("mutually exclusive"),
406 "diagnostic should name the intra-family mutex: {err}",
407 );
408 }
409
410 #[test]
411 fn run_git_changed_plus_changed_since_rejects() {
412 let err = parse(&["run", "--git-changed", "--git-changed-since", "main"]).unwrap_err();
413 assert!(
414 err.contains("mutually exclusive"),
415 "diagnostic should name the intra-family mutex: {err}",
416 );
417 }
418
419 #[test]
420 fn run_git_staged_plus_changed_since_rejects() {
421 let err = parse(&["run", "--git-staged", "--git-changed-since", "main"]).unwrap_err();
422 assert!(
423 err.contains("mutually exclusive"),
424 "diagnostic should name the intra-family mutex: {err}",
425 );
426 }
427
428 #[test]
429 fn run_git_changed_plus_positional_rejects() {
430 let err = parse(&["run", "--git-changed", "lib:build"]).unwrap_err();
432 assert!(
433 err.contains("git-changeset") && err.contains("positional"),
434 "diagnostic should mention the filter-vs-positional mutex: {err}",
435 );
436 }
437
438 #[test]
439 fn run_git_staged_plus_positional_rejects() {
440 let err = parse(&["run", "--git-staged", "lib:build"]).unwrap_err();
441 assert!(
442 err.contains("git-changeset") && err.contains("positional"),
443 "diagnostic should mention the filter-vs-positional mutex: {err}",
444 );
445 }
446
447 #[test]
448 fn run_git_changed_since_plus_positional_rejects() {
449 let err = parse(&["run", "--git-changed-since", "main", "lib:build"]).unwrap_err();
450 assert!(
451 err.contains("git-changeset") && err.contains("positional"),
452 "diagnostic should mention the filter-vs-positional mutex: {err}",
453 );
454 }
455
456 #[test]
457 fn run_with_unknown_output_mode_rejects() {
458 let err = parse(&["run", "--output-mode", "noisy", "lib:build"]).unwrap_err();
459 assert!(
460 err.contains("unknown output mode"),
461 "diagnostic should name the offending value: {err}",
462 );
463 }
464
465 #[test]
466 fn run_rejects_malformed_target_at_parse_time() {
467 let err = parse(&["run", "no-colon"]).unwrap_err();
471 assert!(
472 !err.is_empty(),
473 "malformed reference must produce a parse diagnostic: {err}",
474 );
475 }
476}