runner-run 0.18.1

Universal project task runner
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
//! Subcommand implementations: info, run, install, clean, list, completions.

use std::ffi::{OsStr, OsString};
use std::path::{Path, PathBuf};
use std::process::{Command, ExitStatus, Stdio};

use colored::Colorize;

use crate::resolver::ResolutionOverrides;
use crate::types::{DetectionWarning, ProjectContext};

mod clean;
mod completions;
mod config;
mod doctor;
mod info;
pub(crate) mod install;
mod list;
#[cfg(feature = "lsp")]
pub(crate) mod lsp;
#[cfg(feature = "man")]
mod man;
pub(crate) mod run;
#[cfg(feature = "schema")]
mod schema;
mod why;

pub(crate) use clean::clean;
pub(crate) use completions::{completions, parse_shell_arg};
pub(crate) use config::config;
pub(crate) use doctor::doctor;
pub(crate) use info::info;
pub(crate) use install::install;
pub(crate) use list::list;
#[cfg(feature = "man")]
pub(crate) use man::{write_man_pages, write_runner_page_to_stdout};
pub(crate) use run::run;
#[cfg(feature = "schema")]
pub(crate) use schema::config_schema;
#[cfg(feature = "schema")]
pub(crate) use schema::write_schema;
pub(crate) use why::why;

/// Shared setup for every spawned task: project-local `node_modules/.bin`
/// dirs on the child `PATH`, working directory, inherited stdio.
fn configure_command(command: &mut Command, dir: &Path, overrides: &ResolutionOverrides) {
    prepend_node_bin_path(command, dir);
    command
        .current_dir(dir)
        .stdin(Stdio::inherit())
        .stdout(Stdio::inherit())
        .stderr(Stdio::inherit());
    // Mark children (and, by env inheritance, all descendants) when this
    // runner opens a GHA group around them, so a nested `runner`/`run`
    // suppresses its own group — GHA groups don't nest. When we're already
    // nested the marker is in our inherited env, so children get it for free.
    if emits_group(overrides) {
        command.env(GROUP_ACTIVE_ENV, "1");
    }
}

/// Every existing `node_modules/.bin` from `dir` up to the filesystem
/// root, nearest first — the same set (and order) `npm run` exposes to
/// `package.json` scripts. Levels without an installed `.bin` are
/// skipped, so non-Node projects collect nothing and the whole
/// augmentation becomes a no-op.
fn node_bin_dirs(dir: &Path) -> Vec<PathBuf> {
    dir.ancestors()
        .map(|ancestor| ancestor.join("node_modules").join(".bin"))
        .filter(|bin| bin.is_dir())
        .collect()
}

/// Prepend the project's `node_modules/.bin` dirs to the child's `PATH`.
///
/// Node PMs inject this for `package.json` scripts, but runner spawns
/// tasks directly (`turbo run <task>`, the bare-binary fallback), so a
/// devDependency-only binary would die with ENOENT. The OS honors a
/// `PATH` set on the [`Command`] itself, so prepending fixes both the
/// spawn and anything the task launches in turn.
///
/// Entries are not deduplicated against the parent `PATH`: prepending
/// unconditionally gives local bins priority over global installs.
fn prepend_node_bin_path(command: &mut Command, dir: &Path) {
    let bins = node_bin_dirs(dir);
    if bins.is_empty() {
        return;
    }
    #[cfg(windows)]
    resolve_program_in_bins(command, &bins);
    if let Some(path) = prepended_path(&bins, std::env::var_os("PATH").as_deref()) {
        command.env("PATH", path);
    }
}

/// `bins` followed by the entries of `parent`, joined with the platform
/// separator. `None` when joining fails (a bin dir embeds the separator
/// itself) — the caller leaves `PATH` untouched rather than corrupt it.
fn prepended_path(bins: &[PathBuf], parent: Option<&OsStr>) -> Option<OsString> {
    let inherited = parent.map(std::env::split_paths).into_iter().flatten();
    std::env::join_paths(bins.iter().cloned().chain(inherited)).ok()
}

/// Re-resolve a bare program name against the project's bin dirs.
///
/// [`crate::tool::program::command`] resolves bare names against the
/// parent `PATH`×`PATHEXT` before the bin dirs are prepended, and the
/// std child-`PATH` search only appends `.exe` at spawn time — so a
/// `turbo.cmd`/`.ps1` shim living only under `node_modules/.bin` would
/// fail to spawn. When a bare name resolves inside `bins`, rebuild the
/// command around the absolute shim path, preserving args and env.
/// Absolute/relative programs and parent-`PATH` hits are left alone
/// (so a global install still shadows a local one here, unlike Unix).
#[cfg(windows)]
fn resolve_program_in_bins(command: &mut Command, bins: &[PathBuf]) {
    let program = command.get_program().to_os_string();
    let Some(name) = program.to_str() else { return };
    if Path::new(name).components().count() > 1 {
        return;
    }
    let Ok(joined) = std::env::join_paths(bins.iter().cloned()) else {
        return;
    };
    let pathext =
        std::env::var_os("PATHEXT").unwrap_or_else(|| crate::tool::program::DEFAULT_PATHEXT.into());
    let Some(resolved) = crate::tool::program::resolve_windows(name, &joined, &pathext) else {
        return;
    };

    let args: Vec<OsString> = command.get_args().map(ToOwned::to_owned).collect();
    let envs: Vec<(OsString, Option<OsString>)> = command
        .get_envs()
        .map(|(key, value)| (key.to_owned(), value.map(ToOwned::to_owned)))
        .collect();
    let cwd = command.get_current_dir().map(Path::to_path_buf);

    let mut next = Command::new(resolved);
    next.args(args);
    for (key, value) in envs {
        match value {
            Some(value) => {
                next.env(key, value);
            }
            None => {
                next.env_remove(key);
            }
        }
    }
    if let Some(cwd) = cwd {
        next.current_dir(cwd);
    }
    *command = next;
}

pub(crate) fn exit_code(status: ExitStatus) -> i32 {
    #[cfg(unix)]
    {
        use std::os::unix::process::ExitStatusExt as _;

        if let Some(code) = status.code() {
            return code;
        }
        if let Some(signal) = status.signal() {
            return 128 + signal;
        }
    }

    status.code().unwrap_or(1)
}

/// Env marker a runner sets on its children when it is in GitHub Actions
/// grouping mode ([`emits_group`]), so a nested `runner`/`run` (e.g. invoked
/// through an `npm` script) detects it and stays silent instead of emitting a
/// second `::group::` that would corrupt the parent's fold. Inherited
/// transitively through intermediate processes; read into
/// [`ResolutionOverrides::parent_group_open`].
///
/// The contract is "a parent is *collecting* your output — don't open your own
/// group", which is slightly broader than "a literal `::group::` is open right
/// now": it is also set in parallel-*streaming* mode, where the parent muxes
/// child output behind a `[task] ` prefix instead of a group. Suppressing
/// nested grouping is correct in every case — a nested group would otherwise
/// either nest-and-corrupt (grouped) or render as inert prefixed text
/// (streaming).
pub(crate) const GROUP_ACTIVE_ENV: &str = "RUNNER_GROUP_ACTIVE";

/// Whether to wrap a run in a GitHub Actions log group: only when the user
/// hasn't opted out (`[github].group_output`) *and* we're under GitHub
/// Actions, so `::group::` markers never leak into a normal terminal.
const fn should_group(group_output: bool, under_github_actions: bool) -> bool {
    group_output && under_github_actions
}

/// Pure core of [`emits_group`]: grouping on, under Actions, and not already
/// nested. Split out from the env read so the nesting gate is unit-testable
/// without a live `GITHUB_ACTIONS` environment.
const fn group_emission(
    group_output: bool,
    under_github_actions: bool,
    parent_group_open: bool,
) -> bool {
    should_group(group_output, under_github_actions) && !parent_group_open
}

/// Whether *this* runner emits a GitHub Actions group around its children:
/// grouping is on, we're under Actions, and a parent runner hasn't already
/// opened one ([`ResolutionOverrides::parent_group_open`]). When true, the
/// group-opening sites fire AND children are marked with [`GROUP_ACTIVE_ENV`]
/// so a nested runner suppresses its own groups. When false, no group is
/// opened (nested output flows into the parent's group, or grouping is off).
pub(crate) fn emits_group(overrides: &ResolutionOverrides) -> bool {
    group_emission(
        overrides.group_output,
        actions_rs::env::is_github_actions(),
        overrides.parent_group_open,
    )
}

/// Open a collapsible GitHub Actions log group titled `runner: {name}` when
/// grouping is enabled (see [`should_group`]).
///
/// The returned [`actions_rs::log::GroupGuard`] emits `::endgroup::` when it
/// is dropped — including on the `?` error path and on panic — so callers
/// just bind it for the duration of the run. Returns `None` (emitting
/// nothing) when grouping is off, which lets callers hold it unconditionally.
fn task_group(overrides: &ResolutionOverrides, name: &str) -> Option<actions_rs::log::GroupGuard> {
    emits_group(overrides).then(|| actions_rs::log::group_guard(format!("runner: {name}")))
}

/// Optional warning collector. `None` means "emit warnings to stderr
/// directly" (single-task path). `Some(set)` means "stash for deduped
/// emission later" (chain dispatch — chain executor emits the deduped
/// set once at the end).
pub(crate) type WarningSink<'a> = Option<&'a mut std::collections::HashSet<DetectionWarning>>;

fn print_warnings(ctx: &ProjectContext, overrides: &ResolutionOverrides, sink: WarningSink<'_>) {
    print_warning_slice(&ctx.warnings, overrides, sink);
}

fn print_warning_slice(
    warnings: &[DetectionWarning],
    overrides: &ResolutionOverrides,
    sink: WarningSink<'_>,
) {
    if overrides.no_warnings {
        return;
    }
    if let Some(set) = sink {
        for warning in warnings {
            set.insert(warning.clone());
        }
        return;
    }
    for warning in warnings {
        eprintln!("{} {warning}", "warn:".yellow().bold());
    }
}

/// Emit a previously-collected warning set to stderr. Used by the chain
/// executor after all per-task resolutions have populated the sink.
///
/// Sorted by `Display` form before emission so output is stable across
/// runs — `HashSet` iteration order is unspecified, which made the
/// warning block jump around between invocations of the same chain.
pub(crate) fn emit_collected_warnings(
    warnings: &std::collections::HashSet<DetectionWarning>,
    overrides: &ResolutionOverrides,
) {
    if overrides.no_warnings {
        return;
    }
    let mut sorted: Vec<(String, &DetectionWarning)> =
        warnings.iter().map(|w| (w.to_string(), w)).collect();
    sorted.sort_by(|a, b| a.0.cmp(&b.0));
    for (_, warning) in sorted {
        eprintln!("{} {warning}", "warn:".yellow().bold());
    }
}

/// Render a [`std::time::Duration`] as a compact, human-readable string for
/// per-task chain timing: sub-second values as whole milliseconds (`342ms`),
/// sub-minute values as seconds with one decimal (`1.2s`), and anything
/// longer as minutes plus zero-padded seconds (`1m 04s`).
pub(crate) fn format_duration(elapsed: std::time::Duration) -> String {
    let millis = elapsed.as_millis();
    if millis < 1000 {
        return format!("{millis}ms");
    }
    // Pick the band from the same rounded tenth-of-a-second that we actually
    // print. Deciding on the truncated whole-second value while rendering a
    // rounded one lets a duration in [59.95s, 60.0s) stay in the seconds band
    // yet round up to a bogus "60.0s"; rounding here promotes it to "1m 00s".
    // Half-up rounding via integer math also keeps this free of the
    // float-to-int cast lints a `(secs_f64 * 10.0).round() as u64` would trip.
    let tenths = (millis + 50) / 100;
    if tenths >= 600 {
        // Round straight from millis to whole seconds (half-up) in one step.
        // The band decision stays on the rounded tenth so a duration in
        // [59.95s, 60.0s) still promotes to "1m 00s"; for any millis in this
        // band `(millis + 500) / 1000 >= 60`, so no "0m"/"60s" can leak.
        // Rounding the already-rounded `tenths` again (`(tenths + 5) / 10`)
        // would cascade two half-ups and shift the seconds boundary from 0.50
        // to 0.45, over-reporting any [0.45s, 0.50s) fraction by a whole
        // second (e.g. 60_450ms would print "1m 01s" instead of "1m 00s").
        let secs = (millis + 500) / 1000;
        return format!("{}m {:02}s", secs / 60, secs % 60);
    }
    format!("{}.{}s", tenths / 10, tenths % 10)
}

/// One-line completion summary shared by every chain output mode, e.g.
/// `finished in 1.2s (exit 0)`. Sequential and live (streaming) parallel
/// output prepend the task name via [`emit_task_timing`]; grouped parallel
/// output folds this summary into each task's block footer.
pub(crate) fn task_timing_summary(elapsed: std::time::Duration, code: i32) -> String {
    format!("finished in {} (exit {code})", format_duration(elapsed))
}

/// Whether per-task chain timing is shown. Timing is diagnostic meta-output,
/// so it follows the same mute switches as the dispatch arrow and warnings:
/// `--quiet` / `RUNNER_QUIET` and `--no-warnings` / `RUNNER_NO_WARNINGS` each
/// suppress it.
pub(crate) const fn timing_enabled(overrides: &ResolutionOverrides) -> bool {
    !overrides.quiet && !overrides.no_warnings
}

/// Print a per-task timing line to stderr for the sequential and live
/// (streaming) parallel paths, e.g. `· build finished in 1.2s (exit 0)`.
/// Mirrors the dimmed `·` meta-line style used by the `--explain` trace and
/// is suppressed by [`timing_enabled`]. Grouped parallel output instead folds
/// the summary into each task's block footer (see the chain executor).
pub(crate) fn emit_task_timing(
    overrides: &ResolutionOverrides,
    name: &str,
    elapsed: std::time::Duration,
    code: i32,
) {
    if !timing_enabled(overrides) {
        return;
    }
    eprintln!(
        "{} {} {}",
        "·".dimmed(),
        name.bold(),
        task_timing_summary(elapsed, code).dimmed(),
    );
}

#[cfg(test)]
mod tests {
    use std::ffi::OsString;
    use std::fs;
    use std::path::PathBuf;
    use std::process::Command;

    use super::{configure_command, group_emission, node_bin_dirs, prepended_path};
    use crate::resolver::ResolutionOverrides;
    use crate::tool::test_support::TempDir;

    #[test]
    fn group_emission_gates_on_nesting() {
        // Exercise the pure core directly (no live GITHUB_ACTIONS needed): the
        // nested flag is the load-bearing gate. Holding grouping on + under
        // Actions, flipping parent_group_open flips the decision — proving the
        // nesting suppression actually fires (a tautological test that only
        // checked the env-false path would pass even without the gate).
        assert!(
            group_emission(true, true, false),
            "grouping on + under Actions + not nested → emit"
        );
        assert!(
            !group_emission(true, true, true),
            "...but a parent's open group suppresses it (GHA groups don't nest)"
        );
        // The other two factors still gate independently.
        assert!(!group_emission(false, true, false), "grouping opted out");
        assert!(!group_emission(true, false, false), "not under Actions");
    }

    #[test]
    fn configure_command_sets_current_dir() {
        let dir = std::env::temp_dir();
        let mut command = Command::new("runner-test-command");

        configure_command(&mut command, dir.as_path(), &ResolutionOverrides::default());

        assert_eq!(command.get_current_dir(), Some(dir.as_path()));
    }

    #[test]
    fn node_bin_dirs_walks_ancestors_nearest_first() {
        let dir = TempDir::new("node-bin-walk");
        let member = dir.path().join("apps").join("web");
        let member_bin = member.join("node_modules").join(".bin");
        let root_bin = dir.path().join("node_modules").join(".bin");
        fs::create_dir_all(&member_bin).expect("member bin should be created");
        fs::create_dir_all(&root_bin).expect("root bin should be created");

        let bins = node_bin_dirs(&member);

        // `apps/` has no node_modules — levels without an installed
        // `.bin` are skipped, not invented. Entries past the temp root
        // (a stray `/tmp/node_modules`) are out of our control, so only
        // pin the leading order and that nothing else came from inside
        // the fixture.
        assert_eq!(&bins[..2], [member_bin, root_bin]);
        assert!(bins.iter().skip(2).all(|bin| !bin.starts_with(dir.path())));
    }

    #[test]
    fn node_bin_dirs_requires_bin_subdir() {
        // A `node_modules` without `.bin` (no dependencies expose
        // binaries) must not contribute a phantom PATH entry.
        let dir = TempDir::new("node-bin-missing");
        fs::create_dir_all(dir.path().join("node_modules")).expect("dir should be created");

        let bins = node_bin_dirs(dir.path());

        assert!(bins.iter().all(|bin| !bin.starts_with(dir.path())));
    }

    #[test]
    fn prepended_path_orders_bins_before_parent() {
        let bins = vec![
            PathBuf::from("/repo/apps/web/node_modules/.bin"),
            PathBuf::from("/repo/node_modules/.bin"),
        ];
        let parent = OsString::from("/usr/bin");

        let joined = prepended_path(&bins, Some(parent.as_os_str()))
            .expect("plain paths should always join");

        let parts: Vec<PathBuf> = std::env::split_paths(&joined).collect();
        assert_eq!(
            parts,
            [
                PathBuf::from("/repo/apps/web/node_modules/.bin"),
                PathBuf::from("/repo/node_modules/.bin"),
                PathBuf::from("/usr/bin"),
            ],
        );
    }

    #[test]
    fn prepended_path_handles_missing_parent() {
        let bins = vec![PathBuf::from("/repo/node_modules/.bin")];

        let joined = prepended_path(&bins, None).expect("plain paths should always join");

        let parts: Vec<PathBuf> = std::env::split_paths(&joined).collect();
        assert_eq!(parts, [PathBuf::from("/repo/node_modules/.bin")]);
    }

    #[cfg(unix)]
    #[test]
    fn spawn_resolves_dev_dependency_binary_via_child_path() {
        use std::os::unix::fs::PermissionsExt as _;

        // End-to-end pin for the mechanism the PATH fix relies on: the
        // OS-level bare-name lookup must honor the PATH set on the
        // child Command (std documents this on `Command::new`). A
        // devDependency-style shim that exists only under the project's
        // `node_modules/.bin` has to spawn — this is exactly the
        // "turbo.json task dies with ENOENT because turbo is only a
        // devDependency" report.
        let dir = TempDir::new("child-path-spawn");
        let bin = dir.path().join("node_modules").join(".bin");
        fs::create_dir_all(&bin).expect("bin dir should be created");
        let shim = bin.join("runner-test-shim");
        fs::write(&shim, "#!/bin/sh\nexit 42\n").expect("shim should be written");
        fs::set_permissions(&shim, fs::Permissions::from_mode(0o755))
            .expect("shim should be marked executable");

        let mut command = Command::new("runner-test-shim");
        configure_command(&mut command, dir.path(), &ResolutionOverrides::default());

        let status = command
            .status()
            .expect("shim should spawn via the child PATH");
        assert_eq!(status.code(), Some(42));
    }

    #[cfg(windows)]
    #[test]
    fn configure_command_resolves_cmd_shim_from_bin_dir() {
        use std::ffi::OsStr;

        // `CreateProcessW` never consults PATHEXT and the std child-PATH
        // search only appends `.exe`, so a bare name backed only by a
        // `.cmd` shim in node_modules/.bin must be rebuilt around the
        // absolute shim path — with args and env tweaks surviving.
        let dir = TempDir::new("win-bin-shim");
        let bin = dir.path().join("node_modules").join(".bin");
        fs::create_dir_all(&bin).expect("bin dir should be created");
        let shim = bin.join("runner-test-shim.cmd");
        fs::write(&shim, "@echo off\r\n").expect("shim should be written");

        let mut command = Command::new("runner-test-shim");
        command.arg("run").env("RUNNER_TEST_MARKER", "1");
        configure_command(&mut command, dir.path(), &ResolutionOverrides::default());

        assert_eq!(PathBuf::from(command.get_program()), shim);
        let args: Vec<_> = command.get_args().collect();
        assert_eq!(args, [OsStr::new("run")]);
        assert!(
            command
                .get_envs()
                .any(|(key, value)| key == "RUNNER_TEST_MARKER" && value == Some(OsStr::new("1"))),
        );
    }

    #[test]
    fn no_warnings_suppresses_emission() {
        use super::print_warning_slice;
        use crate::resolver::ResolutionOverrides;
        use crate::types::{DetectionWarning, PackageManager};

        // Smoke: print_warning_slice with no_warnings=true must
        // short-circuit before the eprintln. The test asserts no
        // panic / no observable side effects; capturing stderr in
        // cargo test is fiddly and not worth a fixture.
        let warnings = vec![DetectionWarning::PmMismatch {
            declared: PackageManager::Pnpm,
            field: "packageManager",
            lockfile: PackageManager::Yarn,
        }];
        let overrides = ResolutionOverrides {
            no_warnings: true,
            ..ResolutionOverrides::default()
        };
        print_warning_slice(&warnings, &overrides, None);
    }

    #[cfg(unix)]
    #[test]
    fn exit_code_preserves_signal_status() {
        use std::os::unix::process::ExitStatusExt as _;

        use super::exit_code;

        assert_eq!(exit_code(std::process::ExitStatus::from_raw(5 << 8)), 5);
        assert_eq!(exit_code(std::process::ExitStatus::from_raw(2)), 130);
    }

    #[test]
    fn format_duration_uses_millis_below_one_second() {
        use std::time::Duration;

        use super::format_duration;

        assert_eq!(format_duration(Duration::from_millis(0)), "0ms");
        assert_eq!(format_duration(Duration::from_millis(5)), "5ms");
        assert_eq!(format_duration(Duration::from_millis(342)), "342ms");
        // 999ms stays in the millisecond band; 1000ms crosses into seconds.
        assert_eq!(format_duration(Duration::from_millis(999)), "999ms");
    }

    #[test]
    fn format_duration_uses_seconds_with_one_decimal_under_a_minute() {
        use std::time::Duration;

        use super::format_duration;

        assert_eq!(format_duration(Duration::from_secs(1)), "1.0s");
        assert_eq!(format_duration(Duration::from_millis(1234)), "1.2s");
        assert_eq!(format_duration(Duration::from_millis(4200)), "4.2s");
        assert_eq!(format_duration(Duration::from_millis(59_400)), "59.4s");
    }

    #[test]
    fn format_duration_uses_minutes_and_padded_seconds_at_a_minute() {
        use std::time::Duration;

        use super::format_duration;

        // 60s is the boundary into the minute band; sub-minute seconds are
        // zero-padded to two digits so columns stay aligned.
        assert_eq!(format_duration(Duration::from_mins(1)), "1m 00s");
        assert_eq!(format_duration(Duration::from_secs(64)), "1m 04s");
        assert_eq!(format_duration(Duration::from_secs(125)), "2m 05s");
        assert_eq!(format_duration(Duration::from_secs(3661)), "61m 01s");
    }

    #[test]
    fn format_duration_rounds_seconds_to_nearest_inside_minute_band() {
        use std::time::Duration;

        use super::format_duration;

        // Non-integer seconds inside the minute band must round half-up to the
        // nearest whole second, not floor. Flooring would under-report by up to
        // ~0.95s near band edges (the bug these inputs guard against).
        assert_eq!(format_duration(Duration::from_millis(60_900)), "1m 01s");
        assert_eq!(format_duration(Duration::from_millis(90_700)), "1m 31s");
        assert_eq!(format_duration(Duration::from_millis(119_940)), "2m 00s");
        // Well below the half-second boundary (0.449s) rounds down.
        assert_eq!(format_duration(Duration::from_millis(90_449)), "1m 30s");
        // Just inside [0.45s, 0.50s): rounding must stay half-up (boundary at
        // 0.50, not 0.45). A cascaded double-rounding would bump these up a
        // whole second (0.45 -> 0.5 tenth -> 1 second), so guard the window.
        assert_eq!(format_duration(Duration::from_millis(60_450)), "1m 00s");
        assert_eq!(format_duration(Duration::from_millis(60_499)), "1m 00s");
        assert_eq!(format_duration(Duration::from_millis(90_450)), "1m 30s");
        // The double-round bug visibly flipped the minute here (1m 59s -> 2m 00s).
        assert_eq!(format_duration(Duration::from_millis(119_450)), "1m 59s");
        // The true half-second boundary rounds up.
        assert_eq!(format_duration(Duration::from_millis(60_500)), "1m 01s");
    }

    #[test]
    fn format_duration_rounds_into_minute_band_near_sixty_seconds() {
        use std::time::Duration;

        use super::format_duration;

        // Durations in [59.95s, 60.0s) have as_secs() == 59 but round up to
        // 60.0s. The band must be chosen on the rounded value, so these promote
        // into the minute band instead of printing a contract-violating "60.0s".
        for millis in [59_950, 59_990, 59_999] {
            let rendered = format_duration(Duration::from_millis(millis));
            assert_eq!(rendered, "1m 00s", "{millis}ms should round into minutes");
            assert_ne!(rendered, "60.0s", "{millis}ms must never print as 60.0s");
        }
        // The tenth just below the rounding boundary stays in the seconds band.
        assert_eq!(format_duration(Duration::from_millis(59_940)), "59.9s");
    }

    #[test]
    fn task_timing_summary_includes_duration_and_exit_code() {
        use std::time::Duration;

        use super::task_timing_summary;

        assert_eq!(
            task_timing_summary(Duration::from_millis(1500), 0),
            "finished in 1.5s (exit 0)"
        );
        assert_eq!(
            task_timing_summary(Duration::from_millis(200), 7),
            "finished in 200ms (exit 7)"
        );
    }

    #[test]
    fn timing_enabled_respects_quiet_and_no_warnings() {
        use super::timing_enabled;
        use crate::resolver::ResolutionOverrides;

        assert!(timing_enabled(&ResolutionOverrides::default()));
        assert!(!timing_enabled(&ResolutionOverrides {
            quiet: true,
            ..ResolutionOverrides::default()
        }));
        assert!(!timing_enabled(&ResolutionOverrides {
            no_warnings: true,
            ..ResolutionOverrides::default()
        }));
    }

    #[test]
    fn should_group_requires_both_opt_in_and_github_actions() {
        use super::should_group;

        assert!(should_group(true, true));
        assert!(!should_group(false, true), "config opt-out wins");
        assert!(
            !should_group(true, false),
            "no grouping outside GitHub Actions"
        );
        assert!(!should_group(false, false));
    }
}