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