use std::collections::{BTreeMap, BTreeSet};
use std::io::{self, Write};
use std::sync::{Arc, Mutex};
use std::time::{Instant, SystemTime, UNIX_EPOCH};
use haz_dag::edge::Edge;
use haz_dag::graph::TaskGraph;
use haz_domain::env::EnvVarName;
use haz_domain::settings::output::OutputMode;
use haz_domain::task_id::TaskId;
use haz_exec::exit_code::{CancellationSignal, exit_code_for};
use haz_exec::output::{BufferedOutputObserver, LiveOutputObserver};
use haz_exec::presenter::TaskPresenter;
use haz_exec::run_graph::{RunGraphOutcome, run_graph};
use haz_exec::run_task::RunContext;
use haz_exec::std_impl::StdProcessSpawner;
use miette::{IntoDiagnostic, Result, WrapErr};
use tokio_util::sync::CancellationToken;
use haz_target::{TargetSpec, resolve_targets};
use haz_vcs::workspace_changeset;
use crate::cli::RunArgs;
use crate::context::Context;
use crate::output::color_choice::ColorChoice;
use crate::output::epilogue::write_epilogue;
use crate::output::presenter::CliPresenter;
pub fn execute(args: &RunArgs, ctx: &Context) -> Result<i32> {
let bearing = ctx.bearing();
let changeset = match &args.git_filter {
Some(filter) => Some(
workspace_changeset(filter, ctx.workspace().root.as_path())
.into_diagnostic()
.wrap_err("git-changeset enumeration failed")?,
),
None => None,
};
let spec = TargetSpec {
targets: args.targets.clone(),
scope: args.scope,
};
let targets = resolve_targets(
&spec,
bearing,
ctx.workspace(),
ctx.graph(),
changeset.as_ref(),
)
.into_diagnostic()
.wrap_err("target resolution failed")?;
if args.dry_run {
let mut out = io::stdout().lock();
return print_dry_run_plan(&targets, &mut out)
.into_diagnostic()
.wrap_err("failed to write dry-run plan")
.map(|()| 0);
}
let mode = args
.output_mode
.unwrap_or(ctx.workspace().settings.output.mode);
let exec_graph = restrict_graph_to_targets(ctx.graph(), &targets);
let color = ColorChoice::detect();
run_to_exit(ctx, exec_graph, mode, color)
}
fn print_dry_run_plan(targets: &BTreeSet<TaskId>, out: &mut impl Write) -> io::Result<()> {
if targets.is_empty() {
return writeln!(out, "would run 0 tasks.");
}
let suffix = if targets.len() == 1 { "" } else { "s" };
writeln!(out, "would run {} task{suffix}:", targets.len())?;
for id in targets {
writeln!(out, " - {id}")?;
}
Ok(())
}
fn restrict_graph_to_targets(graph: &TaskGraph, targets: &BTreeSet<TaskId>) -> TaskGraph {
let nodes = targets.clone();
let edges: BTreeSet<Edge> = graph
.edges
.iter()
.filter(|e| nodes.contains(&e.from) && nodes.contains(&e.to))
.cloned()
.collect();
TaskGraph { nodes, edges }
}
fn run_to_exit(
ctx: &Context,
graph: TaskGraph,
mode: OutputMode,
color: ColorChoice,
) -> Result<i32> {
if graph.nodes.is_empty() {
return Ok(0);
}
let runtime = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()
.into_diagnostic()
.wrap_err("failed to build the tokio runtime")?;
runtime.block_on(async move { dispatch_run_graph(ctx, graph, mode, color).await })
}
async fn dispatch_run_graph(
ctx: &Context,
graph: TaskGraph,
mode: OutputMode,
color: ColorChoice,
) -> Result<i32> {
let cache = ctx.cache_writer();
let algo = cache.hash_algo();
let spawner = StdProcessSpawner::new();
let host_env = host_env_snapshot();
let cancel = CancellationToken::new();
let captured_signal: Arc<Mutex<Option<CancellationSignal>>> = Arc::new(Mutex::new(None));
let signal_handle = spawn_signal_listener(cancel.clone(), Arc::clone(&captured_signal));
let created_at_unix = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_or(0, |d| d.as_secs());
let presenter: Arc<dyn TaskPresenter> = Arc::new(CliPresenter::new(color));
let started_at = Instant::now();
let outcome = match mode {
OutputMode::Live => {
let observer = LiveOutputObserver::with_presenter(
io::stdout(),
io::stderr(),
Arc::clone(&presenter),
);
let run_ctx = RunContext {
fs: cache.fs(),
cache: &cache,
spawner: &spawner,
observer: &observer,
workspace: ctx.workspace(),
graph: &graph,
host_env: &host_env,
algo,
cancel: &cancel,
};
run_graph(&run_ctx, created_at_unix).await
}
OutputMode::Buffered => {
let observer = BufferedOutputObserver::with_presenter(
io::stdout(),
io::stderr(),
Arc::clone(&presenter),
);
let run_ctx = RunContext {
fs: cache.fs(),
cache: &cache,
spawner: &spawner,
observer: &observer,
workspace: ctx.workspace(),
graph: &graph,
host_env: &host_env,
algo,
cancel: &cancel,
};
run_graph(&run_ctx, created_at_unix).await
}
}
.map_err(|err| miette::miette!("scheduler failed: {err}"))?;
let elapsed = started_at.elapsed();
signal_handle.abort();
emit_run_epilogue(&outcome, elapsed, color);
let signal = *captured_signal
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
Ok(exit_code_for(&outcome, signal))
}
fn emit_run_epilogue(outcome: &RunGraphOutcome, elapsed: std::time::Duration, color: ColorChoice) {
let mut stderr = io::stderr().lock();
let _ = write_epilogue(&mut stderr, outcome, elapsed, color);
}
fn spawn_signal_listener(
cancel: CancellationToken,
captured: Arc<Mutex<Option<CancellationSignal>>>,
) -> tokio::task::JoinHandle<()> {
tokio::spawn(listen_for_signals(cancel, captured))
}
#[cfg(unix)]
async fn listen_for_signals(
cancel: CancellationToken,
captured: Arc<Mutex<Option<CancellationSignal>>>,
) {
use tokio::signal::unix::{SignalKind, signal};
let (Ok(mut sigint), Ok(mut sigterm)) = (
signal(SignalKind::interrupt()),
signal(SignalKind::terminate()),
) else {
return;
};
tokio::select! {
_ = sigint.recv() => {
record_signal(&captured, CancellationSignal::Interrupt);
cancel.cancel();
}
_ = sigterm.recv() => {
record_signal(&captured, CancellationSignal::Terminate);
cancel.cancel();
}
}
}
#[cfg(not(unix))]
async fn listen_for_signals(
_cancel: CancellationToken,
_captured: Arc<Mutex<Option<CancellationSignal>>>,
) {
}
#[cfg(unix)]
fn record_signal(captured: &Mutex<Option<CancellationSignal>>, sig: CancellationSignal) {
let mut slot = captured
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
*slot = Some(sig);
}
fn host_env_snapshot() -> BTreeMap<EnvVarName, String> {
let mut out = BTreeMap::new();
for (key, value) in std::env::vars() {
if let Ok(name) = EnvVarName::try_new(key) {
out.insert(name, value);
}
}
out
}
#[cfg(test)]
mod tests {
use super::{execute, print_dry_run_plan};
use crate::cli::{RunArgs, Scope, TaskRef};
use crate::context::Context;
use haz_domain::name::{ProjectName, TaskName};
use haz_domain::task_id::TaskId;
use miette::Result;
use std::collections::BTreeSet;
use std::fs;
use std::path::Path;
use std::str::FromStr;
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 run_run(args: &RunArgs, cwd: &Path) -> Result<i32> {
execute(args, &Context::open(cwd)?)
}
fn id(project: &str, task: &str) -> TaskId {
TaskId {
project: ProjectName::from_str(project).unwrap(),
task: TaskName::from_str(task).unwrap(),
}
}
fn dry_run_args() -> RunArgs {
RunArgs {
targets: Vec::new(),
scope: Scope::Default,
git_filter: None,
dry_run: true,
output_mode: None,
}
}
fn run_args_with_targets(targets: &[&str]) -> RunArgs {
RunArgs {
targets: targets.iter().map(|s| TaskRef::parse(s).unwrap()).collect(),
scope: Scope::Default,
git_filter: None,
dry_run: false,
output_mode: None,
}
}
#[test]
fn dry_run_plan_empty_targets() {
let targets: BTreeSet<TaskId> = BTreeSet::new();
let mut out = Vec::<u8>::new();
print_dry_run_plan(&targets, &mut out).unwrap();
assert_eq!(String::from_utf8(out).unwrap(), "would run 0 tasks.\n");
}
#[test]
fn dry_run_plan_single_target_singular_count() {
let mut targets = BTreeSet::new();
targets.insert(id("lib", "build"));
let mut out = Vec::<u8>::new();
print_dry_run_plan(&targets, &mut out).unwrap();
assert_eq!(
String::from_utf8(out).unwrap(),
"would run 1 task:\n - lib:build\n",
);
}
#[test]
fn dry_run_plan_multiple_targets_canonical_order() {
let mut targets = BTreeSet::new();
targets.insert(id("z", "build"));
targets.insert(id("a", "test"));
targets.insert(id("a", "build"));
let mut out = Vec::<u8>::new();
print_dry_run_plan(&targets, &mut out).unwrap();
assert_eq!(
String::from_utf8(out).unwrap(),
"would run 3 tasks:\n - a:build\n - a:test\n - z:build\n",
);
}
fn true_path() -> Option<&'static str> {
const CANDIDATES: &[&str] = &["/usr/bin/true", "/bin/true"];
CANDIDATES.iter().copied().find(|p| Path::new(p).exists())
}
#[test]
fn execute_dry_run_on_singleton_workspace_returns_zero() {
let tempdir = tempfile::TempDir::new().unwrap();
let root = tempdir.path();
fs::create_dir_all(root.join(".git")).unwrap();
write_file(
&root.join("haz.yml"),
"name: only_proj\ntasks:\n default:\n command: [\"true\"]\n",
);
let args = dry_run_args();
let code = run_run(&args, root).unwrap();
assert_eq!(code, 0);
}
#[test]
fn execute_runs_trivial_task_to_success() {
let Some(true_bin) = true_path() else {
eprintln!("skipping: no `true` binary at /usr/bin/true or /bin/true");
return;
};
let tempdir = tempfile::TempDir::new().unwrap();
let root = tempdir.path();
fs::create_dir_all(root.join(".git")).unwrap();
write_file(
&root.join("haz.yml"),
&format!("name: only_proj\ntasks:\n default:\n command: [\"{true_bin}\"]\n"),
);
let args = run_args_with_targets(&["~:default"]);
let code = run_run(&args, root).unwrap();
assert_eq!(code, 0);
}
#[test]
fn execute_runs_trivial_task_in_buffered_mode() {
let Some(true_bin) = true_path() else {
eprintln!("skipping: no `true` binary at /usr/bin/true or /bin/true");
return;
};
let tempdir = tempfile::TempDir::new().unwrap();
let root = tempdir.path();
fs::create_dir_all(root.join(".git")).unwrap();
write_file(
&root.join("haz.yml"),
&format!("name: only_proj\ntasks:\n default:\n command: [\"{true_bin}\"]\n"),
);
let mut args = run_args_with_targets(&["~:default"]);
args.output_mode = Some(crate::cli::OutputMode::Buffered);
let code = run_run(&args, root).unwrap();
assert_eq!(code, 0);
}
#[test]
fn exec_023_execute_default_target_without_bearing_errors() {
let tempdir = tempfile::TempDir::new().unwrap();
let root = tempdir.path();
write_file(&root.join(".haz/workspace.yml"), "projects:\n - /lib\n");
write_file(
&root.join("lib/haz.yml"),
"name: lib\ntasks:\n default:\n command: [\"true\"]\n",
);
let args = RunArgs {
targets: Vec::new(),
scope: Scope::Default,
git_filter: None,
dry_run: false,
output_mode: None,
};
let err = run_run(&args, root).unwrap_err();
assert!(
format!("{err:?}").contains("no bearing project"),
"diagnostic should mention the no-bearing case: {err:?}",
);
}
use crate::cli::GitFilter;
use std::process::Command;
fn git(cwd: &Path, args: &[&str]) {
let output = Command::new("git")
.arg("-C")
.arg(cwd)
.args(args)
.output()
.expect("git binary must be available on PATH for these tests");
assert!(
output.status.success(),
"git {} failed:\nstdout: {}\nstderr: {}",
args.join(" "),
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr),
);
}
fn init_repo(root: &Path) {
git(root, &["init", "--quiet", "--initial-branch=main"]);
git(root, &["config", "user.email", "test@example.invalid"]);
git(root, &["config", "user.name", "Test"]);
git(root, &["config", "commit.gpgsign", "false"]);
}
fn dry_run_with_filter(filter: GitFilter) -> RunArgs {
RunArgs {
targets: Vec::new(),
scope: Scope::Workspace,
git_filter: Some(filter),
dry_run: true,
output_mode: None,
}
}
fn write_two_project_workspace(root: &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 inputs:\n - src/main.rs\n",
);
write_file(
&root.join("lib_b/haz.yml"),
"name: lib_b\ntasks:\n build:\n command: [\"true\"]\n inputs:\n - src/main.rs\n",
);
write_file(&root.join("lib_a/src/main.rs"), "fn main() {}\n");
write_file(&root.join("lib_b/src/main.rs"), "fn main() {}\n");
}
#[test]
fn execute_git_changed_dry_run_succeeds_when_one_input_changes() {
let tempdir = tempfile::TempDir::new().unwrap();
let root = tempdir.path();
init_repo(root);
write_two_project_workspace(root);
git(root, &["add", "."]);
git(root, &["commit", "--quiet", "-m", "init"]);
write_file(&root.join("lib_a/src/main.rs"), "fn main() { /* v2 */ }\n");
let args = dry_run_with_filter(GitFilter::Changed);
let code = run_run(&args, root).unwrap();
assert_eq!(code, 0);
}
#[test]
fn execute_git_changed_dry_run_succeeds_when_no_input_changes() {
let tempdir = tempfile::TempDir::new().unwrap();
let root = tempdir.path();
init_repo(root);
write_two_project_workspace(root);
git(root, &["add", "."]);
git(root, &["commit", "--quiet", "-m", "init"]);
let args = dry_run_with_filter(GitFilter::Changed);
let code = run_run(&args, root).unwrap();
assert_eq!(code, 0);
}
#[test]
fn execute_git_changed_surfaces_not_a_repo_error() {
let tempdir = tempfile::TempDir::new().unwrap();
let root = tempdir.path();
write_two_project_workspace(root);
let args = dry_run_with_filter(GitFilter::Changed);
let err = run_run(&args, root).unwrap_err();
let rendered = format!("{err:?}");
assert!(
rendered.contains("not inside a git repository")
|| rendered.contains("git-changeset enumeration"),
"diagnostic should name the missing-repo failure: {rendered}",
);
}
}