1use std::collections::{BTreeMap, BTreeSet};
19use std::io::{self, Write};
20use std::path::Path;
21use std::sync::{Arc, Mutex};
22use std::time::{Instant, SystemTime, UNIX_EPOCH};
23
24use haz_cache::cache::Cache;
25use haz_dag::edge::Edge;
26use haz_dag::graph::TaskGraph;
27use haz_domain::env::EnvVarName;
28use haz_domain::settings::cache::HashAlgo;
29use haz_domain::settings::output::OutputMode;
30use haz_domain::task_id::TaskId;
31use haz_domain::workspace::Workspace;
32use haz_exec::exit_code::{CancellationSignal, exit_code_for};
33use haz_exec::output::{BufferedOutputObserver, LiveOutputObserver};
34use haz_exec::presenter::TaskPresenter;
35use haz_exec::run_graph::{RunGraphOutcome, run_graph};
36use haz_exec::run_task::RunContext;
37use haz_exec::std_impl::StdProcessSpawner;
38use haz_vfs::StdFilesystem;
39use miette::{IntoDiagnostic, Result, WrapErr};
40use tokio_util::sync::CancellationToken;
41
42use haz_discovery::bearing::bearing_project;
43use haz_target::{TargetSpec, resolve_targets};
44use haz_vcs::workspace_changeset;
45
46use crate::cli::RunArgs;
47use crate::output::color_choice::ColorChoice;
48use crate::output::epilogue::write_epilogue;
49use crate::output::presenter::CliPresenter;
50use crate::workspace_load::load_workspace;
51
52const HASH_ALGO: HashAlgo = HashAlgo::Blake3;
57
58pub fn execute(args: &RunArgs, cwd: &Path) -> Result<i32> {
74 let (workspace, full_graph) = load_workspace(cwd)?;
75 let bearing = bearing_project(cwd, &workspace);
76
77 let changeset = match &args.git_filter {
82 Some(filter) => Some(
83 workspace_changeset(filter, workspace.root.as_path())
84 .into_diagnostic()
85 .wrap_err("git-changeset enumeration failed")?,
86 ),
87 None => None,
88 };
89 let spec = TargetSpec {
90 targets: args.targets.clone(),
91 all: args.all,
92 workspace: args.workspace,
93 };
94 let targets = resolve_targets(&spec, bearing, &workspace, &full_graph, changeset.as_ref())
95 .into_diagnostic()
96 .wrap_err("target resolution failed")?;
97
98 if args.dry_run {
99 let mut out = io::stdout().lock();
100 return print_dry_run_plan(&targets, &mut out)
101 .into_diagnostic()
102 .wrap_err("failed to write dry-run plan")
103 .map(|()| 0);
104 }
105
106 let mode = args.output_mode.unwrap_or(workspace.settings.output.mode);
113 let exec_graph = restrict_graph_to_targets(&full_graph, &targets);
114 let color = ColorChoice::detect();
115 run_to_exit(workspace, exec_graph, mode, color)
116}
117
118fn print_dry_run_plan(targets: &BTreeSet<TaskId>, out: &mut impl Write) -> io::Result<()> {
124 if targets.is_empty() {
125 return writeln!(out, "would run 0 tasks.");
126 }
127 let suffix = if targets.len() == 1 { "" } else { "s" };
128 writeln!(out, "would run {} task{suffix}:", targets.len())?;
129 for id in targets {
130 writeln!(out, " - {id}")?;
131 }
132 Ok(())
133}
134
135fn restrict_graph_to_targets(graph: &TaskGraph, targets: &BTreeSet<TaskId>) -> TaskGraph {
145 let nodes = targets.clone();
146 let edges: BTreeSet<Edge> = graph
147 .edges
148 .iter()
149 .filter(|e| nodes.contains(&e.from) && nodes.contains(&e.to))
150 .cloned()
151 .collect();
152 TaskGraph { nodes, edges }
153}
154
155fn run_to_exit(
161 workspace: Workspace,
162 graph: TaskGraph,
163 mode: OutputMode,
164 color: ColorChoice,
165) -> Result<i32> {
166 if graph.nodes.is_empty() {
167 return Ok(0);
168 }
169 let runtime = tokio::runtime::Builder::new_multi_thread()
170 .enable_all()
171 .build()
172 .into_diagnostic()
173 .wrap_err("failed to build the tokio runtime")?;
174 runtime.block_on(async move { dispatch_run_graph(workspace, graph, mode, color).await })
175}
176
177async fn dispatch_run_graph(
187 workspace: Workspace,
188 graph: TaskGraph,
189 mode: OutputMode,
190 color: ColorChoice,
191) -> Result<i32> {
192 let workspace_root = workspace.root.as_path().to_path_buf();
193 let cache = Cache::new(StdFilesystem::new(), &workspace_root, HASH_ALGO);
194 let spawner = StdProcessSpawner::new();
195 let host_env = host_env_snapshot();
196 let cancel = CancellationToken::new();
197 let captured_signal: Arc<Mutex<Option<CancellationSignal>>> = Arc::new(Mutex::new(None));
198 let signal_handle = spawn_signal_listener(cancel.clone(), Arc::clone(&captured_signal));
199 let created_at_unix = SystemTime::now()
200 .duration_since(UNIX_EPOCH)
201 .map_or(0, |d| d.as_secs());
202 let presenter: Arc<dyn TaskPresenter> = Arc::new(CliPresenter::new(color));
203
204 let started_at = Instant::now();
209
210 let outcome = match mode {
216 OutputMode::Live => {
217 let observer = LiveOutputObserver::with_presenter(
218 io::stdout(),
219 io::stderr(),
220 Arc::clone(&presenter),
221 );
222 let ctx = RunContext {
223 fs: cache.fs(),
224 cache: &cache,
225 spawner: &spawner,
226 observer: &observer,
227 workspace: &workspace,
228 graph: &graph,
229 host_env: &host_env,
230 algo: HASH_ALGO,
231 cancel: &cancel,
232 };
233 run_graph(&ctx, created_at_unix).await
234 }
235 OutputMode::Buffered => {
236 let observer = BufferedOutputObserver::with_presenter(
237 io::stdout(),
238 io::stderr(),
239 Arc::clone(&presenter),
240 );
241 let ctx = RunContext {
242 fs: cache.fs(),
243 cache: &cache,
244 spawner: &spawner,
245 observer: &observer,
246 workspace: &workspace,
247 graph: &graph,
248 host_env: &host_env,
249 algo: HASH_ALGO,
250 cancel: &cancel,
251 };
252 run_graph(&ctx, created_at_unix).await
253 }
254 }
255 .map_err(|err| miette::miette!("scheduler failed: {err}"))?;
256
257 let elapsed = started_at.elapsed();
258
259 signal_handle.abort();
265
266 emit_run_epilogue(&outcome, elapsed, color);
267
268 let signal = *captured_signal
269 .lock()
270 .unwrap_or_else(std::sync::PoisonError::into_inner);
271 Ok(exit_code_for(&outcome, signal))
272}
273
274fn emit_run_epilogue(outcome: &RunGraphOutcome, elapsed: std::time::Duration, color: ColorChoice) {
281 let mut stderr = io::stderr().lock();
282 let _ = write_epilogue(&mut stderr, outcome, elapsed, color);
283}
284
285fn spawn_signal_listener(
300 cancel: CancellationToken,
301 captured: Arc<Mutex<Option<CancellationSignal>>>,
302) -> tokio::task::JoinHandle<()> {
303 tokio::spawn(listen_for_signals(cancel, captured))
304}
305
306#[cfg(unix)]
307async fn listen_for_signals(
308 cancel: CancellationToken,
309 captured: Arc<Mutex<Option<CancellationSignal>>>,
310) {
311 use tokio::signal::unix::{SignalKind, signal};
312 let (Ok(mut sigint), Ok(mut sigterm)) = (
318 signal(SignalKind::interrupt()),
319 signal(SignalKind::terminate()),
320 ) else {
321 return;
322 };
323 tokio::select! {
324 _ = sigint.recv() => {
325 record_signal(&captured, CancellationSignal::Interrupt);
326 cancel.cancel();
327 }
328 _ = sigterm.recv() => {
329 record_signal(&captured, CancellationSignal::Terminate);
330 cancel.cancel();
331 }
332 }
333}
334
335#[cfg(not(unix))]
336async fn listen_for_signals(
337 _cancel: CancellationToken,
338 _captured: Arc<Mutex<Option<CancellationSignal>>>,
339) {
340 }
344
345#[cfg(unix)]
346fn record_signal(captured: &Mutex<Option<CancellationSignal>>, sig: CancellationSignal) {
347 let mut slot = captured
348 .lock()
349 .unwrap_or_else(std::sync::PoisonError::into_inner);
350 *slot = Some(sig);
351}
352
353fn host_env_snapshot() -> BTreeMap<EnvVarName, String> {
360 let mut out = BTreeMap::new();
361 for (key, value) in std::env::vars() {
362 if let Ok(name) = EnvVarName::try_new(key) {
363 out.insert(name, value);
364 }
365 }
366 out
367}
368
369#[cfg(test)]
370mod tests {
371 use super::{execute, print_dry_run_plan};
372 use crate::cli::RunArgs;
373 use haz_domain::name::{ProjectName, TaskName};
374 use haz_domain::task_id::TaskId;
375 use std::collections::BTreeSet;
376 use std::fs;
377 use std::path::Path;
378 use std::str::FromStr;
379
380 fn write_file(path: &Path, contents: &str) {
381 if let Some(parent) = path.parent() {
382 fs::create_dir_all(parent).unwrap();
383 }
384 fs::write(path, contents).unwrap();
385 }
386
387 fn id(project: &str, task: &str) -> TaskId {
388 TaskId {
389 project: ProjectName::from_str(project).unwrap(),
390 task: TaskName::from_str(task).unwrap(),
391 }
392 }
393
394 fn dry_run_args() -> RunArgs {
395 RunArgs {
396 targets: Vec::new(),
397 all: false,
398 workspace: false,
399 git_filter: None,
400 dry_run: true,
401 output_mode: None,
402 }
403 }
404
405 fn run_args_with_targets(targets: Vec<String>) -> RunArgs {
406 RunArgs {
407 targets,
408 all: false,
409 workspace: false,
410 git_filter: None,
411 dry_run: false,
412 output_mode: None,
413 }
414 }
415
416 #[test]
417 fn dry_run_plan_empty_targets() {
418 let targets: BTreeSet<TaskId> = BTreeSet::new();
419 let mut out = Vec::<u8>::new();
420 print_dry_run_plan(&targets, &mut out).unwrap();
421 assert_eq!(String::from_utf8(out).unwrap(), "would run 0 tasks.\n");
422 }
423
424 #[test]
425 fn dry_run_plan_single_target_singular_count() {
426 let mut targets = BTreeSet::new();
427 targets.insert(id("lib", "build"));
428 let mut out = Vec::<u8>::new();
429 print_dry_run_plan(&targets, &mut out).unwrap();
430 assert_eq!(
431 String::from_utf8(out).unwrap(),
432 "would run 1 task:\n - lib:build\n",
433 );
434 }
435
436 #[test]
437 fn dry_run_plan_multiple_targets_canonical_order() {
438 let mut targets = BTreeSet::new();
439 targets.insert(id("z", "build"));
440 targets.insert(id("a", "test"));
441 targets.insert(id("a", "build"));
442 let mut out = Vec::<u8>::new();
443 print_dry_run_plan(&targets, &mut out).unwrap();
444 assert_eq!(
445 String::from_utf8(out).unwrap(),
446 "would run 3 tasks:\n - a:build\n - a:test\n - z:build\n",
447 );
448 }
449
450 fn true_path() -> Option<&'static str> {
457 const CANDIDATES: &[&str] = &["/usr/bin/true", "/bin/true"];
458 CANDIDATES.iter().copied().find(|p| Path::new(p).exists())
459 }
460
461 #[test]
462 fn execute_dry_run_on_singleton_workspace_returns_zero() {
463 let tempdir = tempfile::TempDir::new().unwrap();
464 let root = tempdir.path();
465 fs::create_dir_all(root.join(".git")).unwrap();
466 write_file(
467 &root.join("haz.yml"),
468 "name: only_proj\ntasks:\n default:\n command: [\"true\"]\n",
469 );
470 let args = dry_run_args();
471 let code = execute(&args, root).unwrap();
472 assert_eq!(code, 0);
473 }
474
475 #[test]
476 fn execute_runs_trivial_task_to_success() {
477 let Some(true_bin) = true_path() else {
478 eprintln!("skipping: no `true` binary at /usr/bin/true or /bin/true");
479 return;
480 };
481 let tempdir = tempfile::TempDir::new().unwrap();
482 let root = tempdir.path();
483 fs::create_dir_all(root.join(".git")).unwrap();
484 write_file(
485 &root.join("haz.yml"),
486 &format!("name: only_proj\ntasks:\n default:\n command: [\"{true_bin}\"]\n"),
487 );
488 let args = run_args_with_targets(vec!["~:default".to_owned()]);
491 let code = execute(&args, root).unwrap();
492 assert_eq!(code, 0);
493 }
494
495 #[test]
496 fn execute_runs_trivial_task_in_buffered_mode() {
497 let Some(true_bin) = true_path() else {
503 eprintln!("skipping: no `true` binary at /usr/bin/true or /bin/true");
504 return;
505 };
506 let tempdir = tempfile::TempDir::new().unwrap();
507 let root = tempdir.path();
508 fs::create_dir_all(root.join(".git")).unwrap();
509 write_file(
510 &root.join("haz.yml"),
511 &format!("name: only_proj\ntasks:\n default:\n command: [\"{true_bin}\"]\n"),
512 );
513 let mut args = run_args_with_targets(vec!["~:default".to_owned()]);
514 args.output_mode = Some(crate::cli::OutputMode::Buffered);
515 let code = execute(&args, root).unwrap();
516 assert_eq!(code, 0);
517 }
518
519 #[test]
520 fn exec_023_execute_default_target_without_bearing_errors() {
521 let tempdir = tempfile::TempDir::new().unwrap();
525 let root = tempdir.path();
526 write_file(&root.join(".haz/workspace.yml"), "projects:\n - /lib\n");
527 write_file(
528 &root.join("lib/haz.yml"),
529 "name: lib\ntasks:\n default:\n command: [\"true\"]\n",
530 );
531 let args = RunArgs {
532 targets: Vec::new(),
533 all: false,
534 workspace: false,
535 git_filter: None,
536 dry_run: false,
537 output_mode: None,
538 };
539 let err = execute(&args, root).unwrap_err();
540 assert!(
541 format!("{err:?}").contains("no bearing project"),
542 "diagnostic should mention the no-bearing case: {err:?}",
543 );
544 }
545
546 use crate::cli::GitFilter;
549 use std::process::Command;
550
551 fn git(cwd: &Path, args: &[&str]) {
552 let output = Command::new("git")
553 .arg("-C")
554 .arg(cwd)
555 .args(args)
556 .output()
557 .expect("git binary must be available on PATH for these tests");
558 assert!(
559 output.status.success(),
560 "git {} failed:\nstdout: {}\nstderr: {}",
561 args.join(" "),
562 String::from_utf8_lossy(&output.stdout),
563 String::from_utf8_lossy(&output.stderr),
564 );
565 }
566
567 fn init_repo(root: &Path) {
568 git(root, &["init", "--quiet", "--initial-branch=main"]);
569 git(root, &["config", "user.email", "test@example.invalid"]);
570 git(root, &["config", "user.name", "Test"]);
571 git(root, &["config", "commit.gpgsign", "false"]);
572 }
573
574 fn dry_run_with_filter(filter: GitFilter) -> RunArgs {
575 RunArgs {
576 targets: Vec::new(),
577 all: false,
578 workspace: true,
579 git_filter: Some(filter),
580 dry_run: true,
581 output_mode: None,
582 }
583 }
584
585 fn write_two_project_workspace(root: &Path) {
589 write_file(
590 &root.join(".haz/workspace.yml"),
591 "projects:\n - /lib_a\n - /lib_b\n",
592 );
593 write_file(
594 &root.join("lib_a/haz.yml"),
595 "name: lib_a\ntasks:\n build:\n command: [\"true\"]\n inputs:\n - src/main.rs\n",
596 );
597 write_file(
598 &root.join("lib_b/haz.yml"),
599 "name: lib_b\ntasks:\n build:\n command: [\"true\"]\n inputs:\n - src/main.rs\n",
600 );
601 write_file(&root.join("lib_a/src/main.rs"), "fn main() {}\n");
602 write_file(&root.join("lib_b/src/main.rs"), "fn main() {}\n");
603 }
604
605 #[test]
606 fn execute_git_changed_dry_run_succeeds_when_one_input_changes() {
607 let tempdir = tempfile::TempDir::new().unwrap();
615 let root = tempdir.path();
616 init_repo(root);
617 write_two_project_workspace(root);
618 git(root, &["add", "."]);
619 git(root, &["commit", "--quiet", "-m", "init"]);
620
621 write_file(&root.join("lib_a/src/main.rs"), "fn main() { /* v2 */ }\n");
623
624 let args = dry_run_with_filter(GitFilter::Changed);
625 let code = execute(&args, root).unwrap();
626 assert_eq!(code, 0);
627 }
628
629 #[test]
630 fn execute_git_changed_dry_run_succeeds_when_no_input_changes() {
631 let tempdir = tempfile::TempDir::new().unwrap();
635 let root = tempdir.path();
636 init_repo(root);
637 write_two_project_workspace(root);
638 git(root, &["add", "."]);
639 git(root, &["commit", "--quiet", "-m", "init"]);
640
641 let args = dry_run_with_filter(GitFilter::Changed);
642 let code = execute(&args, root).unwrap();
643 assert_eq!(code, 0);
644 }
645
646 #[test]
647 fn execute_git_changed_surfaces_not_a_repo_error() {
648 let tempdir = tempfile::TempDir::new().unwrap();
651 let root = tempdir.path();
652 write_two_project_workspace(root);
653
654 let args = dry_run_with_filter(GitFilter::Changed);
655 let err = execute(&args, root).unwrap_err();
656 let rendered = format!("{err:?}");
657 assert!(
658 rendered.contains("not inside a git repository")
659 || rendered.contains("git-changeset enumeration"),
660 "diagnostic should name the missing-repo failure: {rendered}",
661 );
662 }
663}