Skip to main content

haz_cli/commands/
why.rs

1//! `haz why <TASK>` per `AUX-013`..`AUX-016`.
2//!
3//! Read-only introspection of a single task. The executor:
4//!
5//! 1. Loads the workspace + validated graph
6//!    (`DISC-001..DISC-009`, `DAG-001..DAG-018`).
7//! 2. Resolves the positional task reference to exactly one node
8//!    `(P, A)` per `REF-001..REF-008`; rejects zero-match and
9//!    multi-match cases with diagnostics directing the user to a
10//!    more specific form.
11//! 3. Walks the transitive hard-predecessor closure, deriving
12//!    each predecessor's cache key and reading its `stdout_hash`
13//!    / `stderr_hash` from the cached manifest. If any
14//!    predecessor lacks a cache entry, derivation of the
15//!    target's key would be ambiguous; the executor surfaces a
16//!    typed diagnostic naming the missing predecessor.
17//! 4. Derives the target's cache key per `CACHE-001..CACHE-009`
18//!    and performs a read-only manifest lookup per `CACHE-014`,
19//!    discriminating the four `AUX-015` step 11 sub-states.
20//! 5. Enumerates materialised input paths (`AUX-015` step 3) and
21//!    annotates each with its producer set per `DAG-013` /
22//!    `AUX-015` step 3 ("produced by", "producer: undetermined",
23//!    or no annotation when zero producers).
24//! 6. Renders the `AUX-015` report. Optional sections (`inputs:`,
25//!    `outputs:`, `deps:`, `weakDeps:`, `mutex:`, `env.from_host:`,
26//!    `env.override:`) are omitted when the underlying field is
27//!    absent so headers never dangle.
28
29use 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
53/// Execute `haz why <TASK>` against `cwd`.
54///
55/// Returns `0` on a successful render. Reference-resolution
56/// failures, predecessor-cache-miss conditions, and filesystem
57/// failures during cache-key derivation or lookup surface as
58/// miette diagnostics.
59///
60/// # Errors
61///
62/// Returns a [`miette::Report`] for workspace-load, reference-
63/// resolution, cache-key-derivation, or cache-lookup failures.
64pub 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
140/// Resolve `reference` to exactly one [`TaskId`].
141///
142/// Resolution failures surface as diagnostics. Multi-match (a
143/// reference that resolves to more than one node) is rejected per
144/// `AUX-013` with a listing of the matched tasks so the user can
145/// pick a more specific form.
146fn 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
171/// Build a host-environment snapshot keyed by [`EnvVarName`].
172///
173/// Names that fail [`EnvVarName::try_new`] (the spec rejects
174/// names with `=` or null bytes; see `CACHE-008`) are dropped
175/// silently: the cache library cannot consume them anyway, and
176/// no spec rule names them invalid for the host side.
177fn 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
183/// Return the set of [`TaskId`]s that are predecessors of
184/// `target` in `graph` under the given `kind`.
185fn 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
194/// Build the `BTreeMap<TaskId, PredecessorStreamHashes>` for the
195/// target's direct hard predecessors. The recursive [`ensure_streams_for`]
196/// populates the `memo` so each predecessor's transitive closure
197/// is resolved before the consumer's key is derived.
198fn 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
219/// Populate `memo` with `task_id`'s stream hashes. Recurses for
220/// every transitive hard predecessor first.
221fn 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
278// ---- Report data + rendering ----
279
280/// One materialised input path with its statically-decidable
281/// producer set per `AUX-015` step 3.
282struct InputLine {
283    path: String,
284    producers: BTreeSet<TaskId>,
285}
286
287/// All the pieces `print_why_report` consumes. Built once by
288/// [`execute`] from the workspace + graph + cache lookup.
289struct 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
308/// Render an `AUX-015` report to `out`. Optional sections are
309/// omitted when the underlying field is absent so the header
310/// never dangles.
311fn 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    /// Mimic the dispatch sequence for `haz why`: open the Context
466    /// against `cwd`, then execute.
467    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        // No empty section headers should appear.
577        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        // BTreeSet sorts: HOME before PATH.
720        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        // Pre-build a minimal valid manifest so we can construct
751        // CacheLookupStatus::Hit without going through the cache.
752        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    // ---- executor tests ----
795
796    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        // Target has no inputs, outputs, or deps; cache is empty
818        // so the lookup is `miss: no entry`. AUX-016 says success
819        // for any lookup outcome.
820        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            // `^:build` is the fan-out form: matches every project's `build`.
864            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    // Invalid reference *syntax* can no longer reach `execute`: the
875    // `WhyArgs::target` field is a parsed `TaskRef`, so malformed
876    // input is rejected at the CLI surface (see
877    // `cli::why::tests::aux_013_why_rejects_malformed_reference_at_parse_time`).
878}