1use 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
37pub 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
63fn 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
86fn 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 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 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 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 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 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 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 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 #[test]
275 fn aux_009_count_default_with_bearing_picks_bearing_project() {
276 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 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 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}