1use 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
34pub 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
54fn count_validated_tasks(
56 args: &CheckArgs,
57 bearing: Option<&ProjectName>,
58 workspace: &Workspace,
59) -> usize {
60 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
81fn 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 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 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 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 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 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 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 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 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 #[test]
280 fn aux_009_count_default_with_bearing_picks_bearing_project() {
281 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 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 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}