haz-cli 0.2.0

Command-line task runner: tracks task DAGs across a workspace of projects, executes them with caching.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
//! Parser for the `haz run` subcommand.
//!
//! Owns [`RunArgs`], the [`run_command`] parser builder, and the
//! private [`git_filter_parser`] sub-parser that reduces the three
//! `--git-*` flags to an `Option<GitFilter>` under the `EXEC-026`
//! intra-family mutex.

use bpaf::{Parser, construct, long, positional};

use crate::cli::{GitFilter, OutputMode, Scope, TaskRef};

/// Arguments parsed for the `haz run` subcommand.
///
/// The scope-mutex rules (`--all` vs `--workspace`, scope vs
/// positional refs, git-filter vs positional refs) are enforced by
/// the parser itself, so any [`RunArgs`] value produced by
/// [`crate::cli::cli`] is already known to satisfy them.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RunArgs {
    /// Positional task references the user named on the command
    /// line, each parsed and syntactically validated
    /// (`REF-001`..`REF-008`) at parse time. Resolution against the
    /// workspace happens after it loads.
    pub targets: Vec<TaskRef>,
    /// How broadly to widen the target set: the bearing project's
    /// `default` task ([`Scope::Default`], `EXEC-023`), every task
    /// in the bearing scope ([`Scope::All`], `EXEC-024`), or every
    /// task in every project ([`Scope::Workspace`], `EXEC-025`).
    /// The `--all` / `--workspace` mutex and the scope-vs-positional
    /// mutex are reduced from the two switches and enforced at parse
    /// time, so non-empty [`Self::targets`] only ever accompanies
    /// [`Scope::Default`].
    pub scope: Scope,
    /// `--git-changed` / `--git-staged` / `--git-changed-since
    /// <REV>`: which git changeset to restrict the candidate set
    /// to (`EXEC-026`..`EXEC-029`). At most one filter; intra-
    /// family mutex enforced at parse time. Mutex with non-empty
    /// [`Self::targets`] also enforced at parse time. MAY combine
    /// with a [`Scope::All`] or [`Scope::Workspace`] selection.
    pub git_filter: Option<GitFilter>,
    /// `--dry-run`: enumerate the target set without spawning any
    /// task (`EXEC-030`). Composable with every other flag.
    pub dry_run: bool,
    /// `--output-mode` flag value, if the user provided one.
    /// `None` means "fall back to the workspace's `output.mode`
    /// setting" per `EXEC-016`.
    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();
    // Named options before positionals: bpaf's parser-combinator
    // matching follows construct! order. Putting the named flags
    // first lets `--output-mode <value>` consume its argument
    // before `targets.many()` becomes greedy on the same value.
    construct!(all, workspace, git_filter, dry_run, output_mode, targets)
        // EXEC-024 / EXEC-025 scope mutexes and the EXEC-026 git-
        // filter / positional mutex. Caught at parse time so a
        // downstream resolver can assume every received RunArgs is
        // already in a well-formed shape (one Scope, and either a
        // positional list OR a git filter OR neither, but never
        // both).
        .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,
                })
            },
        )
}

/// Build the optional git-changeset filter parser.
///
/// Three independent sub-flags are gathered (`--git-changed`,
/// `--git-staged`, `--git-changed-since <REV>`) and reduced to an
/// `Option<GitFilter>`. The reduction is the EXEC-026 intra-family
/// mutex: two or more flags are rejected at parse time, so callers
/// never see an ambiguous filter value.
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() {
        // EXEC-024 / EXEC-025 mutex.
        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() {
        // EXEC-024 mutex with positional targets.
        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() {
        // EXEC-025 mutex with positional targets.
        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() {
        // EXEC-026 explicitly permits filter + single scope flag.
        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() {
        // EXEC-026 intra-family mutex.
        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() {
        // EXEC-026 filter-vs-positional mutex.
        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() {
        // REF-006: a bare word with no `:` separator is not a valid
        // reference. The CLI surface rejects it before any workspace
        // load, rather than deferring to the resolver.
        let err = parse(&["run", "no-colon"]).unwrap_err();
        assert!(
            !err.is_empty(),
            "malformed reference must produce a parse diagnostic: {err}",
        );
    }
}