1use std::collections::{BTreeMap, BTreeSet};
30use std::env as process_env;
31use std::io::{self, Write};
32use std::path::Path;
33
34use haz_cache::cache::Cache;
35use haz_cache::lookup::CacheLookupStatus;
36use haz_dag::edge::EdgeKind;
37use haz_dag::graph::TaskGraph;
38use haz_dag::producer::producers_of_path;
39use haz_domain::action::{ShellType, TaskAction};
40use haz_domain::env::EnvVarName;
41use haz_domain::mutex::{Mutex, MutexMode, MutexScope};
42use haz_domain::resolution::resolve_task_ref;
43use haz_domain::settings::cache::HashAlgo;
44use haz_domain::task::Task;
45use haz_domain::task_id::TaskId;
46use haz_domain::task_ref::TaskRef;
47use haz_domain::workspace::Workspace;
48use haz_exec::cache_key::{PredecessorStreamHashes, build_cache_key, resolve_input_files};
49use haz_vfs::StdFilesystem;
50use miette::{IntoDiagnostic, Result, WrapErr, miette};
51
52use haz_discovery::bearing::bearing_project;
53
54use crate::cli::WhyArgs;
55use crate::workspace_load::load_workspace;
56
57const HASH_ALGO: HashAlgo = HashAlgo::Blake3;
62
63pub fn execute(args: &WhyArgs, cwd: &Path) -> Result<i32> {
75 let (workspace, graph) = load_workspace(cwd)?;
76 let target_id = resolve_target(&args.target, cwd, &workspace)?;
77 let project = workspace
78 .projects
79 .get(&target_id.project)
80 .ok_or_else(|| miette!("internal error: resolved task `{target_id}` not in workspace"))?;
81 let task = project
82 .tasks
83 .get(&target_id.task)
84 .ok_or_else(|| miette!("internal error: resolved task `{target_id}` not in workspace"))?;
85
86 let cache = Cache::new(StdFilesystem::new(), workspace.root.as_path(), HASH_ALGO);
87 let host_env = host_env_map();
88
89 let mut stream_memo: BTreeMap<TaskId, PredecessorStreamHashes> = BTreeMap::new();
90 let direct_pred_streams = ensure_direct_predecessor_streams(
91 &cache,
92 &workspace,
93 &graph,
94 &target_id,
95 &host_env,
96 &mut stream_memo,
97 )?;
98
99 let target_key = build_cache_key(
100 cache.fs(),
101 &workspace,
102 &graph,
103 &target_id,
104 &host_env,
105 &direct_pred_streams,
106 HASH_ALGO,
107 )
108 .into_diagnostic()
109 .wrap_err("failed to derive cache key for target")?;
110
111 let target_lookup = cache
112 .lookup_status(&target_key)
113 .into_diagnostic()
114 .wrap_err("cache lookup for target failed")?;
115
116 let input_files = resolve_input_files(cache.fs(), &workspace, project, &task.inputs, HASH_ALGO)
117 .into_diagnostic()
118 .wrap_err("failed to enumerate inputs")?;
119
120 let mut inputs: Vec<InputLine> = input_files
121 .iter()
122 .map(|f| InputLine {
123 path: f.workspace_absolute_path.clone(),
124 producers: producers_of_path(&workspace, &f.workspace_absolute_path),
125 })
126 .collect();
127 inputs.sort_by(|a, b| a.path.cmp(&b.path));
128
129 let hard_preds = predecessors_of(&graph, &target_id, EdgeKind::Hard);
130 let soft_preds = predecessors_of(&graph, &target_id, EdgeKind::Soft);
131
132 let report = WhyReport {
133 task_id: &target_id,
134 task,
135 inputs,
136 hard_predecessors: hard_preds,
137 soft_predecessors: soft_preds,
138 cache_key_hex: target_key.to_hex(),
139 cache_lookup: lookup_label(&target_lookup),
140 };
141
142 let mut out = io::stdout().lock();
143 print_why_report(&report, &mut out)
144 .into_diagnostic()
145 .wrap_err("failed to write why report")?;
146 Ok(0)
147}
148
149fn resolve_target(target: &str, cwd: &Path, workspace: &Workspace) -> Result<TaskId> {
156 let reference =
157 TaskRef::parse(target).map_err(|e| miette!("invalid task reference `{target}`: {e}"))?;
158 let bearing_name = bearing_project(cwd, workspace).ok_or_else(|| {
159 miette!(
160 "haz why must be invoked from within a project root (cwd is not under any of the workspace's projects)"
161 )
162 })?;
163 let bearing = workspace.projects.get(bearing_name).ok_or_else(|| {
164 miette!("internal error: bearing project `{bearing_name}` not in workspace")
165 })?;
166 let resolved = resolve_task_ref(&reference, bearing, workspace)
167 .map_err(|e| miette!("could not resolve `{target}`: {e}"))?;
168 if resolved.len() > 1 {
169 let listing: Vec<String> = resolved.iter().map(|id| format!("{id}")).collect();
170 return Err(miette!(
171 "reference `{target}` resolves to {} tasks; use a more specific form (matched: {})",
172 resolved.len(),
173 listing.join(", "),
174 ));
175 }
176 Ok(resolved
177 .into_iter()
178 .next()
179 .expect("non-empty per resolve_task_ref"))
180}
181
182fn host_env_map() -> BTreeMap<EnvVarName, String> {
189 process_env::vars()
190 .filter_map(|(k, v)| EnvVarName::try_new(&k).ok().map(|n| (n, v)))
191 .collect()
192}
193
194fn predecessors_of(graph: &TaskGraph, target: &TaskId, kind: EdgeKind) -> BTreeSet<TaskId> {
197 graph
198 .edges
199 .iter()
200 .filter(|e| e.kind == kind && &e.to == target)
201 .map(|e| e.from.clone())
202 .collect()
203}
204
205fn ensure_direct_predecessor_streams(
210 cache: &Cache<StdFilesystem>,
211 workspace: &Workspace,
212 graph: &TaskGraph,
213 target: &TaskId,
214 host_env: &BTreeMap<EnvVarName, String>,
215 memo: &mut BTreeMap<TaskId, PredecessorStreamHashes>,
216) -> Result<BTreeMap<TaskId, PredecessorStreamHashes>> {
217 let direct = predecessors_of(graph, target, EdgeKind::Hard);
218 for pred in &direct {
219 ensure_streams_for(cache, workspace, graph, pred, host_env, memo)?;
220 }
221 Ok(direct
222 .into_iter()
223 .map(|p| {
224 let streams = *memo.get(&p).expect("ensured above");
225 (p, streams)
226 })
227 .collect())
228}
229
230fn ensure_streams_for(
233 cache: &Cache<StdFilesystem>,
234 workspace: &Workspace,
235 graph: &TaskGraph,
236 task_id: &TaskId,
237 host_env: &BTreeMap<EnvVarName, String>,
238 memo: &mut BTreeMap<TaskId, PredecessorStreamHashes>,
239) -> Result<()> {
240 if memo.contains_key(task_id) {
241 return Ok(());
242 }
243 let hard_preds = predecessors_of(graph, task_id, EdgeKind::Hard);
244 for pred in &hard_preds {
245 ensure_streams_for(cache, workspace, graph, pred, host_env, memo)?;
246 }
247 let pred_streams: BTreeMap<TaskId, PredecessorStreamHashes> = hard_preds
248 .iter()
249 .map(|p| (p.clone(), *memo.get(p).expect("ensured above")))
250 .collect();
251
252 let key = build_cache_key(
253 cache.fs(),
254 workspace,
255 graph,
256 task_id,
257 host_env,
258 &pred_streams,
259 HASH_ALGO,
260 )
261 .into_diagnostic()
262 .wrap_err_with(|| format!("failed to derive cache key for predecessor `{task_id}`"))?;
263
264 let status = cache
265 .lookup_status(&key)
266 .into_diagnostic()
267 .wrap_err_with(|| format!("cache lookup for predecessor `{task_id}` failed"))?;
268
269 match status {
270 CacheLookupStatus::Hit(manifest) => {
271 memo.insert(
272 task_id.clone(),
273 PredecessorStreamHashes {
274 stdout_hash: manifest.stdout_hash,
275 stderr_hash: manifest.stderr_hash,
276 },
277 );
278 Ok(())
279 }
280 CacheLookupStatus::MissNoEntry
281 | CacheLookupStatus::MissSchemaMismatch
282 | CacheLookupStatus::MissCorruptEntry => Err(miette!(
283 "haz why cannot derive the target's cache key: predecessor `{task_id}` has no cache hit (status: {}); run it first (e.g. `haz run {task_id}`)",
284 lookup_label(&status),
285 )),
286 }
287}
288
289struct InputLine {
294 path: String,
295 producers: BTreeSet<TaskId>,
296}
297
298struct WhyReport<'a> {
301 task_id: &'a TaskId,
302 task: &'a Task,
303 inputs: Vec<InputLine>,
304 hard_predecessors: BTreeSet<TaskId>,
305 soft_predecessors: BTreeSet<TaskId>,
306 cache_key_hex: String,
307 cache_lookup: &'static str,
308}
309
310fn lookup_label(status: &CacheLookupStatus) -> &'static str {
311 match status {
312 CacheLookupStatus::Hit(_) => "hit",
313 CacheLookupStatus::MissNoEntry => "miss: no entry",
314 CacheLookupStatus::MissSchemaMismatch => "miss: schema mismatch",
315 CacheLookupStatus::MissCorruptEntry => "miss: corrupt entry",
316 }
317}
318
319fn print_why_report(report: &WhyReport, out: &mut impl Write) -> io::Result<()> {
323 writeln!(out, "{}", report.task_id)?;
324 write_action(out, &report.task.action)?;
325 write_inputs(out, &report.inputs)?;
326 write_outputs(out, report.task)?;
327 write_predecessors(out, "deps", &report.hard_predecessors)?;
328 write_predecessors(out, "weakDeps", &report.soft_predecessors)?;
329 if let Some(m) = &report.task.mutex {
330 write_mutex(out, m)?;
331 }
332 write_env_from_host(out, report.task)?;
333 write_env_override(out, report.task)?;
334 writeln!(out, "cache_key: {}", report.cache_key_hex)?;
335 writeln!(out, "cache_lookup: {}", report.cache_lookup)?;
336 Ok(())
337}
338
339fn write_action(out: &mut impl Write, action: &TaskAction) -> io::Result<()> {
340 match action {
341 TaskAction::Command(argv) => {
342 let pieces: Vec<String> = argv.iter().map(|a| format!("{a:?}")).collect();
343 writeln!(out, "command: [{}]", pieces.join(", "))
344 }
345 TaskAction::Shell { shell, script } => {
346 writeln!(out, "shell ({}): {script}", shell_label(shell))
347 }
348 }
349}
350
351fn shell_label(shell: &ShellType) -> String {
352 match shell {
353 ShellType::Sh => "sh".to_owned(),
354 ShellType::Bash => "bash".to_owned(),
355 ShellType::Other(name) => name.as_ref().to_owned(),
356 }
357}
358
359fn write_inputs(out: &mut impl Write, inputs: &[InputLine]) -> io::Result<()> {
360 if inputs.is_empty() {
361 return Ok(());
362 }
363 writeln!(out, "inputs:")?;
364 for line in inputs {
365 match line.producers.len() {
366 0 => writeln!(out, " {}", line.path)?,
367 1 => {
368 let producer = line.producers.iter().next().expect("len == 1");
369 writeln!(out, " {} (produced by {producer})", line.path)?;
370 }
371 _ => writeln!(out, " {} (producer: undetermined)", line.path)?,
372 }
373 }
374 Ok(())
375}
376
377fn write_outputs(out: &mut impl Write, task: &Task) -> io::Result<()> {
378 if task.outputs.is_empty() {
379 return Ok(());
380 }
381 writeln!(out, "outputs:")?;
382 for o in &task.outputs {
383 writeln!(out, " {o}")?;
384 }
385 Ok(())
386}
387
388fn write_predecessors(
389 out: &mut impl Write,
390 label: &str,
391 preds: &BTreeSet<TaskId>,
392) -> io::Result<()> {
393 if preds.is_empty() {
394 return Ok(());
395 }
396 writeln!(out, "{label}:")?;
397 for id in preds {
398 writeln!(out, " {id}")?;
399 }
400 Ok(())
401}
402
403fn write_mutex(out: &mut impl Write, mutex: &Mutex) -> io::Result<()> {
404 writeln!(
405 out,
406 "mutex: scope={}, name={}, mode={}",
407 scope_label(mutex.scope),
408 mutex.name,
409 mode_label(mutex.mode),
410 )
411}
412
413fn scope_label(s: MutexScope) -> &'static str {
414 match s {
415 MutexScope::Workspace => "workspace",
416 MutexScope::Project => "project",
417 }
418}
419
420fn mode_label(m: MutexMode) -> &'static str {
421 match m {
422 MutexMode::Exclusive => "exclusive",
423 MutexMode::Shared => "shared",
424 }
425}
426
427fn write_env_from_host(out: &mut impl Write, task: &Task) -> io::Result<()> {
428 if task.env.from_host.is_empty() {
429 return Ok(());
430 }
431 writeln!(out, "env.from_host:")?;
432 for name in &task.env.from_host {
433 writeln!(out, " {name}")?;
434 }
435 Ok(())
436}
437
438fn write_env_override(out: &mut impl Write, task: &Task) -> io::Result<()> {
439 if task.env.overrides.is_empty() {
440 return Ok(());
441 }
442 writeln!(out, "env.override:")?;
443 for (name, value) in &task.env.overrides {
444 writeln!(out, " {name}={value}")?;
445 }
446 Ok(())
447}
448
449#[cfg(test)]
450mod tests {
451 use super::{
452 InputLine, WhyReport, execute, lookup_label, print_why_report, scope_label, shell_label,
453 };
454 use crate::cli::WhyArgs;
455 use haz_cache::lookup::CacheLookupStatus;
456 use haz_cache::manifest::{HashFunctionLabel, Manifest};
457 use haz_domain::action::{ShellType, TaskAction};
458 use haz_domain::env::{EnvSettings, EnvVarName};
459 use haz_domain::mutex::{Mutex, MutexMode, MutexScope};
460 use haz_domain::name::{MutexName, ProjectName, TaskName};
461 use haz_domain::path::OutputSpec;
462 use haz_domain::task::Task;
463 use haz_domain::task_id::TaskId;
464 use nonempty::NonEmpty;
465 use std::collections::{BTreeMap, BTreeSet};
466 use std::fs;
467 use std::path::Path;
468 use std::str::FromStr;
469
470 fn pname(s: &str) -> ProjectName {
471 ProjectName::from_str(s).unwrap()
472 }
473 fn tname(s: &str) -> TaskName {
474 TaskName::from_str(s).unwrap()
475 }
476 fn id(project: &str, task: &str) -> TaskId {
477 TaskId {
478 project: pname(project),
479 task: tname(task),
480 }
481 }
482
483 fn cmd_task() -> Task {
484 Task {
485 name: tname("build"),
486 action: TaskAction::Command(
487 NonEmpty::from_vec(vec!["cargo".into(), "build".into()]).unwrap(),
488 ),
489 inputs: vec![],
490 outputs: vec![],
491 deps: vec![],
492 weak_deps: vec![],
493 mutex: None,
494 env: EnvSettings::default(),
495 }
496 }
497
498 fn shell_task() -> Task {
499 Task {
500 name: tname("hello"),
501 action: TaskAction::Shell {
502 shell: ShellType::Sh,
503 script: "echo hello".to_owned(),
504 },
505 inputs: vec![],
506 outputs: vec![],
507 deps: vec![],
508 weak_deps: vec![],
509 mutex: None,
510 env: EnvSettings::default(),
511 }
512 }
513
514 fn render(report: &WhyReport) -> String {
515 let mut out = Vec::<u8>::new();
516 print_why_report(report, &mut out).unwrap();
517 String::from_utf8(out).unwrap()
518 }
519
520 fn base_report<'a>(task_id: &'a TaskId, task: &'a Task) -> WhyReport<'a> {
521 WhyReport {
522 task_id,
523 task,
524 inputs: vec![],
525 hard_predecessors: BTreeSet::new(),
526 soft_predecessors: BTreeSet::new(),
527 cache_key_hex: "abcd".to_owned(),
528 cache_lookup: "hit",
529 }
530 }
531
532 #[test]
533 fn print_why_report_renders_resolved_identity_first_line() {
534 let target = id("lib", "build");
535 let task = cmd_task();
536 let report = base_report(&target, &task);
537 let rendered = render(&report);
538 assert!(
539 rendered.starts_with("lib:build\n"),
540 "first line must be the task identity: {rendered}",
541 );
542 }
543
544 #[test]
545 fn print_why_report_renders_command_action_arglist() {
546 let target = id("lib", "build");
547 let task = cmd_task();
548 let report = base_report(&target, &task);
549 let rendered = render(&report);
550 assert!(
551 rendered.contains("command: [\"cargo\", \"build\"]"),
552 "command line missing: {rendered}",
553 );
554 }
555
556 #[test]
557 fn print_why_report_renders_shell_action_with_shell_type() {
558 let target = id("lib", "hello");
559 let task = shell_task();
560 let report = base_report(&target, &task);
561 let rendered = render(&report);
562 assert!(
563 rendered.contains("shell (sh): echo hello"),
564 "shell line missing: {rendered}",
565 );
566 }
567
568 #[test]
569 fn print_why_report_omits_optional_sections_when_empty() {
570 let target = id("lib", "build");
571 let task = cmd_task();
572 let report = base_report(&target, &task);
573 let rendered = render(&report);
574 for header in [
576 "inputs:",
577 "outputs:",
578 "deps:",
579 "weakDeps:",
580 "mutex:",
581 "env.from_host:",
582 "env.override:",
583 ] {
584 assert!(
585 !rendered.contains(header),
586 "section `{header}` must be omitted when empty: {rendered}",
587 );
588 }
589 }
590
591 #[test]
592 fn print_why_report_lists_inputs_without_annotation_when_zero_producers() {
593 let target = id("lib", "build");
594 let task = cmd_task();
595 let mut report = base_report(&target, &task);
596 report.inputs = vec![InputLine {
597 path: "/lib/src/main.rs".to_owned(),
598 producers: BTreeSet::new(),
599 }];
600 let rendered = render(&report);
601 assert!(
602 rendered.contains("inputs:\n /lib/src/main.rs\n"),
603 "zero-producer input must appear unannotated: {rendered}",
604 );
605 assert!(
606 !rendered.contains("produced by"),
607 "zero-producer input must not be annotated: {rendered}",
608 );
609 }
610
611 #[test]
612 fn print_why_report_annotates_single_producer_input() {
613 let target = id("lib", "build");
614 let task = cmd_task();
615 let mut report = base_report(&target, &task);
616 report.inputs = vec![InputLine {
617 path: "/lib/dist/api.js".to_owned(),
618 producers: BTreeSet::from([id("lib", "gen")]),
619 }];
620 let rendered = render(&report);
621 assert!(
622 rendered.contains("/lib/dist/api.js (produced by lib:gen)"),
623 "single-producer annotation missing: {rendered}",
624 );
625 }
626
627 #[test]
628 fn print_why_report_annotates_multi_producer_input_as_undetermined() {
629 let target = id("lib", "build");
630 let task = cmd_task();
631 let mut report = base_report(&target, &task);
632 report.inputs = vec![InputLine {
633 path: "/lib/dist/api.js".to_owned(),
634 producers: BTreeSet::from([id("lib", "gen_a"), id("lib", "gen_b")]),
635 }];
636 let rendered = render(&report);
637 assert!(
638 rendered.contains("/lib/dist/api.js (producer: undetermined)"),
639 "multi-producer annotation missing: {rendered}",
640 );
641 }
642
643 #[test]
644 fn print_why_report_lists_outputs_verbatim() {
645 let target = id("lib", "build");
646 let mut task = cmd_task();
647 task.outputs = vec![OutputSpec::parse("dist/main.js").unwrap()];
648 let report = base_report(&target, &task);
649 let rendered = render(&report);
650 assert!(
651 rendered.contains("outputs:\n dist/main.js\n"),
652 "outputs verbatim missing: {rendered}",
653 );
654 }
655
656 #[test]
657 fn print_why_report_lists_resolved_deps_in_canonical_order() {
658 let target = id("lib", "build");
659 let task = cmd_task();
660 let mut report = base_report(&target, &task);
661 report.hard_predecessors = BTreeSet::from([id("z_lib", "fmt"), id("a_lib", "gen")]);
662 let rendered = render(&report);
663 let a_pos = rendered.find("a_lib:gen").unwrap();
664 let z_pos = rendered.find("z_lib:fmt").unwrap();
665 assert!(
666 a_pos < z_pos,
667 "deps must appear in canonical (ProjectName, TaskName) order: {rendered}",
668 );
669 }
670
671 #[test]
672 fn print_why_report_lists_weak_deps_under_weak_deps_section() {
673 let target = id("lib", "build");
674 let task = cmd_task();
675 let mut report = base_report(&target, &task);
676 report.soft_predecessors = BTreeSet::from([id("lib", "fmt")]);
677 let rendered = render(&report);
678 assert!(
679 rendered.contains("weakDeps:\n lib:fmt\n"),
680 "weakDeps section missing: {rendered}",
681 );
682 }
683
684 #[test]
685 fn print_why_report_renders_mutex_section_when_present() {
686 let target = id("lib", "build");
687 let mut task = cmd_task();
688 task.mutex = Some(Mutex {
689 scope: MutexScope::Project,
690 name: MutexName::from_str("db").unwrap(),
691 mode: MutexMode::Shared,
692 });
693 let report = base_report(&target, &task);
694 let rendered = render(&report);
695 assert!(
696 rendered.contains("mutex: scope=project, name=db, mode=shared"),
697 "mutex line missing: {rendered}",
698 );
699 }
700
701 #[test]
702 fn print_why_report_renders_env_sections_when_non_empty() {
703 let target = id("lib", "build");
704 let mut task = cmd_task();
705 task.env = EnvSettings {
706 from_host: BTreeSet::from([
707 EnvVarName::try_new("PATH").unwrap(),
708 EnvVarName::try_new("HOME").unwrap(),
709 ]),
710 overrides: BTreeMap::from([(
711 EnvVarName::try_new("RUST_LOG").unwrap(),
712 "debug".to_owned(),
713 )]),
714 };
715 let report = base_report(&target, &task);
716 let rendered = render(&report);
717 assert!(
719 rendered.contains("env.from_host:\n HOME\n PATH\n"),
720 "env.from_host listing missing or unsorted: {rendered}",
721 );
722 assert!(
723 rendered.contains("env.override:\n RUST_LOG=debug\n"),
724 "env.override listing missing: {rendered}",
725 );
726 }
727
728 #[test]
729 fn print_why_report_emits_cache_key_and_cache_lookup_lines() {
730 let target = id("lib", "build");
731 let task = cmd_task();
732 let mut report = base_report(&target, &task);
733 report.cache_key_hex = "deadbeef".to_owned();
734 report.cache_lookup = "miss: corrupt entry";
735 let rendered = render(&report);
736 assert!(
737 rendered.contains("cache_key: deadbeef"),
738 "cache_key line missing: {rendered}",
739 );
740 assert!(
741 rendered.contains("cache_lookup: miss: corrupt entry"),
742 "cache_lookup line missing: {rendered}",
743 );
744 }
745
746 #[test]
747 fn lookup_label_maps_every_status_variant() {
748 let mut bytes = [0u8; 32];
751 bytes[0] = 0xAB;
752 let key = haz_cache::CacheKey::from_bytes(bytes);
753 let manifest = Manifest {
754 chapter_revision: 0,
755 hash_function: HashFunctionLabel::Blake3,
756 key,
757 outputs: vec![],
758 stdout_len: 0,
759 stderr_len: 0,
760 stdout_hash: [0u8; 32],
761 stderr_hash: [0u8; 32],
762 exit_status: 0,
763 created_at_unix: 0,
764 };
765 assert_eq!(lookup_label(&CacheLookupStatus::Hit(manifest)), "hit");
766 assert_eq!(
767 lookup_label(&CacheLookupStatus::MissNoEntry),
768 "miss: no entry",
769 );
770 assert_eq!(
771 lookup_label(&CacheLookupStatus::MissSchemaMismatch),
772 "miss: schema mismatch",
773 );
774 assert_eq!(
775 lookup_label(&CacheLookupStatus::MissCorruptEntry),
776 "miss: corrupt entry",
777 );
778 }
779
780 #[test]
781 fn shell_label_maps_each_shell_type() {
782 assert_eq!(shell_label(&ShellType::Sh), "sh");
783 assert_eq!(shell_label(&ShellType::Bash), "bash");
784 }
785
786 #[test]
787 fn scope_label_maps_each_scope() {
788 assert_eq!(scope_label(MutexScope::Workspace), "workspace");
789 assert_eq!(scope_label(MutexScope::Project), "project");
790 }
791
792 fn write_file(path: &Path, contents: &str) {
795 if let Some(parent) = path.parent() {
796 fs::create_dir_all(parent).unwrap();
797 }
798 fs::write(path, contents).unwrap();
799 }
800
801 fn write_workspace_with_single_task(root: &Path) {
802 write_file(&root.join(".haz/workspace.yml"), "projects:\n - /lib\n");
803 write_file(
804 &root.join("lib/haz.yml"),
805 "name: lib\ntasks:\n build:\n command: [\"true\"]\n",
806 );
807 }
808
809 fn cd_in_lib(root: &Path) -> std::path::PathBuf {
810 root.join("lib")
811 }
812
813 #[test]
814 fn execute_succeeds_on_isolated_task_with_no_predecessors() {
815 let tempdir = tempfile::TempDir::new().unwrap();
819 let root = tempdir.path();
820 write_workspace_with_single_task(root);
821 let args = WhyArgs {
822 target: "~:build".to_owned(),
823 };
824 let code = execute(&args, &cd_in_lib(root)).unwrap();
825 assert_eq!(code, 0);
826 }
827
828 #[test]
829 fn execute_rejects_unresolved_reference() {
830 let tempdir = tempfile::TempDir::new().unwrap();
831 let root = tempdir.path();
832 write_workspace_with_single_task(root);
833 let args = WhyArgs {
834 target: "~:nonexistent".to_owned(),
835 };
836 let err = execute(&args, &cd_in_lib(root)).unwrap_err();
837 let rendered = format!("{err}");
838 assert!(
839 rendered.contains("could not resolve"),
840 "missing resolution diagnostic: {rendered}",
841 );
842 }
843
844 #[test]
845 fn execute_rejects_multi_match_fan_out() {
846 let tempdir = tempfile::TempDir::new().unwrap();
847 let root = tempdir.path();
848 write_file(
849 &root.join(".haz/workspace.yml"),
850 "projects:\n - /lib_a\n - /lib_b\n",
851 );
852 write_file(
853 &root.join("lib_a/haz.yml"),
854 "name: lib_a\ntasks:\n build:\n command: [\"true\"]\n",
855 );
856 write_file(
857 &root.join("lib_b/haz.yml"),
858 "name: lib_b\ntasks:\n build:\n command: [\"true\"]\n",
859 );
860 let args = WhyArgs {
861 target: "^:build".to_owned(),
863 };
864 let err = execute(&args, &root.join("lib_a")).unwrap_err();
865 let rendered = format!("{err}");
866 assert!(
867 rendered.contains("resolves to") && rendered.contains("use a more specific form"),
868 "missing multi-match diagnostic: {rendered}",
869 );
870 }
871
872 #[test]
873 fn execute_rejects_invalid_reference_syntax() {
874 let tempdir = tempfile::TempDir::new().unwrap();
875 let root = tempdir.path();
876 write_workspace_with_single_task(root);
877 let args = WhyArgs {
878 target: String::new(),
879 };
880 let err = execute(&args, &cd_in_lib(root)).unwrap_err();
881 let rendered = format!("{err}");
882 assert!(
883 rendered.contains("invalid task reference"),
884 "missing parse diagnostic: {rendered}",
885 );
886 }
887}