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};
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
57/// Hash algorithm `haz why` uses when deriving cache keys. MUST
58/// match the algorithm used by `commands::run::execute` so the
59/// schema-mismatch arm of `CACHE-016` only fires for genuinely
60/// out-of-band manifests.
61const HASH_ALGO: HashAlgo = HashAlgo::Blake3;
62
63/// Execute `haz why <TASK>` against `cwd`.
64///
65/// Returns `0` on a successful render. Reference-resolution
66/// failures, predecessor-cache-miss conditions, and filesystem
67/// failures during cache-key derivation or lookup surface as
68/// miette diagnostics.
69///
70/// # Errors
71///
72/// Returns a [`miette::Report`] for workspace-load, reference-
73/// resolution, cache-key-derivation, or cache-lookup failures.
74pub 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
149/// Parse `target` and resolve it to exactly one [`TaskId`].
150///
151/// Reference parsing failures and resolution failures surface as
152/// diagnostics. Multi-match (a reference that resolves to more
153/// than one node) is rejected per `AUX-013` with a listing of the
154/// matched tasks so the user can pick a more specific form.
155fn 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
182/// Build a host-environment snapshot keyed by [`EnvVarName`].
183///
184/// Names that fail [`EnvVarName::try_new`] (the spec rejects
185/// names with `=` or null bytes; see `CACHE-008`) are dropped
186/// silently: the cache library cannot consume them anyway, and
187/// no spec rule names them invalid for the host side.
188fn 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
194/// Return the set of [`TaskId`]s that are predecessors of
195/// `target` in `graph` under the given `kind`.
196fn 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
205/// Build the `BTreeMap<TaskId, PredecessorStreamHashes>` for the
206/// target's direct hard predecessors. The recursive [`ensure_streams_for`]
207/// populates the `memo` so each predecessor's transitive closure
208/// is resolved before the consumer's key is derived.
209fn 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
230/// Populate `memo` with `task_id`'s stream hashes. Recurses for
231/// every transitive hard predecessor first.
232fn 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
289// ---- Report data + rendering ----
290
291/// One materialised input path with its statically-decidable
292/// producer set per `AUX-015` step 3.
293struct InputLine {
294    path: String,
295    producers: BTreeSet<TaskId>,
296}
297
298/// All the pieces `print_why_report` consumes. Built once by
299/// [`execute`] from the workspace + graph + cache lookup.
300struct 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
319/// Render an `AUX-015` report to `out`. Optional sections are
320/// omitted when the underlying field is absent so the header
321/// never dangles.
322fn 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        // No empty section headers should appear.
575        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        // BTreeSet sorts: HOME before PATH.
718        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        // Pre-build a minimal valid manifest so we can construct
749        // CacheLookupStatus::Hit without going through the cache.
750        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    // ---- executor tests ----
793
794    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        // Target has no inputs, outputs, or deps; cache is empty
816        // so the lookup is `miss: no entry`. AUX-016 says success
817        // for any lookup outcome.
818        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            // `^:build` is the fan-out form: matches every project's `build`.
862            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}