Skip to main content

haz_cli/commands/
check.rs

1//! `haz check` subcommand.
2//!
3//! Loads the discovered workspace through the full
4//! DISC / CFG / REF / DAG pipeline (`AUX-010`), accumulating every
5//! detected error via the per-phase accumulators (`LoadErrors`,
6//! `LoadOverlayErrors`, `BuildErrors`, ...). On success, emits a
7//! single line of the form `<N> task(s) validated.` to stdout
8//! (`AUX-011`) and returns exit code 0 (`AUX-012`). On failure,
9//! propagates the typed diagnostic up to the binary, which renders
10//! it to stderr and exits with the internal-or-configuration-error
11//! class.
12//!
13//! The candidate set whose size enters the count line is determined
14//! per `AUX-009`:
15//!
16//! - `--workspace`: every task in every project, regardless of cwd.
17//! - `--all` + bearing project: the bearing project's effective
18//!   task set.
19//! - `--all` + no bearing project: every task in every project.
20//! - No scope flag + bearing project: the bearing project's
21//!   effective task set.
22//! - No scope flag + no bearing project: every task in every
23//!   project.
24
25use std::io::{self, Write};
26
27use haz_domain::name::ProjectName;
28use haz_domain::workspace::Workspace;
29use miette::{IntoDiagnostic, Result, WrapErr};
30
31use crate::cli::{CheckArgs, Scope};
32use crate::context::Context;
33
34/// Execute the `haz check` subcommand against the supplied [`Context`].
35///
36/// Returns the process exit code that the binary should propagate
37/// (`AUX-012`).
38///
39/// # Errors
40///
41/// Returns a [`miette::Report`] on any failure of the underlying
42/// validation pipeline (`AUX-010`). The binary maps the report to
43/// the internal-or-configuration-error exit class
44/// ([`crate::EXIT_INTERNAL_OR_CONFIG_ERROR`]).
45pub fn execute(args: &CheckArgs, ctx: &Context) -> Result<i32> {
46    let count = count_validated_tasks(args, ctx.bearing(), ctx.workspace());
47    let mut out = io::stdout().lock();
48    print_validation_summary(count, &mut out)
49        .into_diagnostic()
50        .wrap_err("failed to write validation summary")?;
51    Ok(0)
52}
53
54/// Compute the size of the candidate task set per `AUX-009`.
55fn count_validated_tasks(
56    args: &CheckArgs,
57    bearing: Option<&ProjectName>,
58    workspace: &Workspace,
59) -> usize {
60    // AUX-009: `--workspace` counts every project regardless of
61    // cwd; `--all` and the no-flag default both count the bearing
62    // project when one applies, otherwise the whole workspace (so
63    // the two collapse to one arm here).
64    match args.scope {
65        Scope::Workspace => workspace_task_count(workspace),
66        Scope::All | Scope::Default => match bearing {
67            Some(name) => bearing_task_count(workspace, name),
68            None => workspace_task_count(workspace),
69        },
70    }
71}
72
73fn workspace_task_count(workspace: &Workspace) -> usize {
74    workspace.projects.values().map(|p| p.tasks.len()).sum()
75}
76
77fn bearing_task_count(workspace: &Workspace, name: &ProjectName) -> usize {
78    workspace.projects.get(name).map_or(0, |p| p.tasks.len())
79}
80
81/// Emit the `AUX-011` success line.
82///
83/// The literal `task(s)` form is taken verbatim from the spec
84/// (`AUX-011`): the count is the only field that varies.
85fn print_validation_summary(count: usize, out: &mut impl Write) -> io::Result<()> {
86    writeln!(out, "{count} task(s) validated.")
87}
88
89#[cfg(test)]
90mod tests {
91    use super::{count_validated_tasks, execute, print_validation_summary};
92    use crate::cli::{CheckArgs, Scope};
93    use crate::context::Context;
94    use miette::Result;
95    use std::fs;
96    use std::path::Path;
97    use std::str::FromStr;
98
99    fn write_file(path: &Path, contents: &str) {
100        if let Some(parent) = path.parent() {
101            fs::create_dir_all(parent).unwrap();
102        }
103        fs::write(path, contents).unwrap();
104    }
105
106    /// Run the full dispatch pipeline for `haz check`: open the
107    /// Context against `cwd` and execute, surfacing the
108    /// load-failure and execute-failure paths as a single `Result`.
109    fn run_check(args: &CheckArgs, cwd: &Path) -> Result<i32> {
110        let ctx = Context::open(cwd)?;
111        execute(args, &ctx)
112    }
113
114    fn explicit_two_project_workspace(root: &Path) {
115        write_file(
116            &root.join(".haz/workspace.yml"),
117            "projects:\n  - /lib_core\n  - /apps/web\n",
118        );
119        write_file(
120            &root.join("lib_core/haz.yml"),
121            "name: lib_core\ntasks:\n  build:\n    command: [\"true\"]\n",
122        );
123        write_file(
124            &root.join("apps/web/haz.yml"),
125            "name: web\ntasks:\n  bundle:\n    command: [\"true\"]\n    deps:\n      - lib_core:build\n",
126        );
127    }
128
129    #[test]
130    fn aux_010_check_runs_every_validation_step_against_clean_workspace() {
131        // A two-project workspace with a valid cross-project
132        // dependency exercises the full DISC / CFG / REF / DAG
133        // pipeline: discovery loads `workspace.yml` + both
134        // `haz.yml`s, reference resolution turns `lib_core:build`
135        // into a node, and the DAG builder accepts the resulting
136        // hard edge. A non-error return value proves every layer
137        // ran without short-circuiting.
138        let tempdir = tempfile::TempDir::new().unwrap();
139        let root = tempdir.path();
140        explicit_two_project_workspace(root);
141
142        let args = CheckArgs::default();
143        let code = run_check(&args, root).unwrap();
144        assert_eq!(code, 0);
145    }
146
147    #[test]
148    fn aux_010_check_accumulates_multiple_dag_errors_without_short_circuit() {
149        // Three independent DAG-layer errors in one workspace:
150        // 1. Cycle between lib_a:build and lib_b:build (DAG-014).
151        // 2. Unresolved reference in lib_c:build (DAG-011).
152        // 3. Duplicate literal output `dup.txt` declared by
153        //    lib_d:build and lib_e:build (DAG-015).
154        // `BuildErrors` MUST surface all three in one report.
155        let tempdir = tempfile::TempDir::new().unwrap();
156        let root = tempdir.path();
157        write_file(
158            &root.join(".haz/workspace.yml"),
159            "projects:\n  - /lib_a\n  - /lib_b\n  - /lib_c\n  - /lib_d\n  - /lib_e\n",
160        );
161        write_file(
162            &root.join("lib_a/haz.yml"),
163            "name: lib_a\ntasks:\n  build:\n    command: [\"true\"]\n    deps:\n      - lib_b:build\n",
164        );
165        write_file(
166            &root.join("lib_b/haz.yml"),
167            "name: lib_b\ntasks:\n  build:\n    command: [\"true\"]\n    deps:\n      - lib_a:build\n",
168        );
169        write_file(
170            &root.join("lib_c/haz.yml"),
171            "name: lib_c\ntasks:\n  build:\n    command: [\"true\"]\n    deps:\n      - lib_x:nonexistent\n",
172        );
173        write_file(
174            &root.join("lib_d/haz.yml"),
175            "name: lib_d\ntasks:\n  build:\n    command: [\"true\"]\n    outputs:\n      - dup.txt\n",
176        );
177        write_file(
178            &root.join("lib_e/haz.yml"),
179            "name: lib_e\ntasks:\n  build:\n    command: [\"true\"]\n    outputs:\n      - /lib_d/dup.txt\n",
180        );
181
182        let args = CheckArgs::default();
183        let err = run_check(&args, root).unwrap_err();
184        let rendered = format!("{err:?}");
185        assert!(
186            rendered.contains("DAG-014"),
187            "diagnostic should surface the cycle rule: {rendered}",
188        );
189        assert!(
190            rendered.contains("DAG-011") || rendered.contains("unresolved"),
191            "diagnostic should surface the unresolved reference: {rendered}",
192        );
193        assert!(
194            rendered.contains("DAG-015"),
195            "diagnostic should surface the duplicate literal output rule: {rendered}",
196        );
197    }
198
199    #[test]
200    fn aux_011_success_output_emits_count_line() {
201        // The literal `<N> task(s) validated.` form per AUX-011.
202        let mut out = Vec::<u8>::new();
203        print_validation_summary(3, &mut out).unwrap();
204        assert_eq!(String::from_utf8(out).unwrap(), "3 task(s) validated.\n");
205    }
206
207    #[test]
208    fn aux_011_failure_surfaces_every_dag_error_in_one_report() {
209        // Two DAG-layer errors share one workspace; `execute`'s
210        // returned `Report` must render BOTH (no truncation per
211        // AUX-011).
212        let tempdir = tempfile::TempDir::new().unwrap();
213        let root = tempdir.path();
214        write_file(
215            &root.join(".haz/workspace.yml"),
216            "projects:\n  - /lib_a\n  - /lib_b\n",
217        );
218        // DAG-015: duplicate literal output between two tasks.
219        write_file(
220            &root.join("lib_a/haz.yml"),
221            "name: lib_a\ntasks:\n  build:\n    command: [\"true\"]\n    outputs:\n      - dup.txt\n",
222        );
223        write_file(
224            &root.join("lib_b/haz.yml"),
225            "name: lib_b\ntasks:\n  build:\n    command: [\"true\"]\n    outputs:\n      - /lib_a/dup.txt\n    deps:\n      - lib_a:nonexistent\n",
226        );
227
228        let args = CheckArgs::default();
229        let err = run_check(&args, root).unwrap_err();
230        let rendered = format!("{err:?}");
231        assert!(
232            rendered.contains("DAG-015"),
233            "duplicate-output diagnostic missing: {rendered}",
234        );
235        assert!(
236            rendered.contains("DAG-011") || rendered.contains("unresolved"),
237            "unresolved-reference diagnostic missing: {rendered}",
238        );
239    }
240
241    #[test]
242    fn aux_012_check_returns_zero_on_clean_workspace() {
243        // Implicit-mode singleton; the simplest possible clean
244        // workspace.
245        let tempdir = tempfile::TempDir::new().unwrap();
246        let root = tempdir.path();
247        fs::create_dir_all(root.join(".git")).unwrap();
248        write_file(
249            &root.join("haz.yml"),
250            "name: only_proj\ntasks:\n  build:\n    command: [\"true\"]\n",
251        );
252
253        let args = CheckArgs::default();
254        let code = run_check(&args, root).unwrap();
255        assert_eq!(code, 0);
256    }
257
258    #[test]
259    fn aux_012_check_returns_err_on_validation_failure() {
260        // The numeric exit code mapping to
261        // `EXIT_INTERNAL_OR_CONFIG_ERROR` lives in the binary's
262        // `main`; this in-process test verifies only that
263        // `execute` surfaces the failure as `Err(_)` so the
264        // binary's mapping logic ever has a chance to run.
265        let tempdir = tempfile::TempDir::new().unwrap();
266        let root = tempdir.path();
267        write_file(&root.join(".haz/workspace.yml"), "projects:\n  - /lib\n");
268        write_file(
269            &root.join("lib/haz.yml"),
270            "name: lib\ntasks:\n  build:\n    command: [\"true\"]\n    deps:\n      - lib:nonexistent\n",
271        );
272
273        let args = CheckArgs::default();
274        assert!(run_check(&args, root).is_err());
275    }
276
277    // Scope-flag counting --------------------------------------
278
279    #[test]
280    fn aux_009_count_default_with_bearing_picks_bearing_project() {
281        // cwd inside lib_core: default mode validates only the
282        // bearing project (1 task), not the workspace's 2 tasks.
283        let tempdir = tempfile::TempDir::new().unwrap();
284        let root = tempdir.path();
285        explicit_two_project_workspace(root);
286        fs::create_dir_all(root.join("lib_core/src")).unwrap();
287
288        let (workspace, _graph) = crate::workspace_load::load_workspace(root).unwrap();
289        let bearing_name = haz_domain::name::ProjectName::from_str("lib_core").unwrap();
290        let bearing = Some(&bearing_name);
291
292        let args = CheckArgs::default();
293        let n = count_validated_tasks(&args, bearing, &workspace);
294        assert_eq!(n, 1, "bearing-project count");
295    }
296
297    #[test]
298    fn aux_009_count_workspace_flag_picks_every_project() {
299        // --workspace overrides the bearing context: both tasks
300        // counted, regardless of the cwd.
301        let tempdir = tempfile::TempDir::new().unwrap();
302        let root = tempdir.path();
303        explicit_two_project_workspace(root);
304
305        let (workspace, _graph) = crate::workspace_load::load_workspace(root).unwrap();
306        let bearing_name = haz_domain::name::ProjectName::from_str("lib_core").unwrap();
307        let bearing = Some(&bearing_name);
308
309        let args = CheckArgs {
310            scope: Scope::Workspace,
311        };
312        let n = count_validated_tasks(&args, bearing, &workspace);
313        assert_eq!(n, 2, "workspace-flag count");
314    }
315
316    #[test]
317    fn aux_009_count_default_without_bearing_picks_every_project() {
318        // cwd at the workspace root (no bearing): the default
319        // candidate set is the entire workspace.
320        let tempdir = tempfile::TempDir::new().unwrap();
321        let root = tempdir.path();
322        explicit_two_project_workspace(root);
323
324        let (workspace, _graph) = crate::workspace_load::load_workspace(root).unwrap();
325
326        let args = CheckArgs::default();
327        let n = count_validated_tasks(&args, None, &workspace);
328        assert_eq!(n, 2, "no-bearing default count");
329    }
330}