1use std::collections::{BTreeMap, BTreeSet};
19use std::io::{self, Write};
20use std::sync::{Arc, Mutex};
21use std::time::{Instant, SystemTime, UNIX_EPOCH};
22
23use haz_dag::edge::Edge;
24use haz_dag::graph::TaskGraph;
25use haz_domain::env::EnvVarName;
26use haz_domain::settings::output::OutputMode;
27use haz_domain::task_id::TaskId;
28use haz_exec::exit_code::{CancellationSignal, exit_code_for};
29use haz_exec::output::{BufferedOutputObserver, LiveOutputObserver};
30use haz_exec::presenter::TaskPresenter;
31use haz_exec::run_graph::{RunGraphOutcome, run_graph};
32use haz_exec::run_task::RunContext;
33use haz_exec::std_impl::StdProcessSpawner;
34use miette::{IntoDiagnostic, Result, WrapErr};
35use tokio_util::sync::CancellationToken;
36
37use haz_target::{TargetSpec, resolve_targets};
38use haz_vcs::workspace_changeset;
39
40use crate::cli::RunArgs;
41use crate::context::Context;
42use crate::output::color_choice::ColorChoice;
43use crate::output::epilogue::write_epilogue;
44use crate::output::presenter::CliPresenter;
45
46pub fn execute(args: &RunArgs, ctx: &Context) -> Result<i32> {
58 let bearing = ctx.bearing();
59
60 let changeset = match &args.git_filter {
65 Some(filter) => Some(
66 workspace_changeset(filter, ctx.workspace().root.as_path())
67 .into_diagnostic()
68 .wrap_err("git-changeset enumeration failed")?,
69 ),
70 None => None,
71 };
72 let spec = TargetSpec {
73 targets: args.targets.clone(),
74 scope: args.scope,
75 };
76 let targets = resolve_targets(
77 &spec,
78 bearing,
79 ctx.workspace(),
80 ctx.graph(),
81 changeset.as_ref(),
82 )
83 .into_diagnostic()
84 .wrap_err("target resolution failed")?;
85
86 if args.dry_run {
87 let mut out = io::stdout().lock();
88 return print_dry_run_plan(&targets, &mut out)
89 .into_diagnostic()
90 .wrap_err("failed to write dry-run plan")
91 .map(|()| 0);
92 }
93
94 let mode = args
101 .output_mode
102 .unwrap_or(ctx.workspace().settings.output.mode);
103 let exec_graph = restrict_graph_to_targets(ctx.graph(), &targets);
104 let color = ColorChoice::detect();
105 run_to_exit(ctx, exec_graph, mode, color)
106}
107
108fn print_dry_run_plan(targets: &BTreeSet<TaskId>, out: &mut impl Write) -> io::Result<()> {
114 if targets.is_empty() {
115 return writeln!(out, "would run 0 tasks.");
116 }
117 let suffix = if targets.len() == 1 { "" } else { "s" };
118 writeln!(out, "would run {} task{suffix}:", targets.len())?;
119 for id in targets {
120 writeln!(out, " - {id}")?;
121 }
122 Ok(())
123}
124
125fn restrict_graph_to_targets(graph: &TaskGraph, targets: &BTreeSet<TaskId>) -> TaskGraph {
135 let nodes = targets.clone();
136 let edges: BTreeSet<Edge> = graph
137 .edges
138 .iter()
139 .filter(|e| nodes.contains(&e.from) && nodes.contains(&e.to))
140 .cloned()
141 .collect();
142 TaskGraph { nodes, edges }
143}
144
145fn run_to_exit(
151 ctx: &Context,
152 graph: TaskGraph,
153 mode: OutputMode,
154 color: ColorChoice,
155) -> Result<i32> {
156 if graph.nodes.is_empty() {
157 return Ok(0);
158 }
159 let runtime = tokio::runtime::Builder::new_multi_thread()
160 .enable_all()
161 .build()
162 .into_diagnostic()
163 .wrap_err("failed to build the tokio runtime")?;
164 runtime.block_on(async move { dispatch_run_graph(ctx, graph, mode, color).await })
165}
166
167async fn dispatch_run_graph(
177 ctx: &Context,
178 graph: TaskGraph,
179 mode: OutputMode,
180 color: ColorChoice,
181) -> Result<i32> {
182 let cache = ctx.cache_writer();
183 let algo = cache.hash_algo();
184 let spawner = StdProcessSpawner::new();
185 let host_env = host_env_snapshot();
186 let cancel = CancellationToken::new();
187 let captured_signal: Arc<Mutex<Option<CancellationSignal>>> = Arc::new(Mutex::new(None));
188 let signal_handle = spawn_signal_listener(cancel.clone(), Arc::clone(&captured_signal));
189 let created_at_unix = SystemTime::now()
190 .duration_since(UNIX_EPOCH)
191 .map_or(0, |d| d.as_secs());
192 let presenter: Arc<dyn TaskPresenter> = Arc::new(CliPresenter::new(color));
193
194 let started_at = Instant::now();
199
200 let outcome = match mode {
206 OutputMode::Live => {
207 let observer = LiveOutputObserver::with_presenter(
208 io::stdout(),
209 io::stderr(),
210 Arc::clone(&presenter),
211 );
212 let run_ctx = RunContext {
213 fs: cache.fs(),
214 cache: &cache,
215 spawner: &spawner,
216 observer: &observer,
217 workspace: ctx.workspace(),
218 graph: &graph,
219 host_env: &host_env,
220 algo,
221 cancel: &cancel,
222 };
223 run_graph(&run_ctx, created_at_unix).await
224 }
225 OutputMode::Buffered => {
226 let observer = BufferedOutputObserver::with_presenter(
227 io::stdout(),
228 io::stderr(),
229 Arc::clone(&presenter),
230 );
231 let run_ctx = RunContext {
232 fs: cache.fs(),
233 cache: &cache,
234 spawner: &spawner,
235 observer: &observer,
236 workspace: ctx.workspace(),
237 graph: &graph,
238 host_env: &host_env,
239 algo,
240 cancel: &cancel,
241 };
242 run_graph(&run_ctx, created_at_unix).await
243 }
244 }
245 .map_err(|err| miette::miette!("scheduler failed: {err}"))?;
246
247 let elapsed = started_at.elapsed();
248
249 signal_handle.abort();
255
256 emit_run_epilogue(&outcome, elapsed, color);
257
258 let signal = *captured_signal
259 .lock()
260 .unwrap_or_else(std::sync::PoisonError::into_inner);
261 Ok(exit_code_for(&outcome, signal))
262}
263
264fn emit_run_epilogue(outcome: &RunGraphOutcome, elapsed: std::time::Duration, color: ColorChoice) {
271 let mut stderr = io::stderr().lock();
272 let _ = write_epilogue(&mut stderr, outcome, elapsed, color);
273}
274
275fn spawn_signal_listener(
290 cancel: CancellationToken,
291 captured: Arc<Mutex<Option<CancellationSignal>>>,
292) -> tokio::task::JoinHandle<()> {
293 tokio::spawn(listen_for_signals(cancel, captured))
294}
295
296#[cfg(unix)]
297async fn listen_for_signals(
298 cancel: CancellationToken,
299 captured: Arc<Mutex<Option<CancellationSignal>>>,
300) {
301 use tokio::signal::unix::{SignalKind, signal};
302 let (Ok(mut sigint), Ok(mut sigterm)) = (
308 signal(SignalKind::interrupt()),
309 signal(SignalKind::terminate()),
310 ) else {
311 return;
312 };
313 tokio::select! {
314 _ = sigint.recv() => {
315 record_signal(&captured, CancellationSignal::Interrupt);
316 cancel.cancel();
317 }
318 _ = sigterm.recv() => {
319 record_signal(&captured, CancellationSignal::Terminate);
320 cancel.cancel();
321 }
322 }
323}
324
325#[cfg(not(unix))]
326async fn listen_for_signals(
327 _cancel: CancellationToken,
328 _captured: Arc<Mutex<Option<CancellationSignal>>>,
329) {
330 }
334
335#[cfg(unix)]
336fn record_signal(captured: &Mutex<Option<CancellationSignal>>, sig: CancellationSignal) {
337 let mut slot = captured
338 .lock()
339 .unwrap_or_else(std::sync::PoisonError::into_inner);
340 *slot = Some(sig);
341}
342
343fn host_env_snapshot() -> BTreeMap<EnvVarName, String> {
350 let mut out = BTreeMap::new();
351 for (key, value) in std::env::vars() {
352 if let Ok(name) = EnvVarName::try_new(key) {
353 out.insert(name, value);
354 }
355 }
356 out
357}
358
359#[cfg(test)]
360mod tests {
361 use super::{execute, print_dry_run_plan};
362 use crate::cli::{RunArgs, Scope, TaskRef};
363 use crate::context::Context;
364 use haz_domain::name::{ProjectName, TaskName};
365 use haz_domain::task_id::TaskId;
366 use miette::Result;
367 use std::collections::BTreeSet;
368 use std::fs;
369 use std::path::Path;
370 use std::str::FromStr;
371
372 fn write_file(path: &Path, contents: &str) {
373 if let Some(parent) = path.parent() {
374 fs::create_dir_all(parent).unwrap();
375 }
376 fs::write(path, contents).unwrap();
377 }
378
379 fn run_run(args: &RunArgs, cwd: &Path) -> Result<i32> {
380 execute(args, &Context::open(cwd)?)
381 }
382
383 fn id(project: &str, task: &str) -> TaskId {
384 TaskId {
385 project: ProjectName::from_str(project).unwrap(),
386 task: TaskName::from_str(task).unwrap(),
387 }
388 }
389
390 fn dry_run_args() -> RunArgs {
391 RunArgs {
392 targets: Vec::new(),
393 scope: Scope::Default,
394 git_filter: None,
395 dry_run: true,
396 output_mode: None,
397 }
398 }
399
400 fn run_args_with_targets(targets: &[&str]) -> RunArgs {
401 RunArgs {
402 targets: targets.iter().map(|s| TaskRef::parse(s).unwrap()).collect(),
403 scope: Scope::Default,
404 git_filter: None,
405 dry_run: false,
406 output_mode: None,
407 }
408 }
409
410 #[test]
411 fn dry_run_plan_empty_targets() {
412 let targets: BTreeSet<TaskId> = BTreeSet::new();
413 let mut out = Vec::<u8>::new();
414 print_dry_run_plan(&targets, &mut out).unwrap();
415 assert_eq!(String::from_utf8(out).unwrap(), "would run 0 tasks.\n");
416 }
417
418 #[test]
419 fn dry_run_plan_single_target_singular_count() {
420 let mut targets = BTreeSet::new();
421 targets.insert(id("lib", "build"));
422 let mut out = Vec::<u8>::new();
423 print_dry_run_plan(&targets, &mut out).unwrap();
424 assert_eq!(
425 String::from_utf8(out).unwrap(),
426 "would run 1 task:\n - lib:build\n",
427 );
428 }
429
430 #[test]
431 fn dry_run_plan_multiple_targets_canonical_order() {
432 let mut targets = BTreeSet::new();
433 targets.insert(id("z", "build"));
434 targets.insert(id("a", "test"));
435 targets.insert(id("a", "build"));
436 let mut out = Vec::<u8>::new();
437 print_dry_run_plan(&targets, &mut out).unwrap();
438 assert_eq!(
439 String::from_utf8(out).unwrap(),
440 "would run 3 tasks:\n - a:build\n - a:test\n - z:build\n",
441 );
442 }
443
444 fn true_path() -> Option<&'static str> {
451 const CANDIDATES: &[&str] = &["/usr/bin/true", "/bin/true"];
452 CANDIDATES.iter().copied().find(|p| Path::new(p).exists())
453 }
454
455 #[test]
456 fn execute_dry_run_on_singleton_workspace_returns_zero() {
457 let tempdir = tempfile::TempDir::new().unwrap();
458 let root = tempdir.path();
459 fs::create_dir_all(root.join(".git")).unwrap();
460 write_file(
461 &root.join("haz.yml"),
462 "name: only_proj\ntasks:\n default:\n command: [\"true\"]\n",
463 );
464 let args = dry_run_args();
465 let code = run_run(&args, root).unwrap();
466 assert_eq!(code, 0);
467 }
468
469 #[test]
470 fn execute_runs_trivial_task_to_success() {
471 let Some(true_bin) = true_path() else {
472 eprintln!("skipping: no `true` binary at /usr/bin/true or /bin/true");
473 return;
474 };
475 let tempdir = tempfile::TempDir::new().unwrap();
476 let root = tempdir.path();
477 fs::create_dir_all(root.join(".git")).unwrap();
478 write_file(
479 &root.join("haz.yml"),
480 &format!("name: only_proj\ntasks:\n default:\n command: [\"{true_bin}\"]\n"),
481 );
482 let args = run_args_with_targets(&["~:default"]);
485 let code = run_run(&args, root).unwrap();
486 assert_eq!(code, 0);
487 }
488
489 #[test]
490 fn execute_runs_trivial_task_in_buffered_mode() {
491 let Some(true_bin) = true_path() else {
497 eprintln!("skipping: no `true` binary at /usr/bin/true or /bin/true");
498 return;
499 };
500 let tempdir = tempfile::TempDir::new().unwrap();
501 let root = tempdir.path();
502 fs::create_dir_all(root.join(".git")).unwrap();
503 write_file(
504 &root.join("haz.yml"),
505 &format!("name: only_proj\ntasks:\n default:\n command: [\"{true_bin}\"]\n"),
506 );
507 let mut args = run_args_with_targets(&["~:default"]);
508 args.output_mode = Some(crate::cli::OutputMode::Buffered);
509 let code = run_run(&args, root).unwrap();
510 assert_eq!(code, 0);
511 }
512
513 #[test]
514 fn exec_023_execute_default_target_without_bearing_errors() {
515 let tempdir = tempfile::TempDir::new().unwrap();
519 let root = tempdir.path();
520 write_file(&root.join(".haz/workspace.yml"), "projects:\n - /lib\n");
521 write_file(
522 &root.join("lib/haz.yml"),
523 "name: lib\ntasks:\n default:\n command: [\"true\"]\n",
524 );
525 let args = RunArgs {
526 targets: Vec::new(),
527 scope: Scope::Default,
528 git_filter: None,
529 dry_run: false,
530 output_mode: None,
531 };
532 let err = run_run(&args, root).unwrap_err();
533 assert!(
534 format!("{err:?}").contains("no bearing project"),
535 "diagnostic should mention the no-bearing case: {err:?}",
536 );
537 }
538
539 use crate::cli::GitFilter;
542 use std::process::Command;
543
544 fn git(cwd: &Path, args: &[&str]) {
545 let output = Command::new("git")
546 .arg("-C")
547 .arg(cwd)
548 .args(args)
549 .output()
550 .expect("git binary must be available on PATH for these tests");
551 assert!(
552 output.status.success(),
553 "git {} failed:\nstdout: {}\nstderr: {}",
554 args.join(" "),
555 String::from_utf8_lossy(&output.stdout),
556 String::from_utf8_lossy(&output.stderr),
557 );
558 }
559
560 fn init_repo(root: &Path) {
561 git(root, &["init", "--quiet", "--initial-branch=main"]);
562 git(root, &["config", "user.email", "test@example.invalid"]);
563 git(root, &["config", "user.name", "Test"]);
564 git(root, &["config", "commit.gpgsign", "false"]);
565 }
566
567 fn dry_run_with_filter(filter: GitFilter) -> RunArgs {
568 RunArgs {
569 targets: Vec::new(),
570 scope: Scope::Workspace,
571 git_filter: Some(filter),
572 dry_run: true,
573 output_mode: None,
574 }
575 }
576
577 fn write_two_project_workspace(root: &Path) {
581 write_file(
582 &root.join(".haz/workspace.yml"),
583 "projects:\n - /lib_a\n - /lib_b\n",
584 );
585 write_file(
586 &root.join("lib_a/haz.yml"),
587 "name: lib_a\ntasks:\n build:\n command: [\"true\"]\n inputs:\n - src/main.rs\n",
588 );
589 write_file(
590 &root.join("lib_b/haz.yml"),
591 "name: lib_b\ntasks:\n build:\n command: [\"true\"]\n inputs:\n - src/main.rs\n",
592 );
593 write_file(&root.join("lib_a/src/main.rs"), "fn main() {}\n");
594 write_file(&root.join("lib_b/src/main.rs"), "fn main() {}\n");
595 }
596
597 #[test]
598 fn execute_git_changed_dry_run_succeeds_when_one_input_changes() {
599 let tempdir = tempfile::TempDir::new().unwrap();
607 let root = tempdir.path();
608 init_repo(root);
609 write_two_project_workspace(root);
610 git(root, &["add", "."]);
611 git(root, &["commit", "--quiet", "-m", "init"]);
612
613 write_file(&root.join("lib_a/src/main.rs"), "fn main() { /* v2 */ }\n");
615
616 let args = dry_run_with_filter(GitFilter::Changed);
617 let code = run_run(&args, root).unwrap();
618 assert_eq!(code, 0);
619 }
620
621 #[test]
622 fn execute_git_changed_dry_run_succeeds_when_no_input_changes() {
623 let tempdir = tempfile::TempDir::new().unwrap();
627 let root = tempdir.path();
628 init_repo(root);
629 write_two_project_workspace(root);
630 git(root, &["add", "."]);
631 git(root, &["commit", "--quiet", "-m", "init"]);
632
633 let args = dry_run_with_filter(GitFilter::Changed);
634 let code = run_run(&args, root).unwrap();
635 assert_eq!(code, 0);
636 }
637
638 #[test]
639 fn execute_git_changed_surfaces_not_a_repo_error() {
640 let tempdir = tempfile::TempDir::new().unwrap();
643 let root = tempdir.path();
644 write_two_project_workspace(root);
645
646 let args = dry_run_with_filter(GitFilter::Changed);
647 let err = run_run(&args, root).unwrap_err();
648 let rendered = format!("{err:?}");
649 assert!(
650 rendered.contains("not inside a git repository")
651 || rendered.contains("git-changeset enumeration"),
652 "diagnostic should name the missing-repo failure: {rendered}",
653 );
654 }
655}