use std::collections::{BTreeMap, BTreeSet};
use std::env as process_env;
use std::io::{self, Write};
use haz_cache::CacheReader;
use haz_cache::lookup::CacheLookupStatus;
use haz_cache_key::{PredecessorStreamHashes, build_cache_key, resolve_input_files};
use haz_dag::edge::EdgeKind;
use haz_dag::graph::TaskGraph;
use haz_dag::producer::producers_of_path;
use haz_domain::action::{ShellType, TaskAction};
use haz_domain::env::EnvVarName;
use haz_domain::mutex::{Mutex, MutexMode, MutexScope};
use haz_domain::resolution::resolve_task_ref;
use haz_domain::task::Task;
use haz_domain::task_id::TaskId;
use haz_domain::task_ref::TaskRef;
use haz_domain::workspace::Workspace;
use haz_vfs::StdFilesystem;
use miette::{IntoDiagnostic, Result, WrapErr, miette};
use crate::cli::WhyArgs;
use crate::context::Context;
pub fn execute(args: &WhyArgs, ctx: &Context) -> Result<i32> {
let target_id = resolve_target(&args.target, ctx)?;
let project = ctx
.workspace()
.projects
.get(&target_id.project)
.ok_or_else(|| miette!("internal error: resolved task `{target_id}` not in workspace"))?;
let task = project
.tasks
.get(&target_id.task)
.ok_or_else(|| miette!("internal error: resolved task `{target_id}` not in workspace"))?;
let cache = ctx.cache_reader();
let algo = cache.hash_algo();
let host_env = host_env_map();
let mut stream_memo: BTreeMap<TaskId, PredecessorStreamHashes> = BTreeMap::new();
let direct_pred_streams = ensure_direct_predecessor_streams(
&cache,
ctx.workspace(),
ctx.graph(),
&target_id,
&host_env,
&mut stream_memo,
)?;
let target_key = build_cache_key(
cache.fs(),
ctx.workspace(),
ctx.graph(),
&target_id,
&host_env,
&direct_pred_streams,
algo,
)
.into_diagnostic()
.wrap_err("failed to derive cache key for target")?;
let target_lookup = cache
.lookup_status(&target_key)
.into_diagnostic()
.wrap_err("cache lookup for target failed")?;
let input_files = resolve_input_files(cache.fs(), ctx.workspace(), project, &task.inputs, algo)
.into_diagnostic()
.wrap_err("failed to enumerate inputs")?;
let mut inputs: Vec<InputLine> = input_files
.iter()
.map(|f| InputLine {
path: f.workspace_absolute_path.clone(),
producers: producers_of_path(ctx.workspace(), &f.workspace_absolute_path),
})
.collect();
inputs.sort_by(|a, b| a.path.cmp(&b.path));
let hard_preds = predecessors_of(ctx.graph(), &target_id, EdgeKind::Hard);
let soft_preds = predecessors_of(ctx.graph(), &target_id, EdgeKind::Soft);
let report = WhyReport {
task_id: &target_id,
task,
inputs,
hard_predecessors: hard_preds,
soft_predecessors: soft_preds,
cache_key_hex: target_key.to_hex(),
cache_lookup: lookup_label(&target_lookup),
};
let mut out = io::stdout().lock();
print_why_report(&report, &mut out)
.into_diagnostic()
.wrap_err("failed to write why report")?;
Ok(0)
}
fn resolve_target(reference: &TaskRef, ctx: &Context) -> Result<TaskId> {
let bearing_name = ctx.bearing().ok_or_else(|| {
miette!(
"haz why must be invoked from within a project root (cwd is not under any of the workspace's projects)"
)
})?;
let bearing = ctx.workspace().projects.get(bearing_name).ok_or_else(|| {
miette!("internal error: bearing project `{bearing_name}` not in workspace")
})?;
let resolved = resolve_task_ref(reference, bearing, ctx.workspace())
.map_err(|e| miette!("could not resolve `{reference}`: {e}"))?;
if resolved.len() > 1 {
let listing: Vec<String> = resolved.iter().map(|id| format!("{id}")).collect();
return Err(miette!(
"reference `{reference}` resolves to {} tasks; use a more specific form (matched: {})",
resolved.len(),
listing.join(", "),
));
}
Ok(resolved
.into_iter()
.next()
.expect("non-empty per resolve_task_ref"))
}
fn host_env_map() -> BTreeMap<EnvVarName, String> {
process_env::vars()
.filter_map(|(k, v)| EnvVarName::try_new(&k).ok().map(|n| (n, v)))
.collect()
}
fn predecessors_of(graph: &TaskGraph, target: &TaskId, kind: EdgeKind) -> BTreeSet<TaskId> {
graph
.edges
.iter()
.filter(|e| e.kind == kind && &e.to == target)
.map(|e| e.from.clone())
.collect()
}
fn ensure_direct_predecessor_streams(
cache: &CacheReader<StdFilesystem>,
workspace: &Workspace,
graph: &TaskGraph,
target: &TaskId,
host_env: &BTreeMap<EnvVarName, String>,
memo: &mut BTreeMap<TaskId, PredecessorStreamHashes>,
) -> Result<BTreeMap<TaskId, PredecessorStreamHashes>> {
let direct = predecessors_of(graph, target, EdgeKind::Hard);
for pred in &direct {
ensure_streams_for(cache, workspace, graph, pred, host_env, memo)?;
}
Ok(direct
.into_iter()
.map(|p| {
let streams = *memo.get(&p).expect("ensured above");
(p, streams)
})
.collect())
}
fn ensure_streams_for(
cache: &CacheReader<StdFilesystem>,
workspace: &Workspace,
graph: &TaskGraph,
task_id: &TaskId,
host_env: &BTreeMap<EnvVarName, String>,
memo: &mut BTreeMap<TaskId, PredecessorStreamHashes>,
) -> Result<()> {
if memo.contains_key(task_id) {
return Ok(());
}
let hard_preds = predecessors_of(graph, task_id, EdgeKind::Hard);
for pred in &hard_preds {
ensure_streams_for(cache, workspace, graph, pred, host_env, memo)?;
}
let pred_streams: BTreeMap<TaskId, PredecessorStreamHashes> = hard_preds
.iter()
.map(|p| (p.clone(), *memo.get(p).expect("ensured above")))
.collect();
let key = build_cache_key(
cache.fs(),
workspace,
graph,
task_id,
host_env,
&pred_streams,
cache.hash_algo(),
)
.into_diagnostic()
.wrap_err_with(|| format!("failed to derive cache key for predecessor `{task_id}`"))?;
let status = cache
.lookup_status(&key)
.into_diagnostic()
.wrap_err_with(|| format!("cache lookup for predecessor `{task_id}` failed"))?;
match status {
CacheLookupStatus::Hit(manifest) => {
memo.insert(
task_id.clone(),
PredecessorStreamHashes {
stdout_hash: manifest.stdout_hash,
stderr_hash: manifest.stderr_hash,
},
);
Ok(())
}
CacheLookupStatus::MissNoEntry
| CacheLookupStatus::MissSchemaMismatch
| CacheLookupStatus::MissCorruptEntry => Err(miette!(
"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}`)",
lookup_label(&status),
)),
}
}
struct InputLine {
path: String,
producers: BTreeSet<TaskId>,
}
struct WhyReport<'a> {
task_id: &'a TaskId,
task: &'a Task,
inputs: Vec<InputLine>,
hard_predecessors: BTreeSet<TaskId>,
soft_predecessors: BTreeSet<TaskId>,
cache_key_hex: String,
cache_lookup: &'static str,
}
fn lookup_label(status: &CacheLookupStatus) -> &'static str {
match status {
CacheLookupStatus::Hit(_) => "hit",
CacheLookupStatus::MissNoEntry => "miss: no entry",
CacheLookupStatus::MissSchemaMismatch => "miss: schema mismatch",
CacheLookupStatus::MissCorruptEntry => "miss: corrupt entry",
}
}
fn print_why_report(report: &WhyReport, out: &mut impl Write) -> io::Result<()> {
writeln!(out, "{}", report.task_id)?;
write_action(out, &report.task.action)?;
write_inputs(out, &report.inputs)?;
write_outputs(out, report.task)?;
write_predecessors(out, "deps", &report.hard_predecessors)?;
write_predecessors(out, "weakDeps", &report.soft_predecessors)?;
if let Some(m) = &report.task.mutex {
write_mutex(out, m)?;
}
write_env_from_host(out, report.task)?;
write_env_override(out, report.task)?;
writeln!(out, "cache_key: {}", report.cache_key_hex)?;
writeln!(out, "cache_lookup: {}", report.cache_lookup)?;
Ok(())
}
fn write_action(out: &mut impl Write, action: &TaskAction) -> io::Result<()> {
match action {
TaskAction::Command(argv) => {
let pieces: Vec<String> = argv.iter().map(|a| format!("{a:?}")).collect();
writeln!(out, "command: [{}]", pieces.join(", "))
}
TaskAction::Shell { shell, script } => {
writeln!(out, "shell ({}): {script}", shell_label(shell))
}
}
}
fn shell_label(shell: &ShellType) -> String {
match shell {
ShellType::Sh => "sh".to_owned(),
ShellType::Bash => "bash".to_owned(),
ShellType::Other(name) => name.as_ref().to_owned(),
}
}
fn write_inputs(out: &mut impl Write, inputs: &[InputLine]) -> io::Result<()> {
if inputs.is_empty() {
return Ok(());
}
writeln!(out, "inputs:")?;
for line in inputs {
match line.producers.len() {
0 => writeln!(out, " {}", line.path)?,
1 => {
let producer = line.producers.iter().next().expect("len == 1");
writeln!(out, " {} (produced by {producer})", line.path)?;
}
_ => writeln!(out, " {} (producer: undetermined)", line.path)?,
}
}
Ok(())
}
fn write_outputs(out: &mut impl Write, task: &Task) -> io::Result<()> {
if task.outputs.is_empty() {
return Ok(());
}
writeln!(out, "outputs:")?;
for o in &task.outputs {
writeln!(out, " {o}")?;
}
Ok(())
}
fn write_predecessors(
out: &mut impl Write,
label: &str,
preds: &BTreeSet<TaskId>,
) -> io::Result<()> {
if preds.is_empty() {
return Ok(());
}
writeln!(out, "{label}:")?;
for id in preds {
writeln!(out, " {id}")?;
}
Ok(())
}
fn write_mutex(out: &mut impl Write, mutex: &Mutex) -> io::Result<()> {
writeln!(
out,
"mutex: scope={}, name={}, mode={}",
scope_label(mutex.scope),
mutex.name,
mode_label(mutex.mode),
)
}
fn scope_label(s: MutexScope) -> &'static str {
match s {
MutexScope::Workspace => "workspace",
MutexScope::Project => "project",
}
}
fn mode_label(m: MutexMode) -> &'static str {
match m {
MutexMode::Exclusive => "exclusive",
MutexMode::Shared => "shared",
}
}
fn write_env_from_host(out: &mut impl Write, task: &Task) -> io::Result<()> {
if task.env.from_host.is_empty() {
return Ok(());
}
writeln!(out, "env.from_host:")?;
for name in &task.env.from_host {
writeln!(out, " {name}")?;
}
Ok(())
}
fn write_env_override(out: &mut impl Write, task: &Task) -> io::Result<()> {
if task.env.overrides.is_empty() {
return Ok(());
}
writeln!(out, "env.override:")?;
for (name, value) in &task.env.overrides {
writeln!(out, " {name}={value}")?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::{
InputLine, WhyReport, execute, lookup_label, print_why_report, scope_label, shell_label,
};
use crate::cli::{TaskRef, WhyArgs};
use crate::context::Context;
use haz_cache::lookup::CacheLookupStatus;
use haz_cache::manifest::{HashFunctionLabel, Manifest};
use haz_domain::action::{ShellType, TaskAction};
use haz_domain::env::{EnvSettings, EnvVarName};
use haz_domain::mutex::{Mutex, MutexMode, MutexScope};
use haz_domain::name::{MutexName, ProjectName, TaskName};
use haz_domain::path::OutputSpec;
use haz_domain::task::Task;
use haz_domain::task_id::TaskId;
use miette::Result;
use nonempty::NonEmpty;
use std::collections::{BTreeMap, BTreeSet};
use std::fs;
use std::path::Path;
use std::str::FromStr;
fn task_ref(s: &str) -> TaskRef {
TaskRef::parse(s).unwrap()
}
fn run_why(args: &WhyArgs, cwd: &Path) -> Result<i32> {
let ctx = Context::open(cwd)?;
execute(args, &ctx)
}
fn pname(s: &str) -> ProjectName {
ProjectName::from_str(s).unwrap()
}
fn tname(s: &str) -> TaskName {
TaskName::from_str(s).unwrap()
}
fn id(project: &str, task: &str) -> TaskId {
TaskId {
project: pname(project),
task: tname(task),
}
}
fn cmd_task() -> Task {
Task {
name: tname("build"),
action: TaskAction::Command(
NonEmpty::from_vec(vec!["cargo".into(), "build".into()]).unwrap(),
),
inputs: vec![],
outputs: vec![],
deps: vec![],
weak_deps: vec![],
mutex: None,
env: EnvSettings::default(),
}
}
fn shell_task() -> Task {
Task {
name: tname("hello"),
action: TaskAction::Shell {
shell: ShellType::Sh,
script: "echo hello".to_owned(),
},
inputs: vec![],
outputs: vec![],
deps: vec![],
weak_deps: vec![],
mutex: None,
env: EnvSettings::default(),
}
}
fn render(report: &WhyReport) -> String {
let mut out = Vec::<u8>::new();
print_why_report(report, &mut out).unwrap();
String::from_utf8(out).unwrap()
}
fn base_report<'a>(task_id: &'a TaskId, task: &'a Task) -> WhyReport<'a> {
WhyReport {
task_id,
task,
inputs: vec![],
hard_predecessors: BTreeSet::new(),
soft_predecessors: BTreeSet::new(),
cache_key_hex: "abcd".to_owned(),
cache_lookup: "hit",
}
}
#[test]
fn print_why_report_renders_resolved_identity_first_line() {
let target = id("lib", "build");
let task = cmd_task();
let report = base_report(&target, &task);
let rendered = render(&report);
assert!(
rendered.starts_with("lib:build\n"),
"first line must be the task identity: {rendered}",
);
}
#[test]
fn print_why_report_renders_command_action_arglist() {
let target = id("lib", "build");
let task = cmd_task();
let report = base_report(&target, &task);
let rendered = render(&report);
assert!(
rendered.contains("command: [\"cargo\", \"build\"]"),
"command line missing: {rendered}",
);
}
#[test]
fn print_why_report_renders_shell_action_with_shell_type() {
let target = id("lib", "hello");
let task = shell_task();
let report = base_report(&target, &task);
let rendered = render(&report);
assert!(
rendered.contains("shell (sh): echo hello"),
"shell line missing: {rendered}",
);
}
#[test]
fn print_why_report_omits_optional_sections_when_empty() {
let target = id("lib", "build");
let task = cmd_task();
let report = base_report(&target, &task);
let rendered = render(&report);
for header in [
"inputs:",
"outputs:",
"deps:",
"weakDeps:",
"mutex:",
"env.from_host:",
"env.override:",
] {
assert!(
!rendered.contains(header),
"section `{header}` must be omitted when empty: {rendered}",
);
}
}
#[test]
fn print_why_report_lists_inputs_without_annotation_when_zero_producers() {
let target = id("lib", "build");
let task = cmd_task();
let mut report = base_report(&target, &task);
report.inputs = vec![InputLine {
path: "/lib/src/main.rs".to_owned(),
producers: BTreeSet::new(),
}];
let rendered = render(&report);
assert!(
rendered.contains("inputs:\n /lib/src/main.rs\n"),
"zero-producer input must appear unannotated: {rendered}",
);
assert!(
!rendered.contains("produced by"),
"zero-producer input must not be annotated: {rendered}",
);
}
#[test]
fn print_why_report_annotates_single_producer_input() {
let target = id("lib", "build");
let task = cmd_task();
let mut report = base_report(&target, &task);
report.inputs = vec![InputLine {
path: "/lib/dist/api.js".to_owned(),
producers: BTreeSet::from([id("lib", "gen")]),
}];
let rendered = render(&report);
assert!(
rendered.contains("/lib/dist/api.js (produced by lib:gen)"),
"single-producer annotation missing: {rendered}",
);
}
#[test]
fn print_why_report_annotates_multi_producer_input_as_undetermined() {
let target = id("lib", "build");
let task = cmd_task();
let mut report = base_report(&target, &task);
report.inputs = vec![InputLine {
path: "/lib/dist/api.js".to_owned(),
producers: BTreeSet::from([id("lib", "gen_a"), id("lib", "gen_b")]),
}];
let rendered = render(&report);
assert!(
rendered.contains("/lib/dist/api.js (producer: undetermined)"),
"multi-producer annotation missing: {rendered}",
);
}
#[test]
fn print_why_report_lists_outputs_verbatim() {
let target = id("lib", "build");
let mut task = cmd_task();
task.outputs = vec![OutputSpec::parse("dist/main.js").unwrap()];
let report = base_report(&target, &task);
let rendered = render(&report);
assert!(
rendered.contains("outputs:\n dist/main.js\n"),
"outputs verbatim missing: {rendered}",
);
}
#[test]
fn print_why_report_lists_resolved_deps_in_canonical_order() {
let target = id("lib", "build");
let task = cmd_task();
let mut report = base_report(&target, &task);
report.hard_predecessors = BTreeSet::from([id("z_lib", "fmt"), id("a_lib", "gen")]);
let rendered = render(&report);
let a_pos = rendered.find("a_lib:gen").unwrap();
let z_pos = rendered.find("z_lib:fmt").unwrap();
assert!(
a_pos < z_pos,
"deps must appear in canonical (ProjectName, TaskName) order: {rendered}",
);
}
#[test]
fn print_why_report_lists_weak_deps_under_weak_deps_section() {
let target = id("lib", "build");
let task = cmd_task();
let mut report = base_report(&target, &task);
report.soft_predecessors = BTreeSet::from([id("lib", "fmt")]);
let rendered = render(&report);
assert!(
rendered.contains("weakDeps:\n lib:fmt\n"),
"weakDeps section missing: {rendered}",
);
}
#[test]
fn print_why_report_renders_mutex_section_when_present() {
let target = id("lib", "build");
let mut task = cmd_task();
task.mutex = Some(Mutex {
scope: MutexScope::Project,
name: MutexName::from_str("db").unwrap(),
mode: MutexMode::Shared,
});
let report = base_report(&target, &task);
let rendered = render(&report);
assert!(
rendered.contains("mutex: scope=project, name=db, mode=shared"),
"mutex line missing: {rendered}",
);
}
#[test]
fn print_why_report_renders_env_sections_when_non_empty() {
let target = id("lib", "build");
let mut task = cmd_task();
task.env = EnvSettings {
from_host: BTreeSet::from([
EnvVarName::try_new("PATH").unwrap(),
EnvVarName::try_new("HOME").unwrap(),
]),
overrides: BTreeMap::from([(
EnvVarName::try_new("RUST_LOG").unwrap(),
"debug".to_owned(),
)]),
};
let report = base_report(&target, &task);
let rendered = render(&report);
assert!(
rendered.contains("env.from_host:\n HOME\n PATH\n"),
"env.from_host listing missing or unsorted: {rendered}",
);
assert!(
rendered.contains("env.override:\n RUST_LOG=debug\n"),
"env.override listing missing: {rendered}",
);
}
#[test]
fn print_why_report_emits_cache_key_and_cache_lookup_lines() {
let target = id("lib", "build");
let task = cmd_task();
let mut report = base_report(&target, &task);
report.cache_key_hex = "deadbeef".to_owned();
report.cache_lookup = "miss: corrupt entry";
let rendered = render(&report);
assert!(
rendered.contains("cache_key: deadbeef"),
"cache_key line missing: {rendered}",
);
assert!(
rendered.contains("cache_lookup: miss: corrupt entry"),
"cache_lookup line missing: {rendered}",
);
}
#[test]
fn lookup_label_maps_every_status_variant() {
let mut bytes = [0u8; 32];
bytes[0] = 0xAB;
let key = haz_cache::CacheKey::from_bytes(bytes);
let manifest = Manifest {
chapter_revision: 0,
hash_function: HashFunctionLabel::Blake3,
key,
outputs: vec![],
stdout_len: 0,
stderr_len: 0,
stdout_hash: [0u8; 32],
stderr_hash: [0u8; 32],
exit_status: 0,
created_at_unix: 0,
};
assert_eq!(lookup_label(&CacheLookupStatus::Hit(manifest)), "hit");
assert_eq!(
lookup_label(&CacheLookupStatus::MissNoEntry),
"miss: no entry",
);
assert_eq!(
lookup_label(&CacheLookupStatus::MissSchemaMismatch),
"miss: schema mismatch",
);
assert_eq!(
lookup_label(&CacheLookupStatus::MissCorruptEntry),
"miss: corrupt entry",
);
}
#[test]
fn shell_label_maps_each_shell_type() {
assert_eq!(shell_label(&ShellType::Sh), "sh");
assert_eq!(shell_label(&ShellType::Bash), "bash");
}
#[test]
fn scope_label_maps_each_scope() {
assert_eq!(scope_label(MutexScope::Workspace), "workspace");
assert_eq!(scope_label(MutexScope::Project), "project");
}
fn write_file(path: &Path, contents: &str) {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).unwrap();
}
fs::write(path, contents).unwrap();
}
fn write_workspace_with_single_task(root: &Path) {
write_file(&root.join(".haz/workspace.yml"), "projects:\n - /lib\n");
write_file(
&root.join("lib/haz.yml"),
"name: lib\ntasks:\n build:\n command: [\"true\"]\n",
);
}
fn cd_in_lib(root: &Path) -> std::path::PathBuf {
root.join("lib")
}
#[test]
fn execute_succeeds_on_isolated_task_with_no_predecessors() {
let tempdir = tempfile::TempDir::new().unwrap();
let root = tempdir.path();
write_workspace_with_single_task(root);
let args = WhyArgs {
target: task_ref("~:build"),
};
let code = run_why(&args, &cd_in_lib(root)).unwrap();
assert_eq!(code, 0);
}
#[test]
fn execute_rejects_unresolved_reference() {
let tempdir = tempfile::TempDir::new().unwrap();
let root = tempdir.path();
write_workspace_with_single_task(root);
let args = WhyArgs {
target: task_ref("~:nonexistent"),
};
let err = run_why(&args, &cd_in_lib(root)).unwrap_err();
let rendered = format!("{err}");
assert!(
rendered.contains("could not resolve"),
"missing resolution diagnostic: {rendered}",
);
}
#[test]
fn execute_rejects_multi_match_fan_out() {
let tempdir = tempfile::TempDir::new().unwrap();
let root = tempdir.path();
write_file(
&root.join(".haz/workspace.yml"),
"projects:\n - /lib_a\n - /lib_b\n",
);
write_file(
&root.join("lib_a/haz.yml"),
"name: lib_a\ntasks:\n build:\n command: [\"true\"]\n",
);
write_file(
&root.join("lib_b/haz.yml"),
"name: lib_b\ntasks:\n build:\n command: [\"true\"]\n",
);
let args = WhyArgs {
target: task_ref("^:build"),
};
let err = run_why(&args, &root.join("lib_a")).unwrap_err();
let rendered = format!("{err}");
assert!(
rendered.contains("resolves to") && rendered.contains("use a more specific form"),
"missing multi-match diagnostic: {rendered}",
);
}
}