///|
pub async fn run_target(
  project : Project,
  target : String?,
  jobs? : Int = 1,
) -> RunSummary raise MoonforgeError {
  validate_project(project)
  let cache = load_cache(project)
  let (_order, levels, plans) = make_plan(project, cache, target)
  let plan_map : Map[String, TaskPlan] = Map([])
  for plan in plans {
    plan_map[plan.task.name] = plan
  }
  let executed = []
  let skipped = []
  let durations_ms : Map[String, Int] = Map([])
  let new_cache = { version: cache.version, tasks: cache.tasks }
  let jobs = if jobs <= 0 { 1 } else { jobs }
  for level in levels {
    for batch in chunk_names(level, jobs) {
      @async.with_task_group(async fn(group) {
        let pending = []
        for task_name in batch {
          let plan = plan_map.get(task_name).unwrap()
          if plan.should_run {
            pending.push(
              group.spawn(async fn() {
                let entry = run_one(project, plan)
                (task_name, entry)
              }),
            )
          } else {
            skipped.push(task_name)
          }
        }
        for task_handle in pending {
          let (task_name, entry) = task_handle.wait() catch {
            err => execution_error(@debug.to_string(err))
          }
          executed.push(task_name)
          durations_ms[task_name] = entry.duration_ms
          new_cache.tasks[task_name] = entry
        }
        group.return_immediately(())
      }) catch {
        err => execution_error(@debug.to_string(err))
      }
    }
  }
  save_cache(project, new_cache)
  { plans, executed, skipped, durations_ms }
}

///|
async fn run_one(
  project : Project,
  plan : TaskPlan,
) -> CacheEntry raise MoonforgeError {
  ensure_output_parents(project.root, plan.task.outputs)
  let started = @bench.monotonic_clock_start()
  let result = @subprocess.exec(plan.task.cmd, cwd=project.root, check=true) catch {
    @subprocess.CommandFailed(result) =>
      execution_error(
        "task '\{plan.task.name}' failed with exit code \{result.exit_code}\n\{result.stderr}",
      )
    @subprocess.SpawnFailed(message) =>
      execution_error("task '\{plan.task.name}' could not start: \{message}")
  }
  ignore(result)
  let duration_ms = (@bench.monotonic_clock_end(started) * 1000.0).to_int()
  let input_hash = fingerprint_paths(project.root, plan.task.inputs)
  let output_hash = fingerprint_paths(project.root, plan.task.outputs)
  { command_hash: plan.command_hash, input_hash, output_hash, duration_ms }
}

///|
fn chunk_names(items : Array[String], size : Int) -> Array[Array[String]] {
  if items.is_empty() {
    []
  } else {
    let chunks = []
    let current = []
    for item in items {
      current.push(item)
      if current.length() >= size {
        chunks.push(current.copy())
        current.clear()
      }
    }
    if !current.is_empty() {
      chunks.push(current)
    }
    chunks
  }
}