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