Skip to main content

haz_cli/cli/
run.rs

1//! Parser for the `haz run` subcommand.
2//!
3//! Owns [`RunArgs`], the [`run_command`] parser builder, and the
4//! private [`git_filter_parser`] sub-parser that reduces the three
5//! `--git-*` flags to an `Option<GitFilter>` under the `EXEC-026`
6//! intra-family mutex.
7
8use bpaf::{Parser, construct, long, positional};
9
10use crate::cli::{GitFilter, OutputMode, Scope, TaskRef};
11
12/// Arguments parsed for the `haz run` subcommand.
13///
14/// The scope-mutex rules (`--all` vs `--workspace`, scope vs
15/// positional refs, git-filter vs positional refs) are enforced by
16/// the parser itself, so any [`RunArgs`] value produced by
17/// [`crate::cli::cli`] is already known to satisfy them.
18#[derive(Debug, Clone, PartialEq, Eq)]
19pub struct RunArgs {
20    /// Positional task references the user named on the command
21    /// line, each parsed and syntactically validated
22    /// (`REF-001`..`REF-008`) at parse time. Resolution against the
23    /// workspace happens after it loads.
24    pub targets: Vec<TaskRef>,
25    /// How broadly to widen the target set: the bearing project's
26    /// `default` task ([`Scope::Default`], `EXEC-023`), every task
27    /// in the bearing scope ([`Scope::All`], `EXEC-024`), or every
28    /// task in every project ([`Scope::Workspace`], `EXEC-025`).
29    /// The `--all` / `--workspace` mutex and the scope-vs-positional
30    /// mutex are reduced from the two switches and enforced at parse
31    /// time, so non-empty [`Self::targets`] only ever accompanies
32    /// [`Scope::Default`].
33    pub scope: Scope,
34    /// `--git-changed` / `--git-staged` / `--git-changed-since
35    /// <REV>`: which git changeset to restrict the candidate set
36    /// to (`EXEC-026`..`EXEC-029`). At most one filter; intra-
37    /// family mutex enforced at parse time. Mutex with non-empty
38    /// [`Self::targets`] also enforced at parse time. MAY combine
39    /// with a [`Scope::All`] or [`Scope::Workspace`] selection.
40    pub git_filter: Option<GitFilter>,
41    /// `--dry-run`: enumerate the target set without spawning any
42    /// task (`EXEC-030`). Composable with every other flag.
43    pub dry_run: bool,
44    /// `--output-mode` flag value, if the user provided one.
45    /// `None` means "fall back to the workspace's `output.mode`
46    /// setting" per `EXEC-016`.
47    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    // Named options before positionals: bpaf's parser-combinator
77    // matching follows construct! order. Putting the named flags
78    // first lets `--output-mode <value>` consume its argument
79    // before `targets.many()` becomes greedy on the same value.
80    construct!(all, workspace, git_filter, dry_run, output_mode, targets)
81        // EXEC-024 / EXEC-025 scope mutexes and the EXEC-026 git-
82        // filter / positional mutex. Caught at parse time so a
83        // downstream resolver can assume every received RunArgs is
84        // already in a well-formed shape (one Scope, and either a
85        // positional list OR a git filter OR neither, but never
86        // both).
87        .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
124/// Build the optional git-changeset filter parser.
125///
126/// Three independent sub-flags are gathered (`--git-changed`,
127/// `--git-staged`, `--git-changed-since <REV>`) and reduced to an
128/// `Option<GitFilter>`. The reduction is the EXEC-026 intra-family
129/// mutex: two or more flags are rejected at parse time, so callers
130/// never see an ambiguous filter value.
131fn 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        // EXEC-024 / EXEC-025 mutex.
310        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        // EXEC-024 mutex with positional targets.
320        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        // EXEC-025 mutex with positional targets.
330        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        // EXEC-026 explicitly permits filter + single scope flag.
376        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        // EXEC-026 intra-family mutex.
403        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        // EXEC-026 filter-vs-positional mutex.
431        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        // REF-006: a bare word with no `:` separator is not a valid
468        // reference. The CLI surface rejects it before any workspace
469        // load, rather than deferring to the resolver.
470        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}