runner-run 0.17.0

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
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
//! Local-file execution for `run <path>`.
//!
//! A token that points at a local file should be *run as that file* — never
//! handed to a package manager's package-exec primitive (`bunx`/`npx`/
//! `pnpm dlx`/`deno x`/`uvx`), which would resolve a local path as a remote
//! package spec and fail with a registry 404 or a `git clone` error.
//!
//! [`try_path_token`] runs at the top of [`super::dispatch::resolve_dispatch`]
//! (an explicit-prefix path, before task lookup), and [`try_bare_file`] runs
//! after a task miss but before the PM-exec fallback (a bare or relative
//! token). Each classifies a path-like token into one of four outcomes:
//!
//! 1. **Directly executable file** (a native binary, or a script whose `#!`
//!    line the kernel can honor) → spawned directly (`Command::new(path)`).
//!    A recognized *source* file that merely carries the exec bit but has no
//!    `#!` line is **not** run this way — `execve` cannot run shebang-less
//!    text (it returns `ENOEXEC`) — it falls to outcome 3 instead.
//! 2. **Non-executable file with a `#!` shebang** → the interpreter is
//!    parsed (including `#!/usr/bin/env -S <interp> <args>`) and the file is
//!    run through it.
//! 3. **Recognized source extension** → run via the project runtime
//!    (`.ts`/`.js`/… → bun / `deno run` / node; `.py` → `uv run` / python;
//!    `.go` → `go run`), *not* package-exec. Reached even with the exec bit
//!    set, since a shebang-less source file is not a native executable.
//! 4. **Otherwise** → a clear, actionable error.

use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;

use anyhow::{Result, bail};

use crate::resolver::ResolutionOverrides;
use crate::tool;
use crate::types::{Ecosystem, PackageManager, ProjectContext};

/// A resolved local-file dispatch: the spawnable command plus the short
/// label shown in the `→` dispatch-arrow trace.
#[derive(Debug)]
pub(super) struct LocalDispatch {
    pub(super) label: String,
    pub(super) command: Command,
}

/// Try to interpret a `token` carrying an explicit local-path prefix
/// (`./`, `../`, `/`, `\`, `~`, or a Windows drive root) as a local file to
/// run. Used at the *top* of dispatch, before task lookup, so an explicit
/// path always wins over a same-named task.
///
/// Only an explicit prefix outranks a task here. A separator-bearing but
/// *relative* token such as `bin/tool` is left for the after-task-miss
/// [`try_bare_file`] fallback, so a matching task (e.g. a `make bin/tool`
/// target) wins first and a built artifact on disk cannot silently shadow it.
///
/// Returns:
/// - `Ok(None)` — `token` has no local prefix (a bare name, a relative
///   `bin/tool`, an existing directory, or a remote spec like `@scope/pkg`
///   or `github.com/owner/tool`); the caller continues normal task /
///   PM-exec resolution.
/// - `Ok(Some(_))` — `token` resolves to a runnable file; the caller spawns
///   the returned command instead of touching any package manager.
/// - `Err(_)` — `token` is unambiguously a local path that cannot be run
///   (a missing file behind an explicit `./`/`/`/`~` prefix, or a file of
///   unrecognized type); surfaced as a clear error rather than a 404.
pub(super) fn try_path_token(
    ctx: &ProjectContext,
    overrides: &ResolutionOverrides,
    token: &str,
    args: &[String],
) -> Result<Option<LocalDispatch>> {
    if !has_local_prefix(token) {
        return Ok(None);
    }
    let path = resolve_path(&ctx.root, token);
    dispatch_for_path(ctx, overrides, token, &path, args)
}

/// Try to interpret a `token` *without* an explicit local-path prefix — a
/// bare name (`main.ts`) or a relative path bearing a separator
/// (`bin/tool`) — as a runnable file under the project root (`ctx.root`).
/// Used as a
/// fallback *after* task lookup misses but before the PM-exec fallback, so a
/// matching task always wins first (a bare name or a `make bin/tool` target
/// never collides) yet a local script such as `main.ts` still runs instead
/// of being mistaken for a remote package.
///
/// Prefix-bearing paths (`./x`, `/x`, `~/x`, `C:\x`) are the pre-task
/// [`try_path_token`] branch's job and are declined here. Once the token
/// names an existing regular file it is treated as a local file: a runnable
/// one is spawned, and an unrunnable one surfaces a clear error rather than
/// leaking into PM-exec. Only a token that names *nothing* on disk (or a
/// directory) is left to the PM-exec fallback (a real package spec).
///
/// Returns:
/// - `Ok(None)` — `token` carries a local prefix, names nothing on disk, or
///   resolves to a non-file (a directory like `./cmd/foo`); the caller
///   continues to the PM-exec fallback.
/// - `Ok(Some(_))` — `token` resolves to a runnable file; the caller spawns it.
/// - `Err(_)` — `token` resolves to an existing regular file the chosen runtime
///   can't run (an unsupported type, no shebang and not executable, or a `.tsx`
///   in a node-only project). Surfaces the same clear error the explicit `./`
///   form raises, never a swallowed `None` that mis-routes an existing file
///   into `pnpm exec`/`npx` and a registry 404 (#69).
pub(super) fn try_bare_file(
    ctx: &ProjectContext,
    overrides: &ResolutionOverrides,
    token: &str,
    args: &[String],
) -> Result<Option<LocalDispatch>> {
    if has_local_prefix(token) {
        return Ok(None);
    }
    bare_file_in(ctx, overrides, &ctx.root, token, args)
}

/// Resolve a bare `token` against an explicit `base` directory. Split from
/// [`try_bare_file`] so the lookup can be unit-tested against a temp
/// directory without mutating the process working directory.
fn bare_file_in(
    ctx: &ProjectContext,
    overrides: &ResolutionOverrides,
    base: &Path,
    token: &str,
    args: &[String],
) -> Result<Option<LocalDispatch>> {
    let path = base.join(token);
    let Ok(meta) = fs::metadata(&path) else {
        return Ok(None);
    };
    if !meta.is_file() {
        return Ok(None);
    }
    // The token names an existing regular file, so it is a local file: run it,
    // and if it can't be run (unsupported type, no shebang and not executable,
    // or a `.tsx` resolved to Node) surface that as the clear local-file error —
    // exactly what the explicit `./file` form does (`dispatch_for_path` ->
    // `build_command?`). Never swallow it into a `None` that falls through to
    // `pnpm exec`/`npx <file>` and a registry 404 (#69). Missing tokens and
    // directories (returned `Ok(None)` above) still fall through to PM-exec.
    let (label, command) = build_command(ctx, overrides, &path, &meta, args)?;
    Ok(Some(LocalDispatch { label, command }))
}

/// Resolve an already-path-like `token` (canonicalized to `path`) into a
/// dispatch. Split from [`try_path_token`] so the filesystem-dependent logic
/// can be unit-tested against temp files with absolute paths.
fn dispatch_for_path(
    ctx: &ProjectContext,
    overrides: &ResolutionOverrides,
    token: &str,
    path: &Path,
    args: &[String],
) -> Result<Option<LocalDispatch>> {
    let Ok(meta) = fs::metadata(path) else {
        // The path resolves to nothing on disk. An explicit local path
        // (`./x`, `../x`, `/x`, `~/x`, `C:\x`) clearly means a file that
        // is not there — report it plainly. Anything else (`@scope/pkg`,
        // `github.com/owner/tool`) falls through so the PM-exec / Go
        // import-path fallback can treat it as a remote spec.
        if has_local_prefix(token) {
            bail!("no such file: {token}");
        }
        return Ok(None);
    };

    // Directories are never scripts: leave `./cmd/foo` to downstream
    // handlers such as `go run ./cmd/foo`. Other non-regular files (FIFOs,
    // sockets, devices) are not runnable here either.
    if !meta.is_file() {
        return Ok(None);
    }

    let (label, command) = build_command(ctx, overrides, path, &meta, args)?;
    Ok(Some(LocalDispatch { label, command }))
}

/// Build the spawn command for an existing regular file at `path`.
fn build_command(
    ctx: &ProjectContext,
    overrides: &ResolutionOverrides,
    path: &Path,
    meta: &fs::Metadata,
    args: &[String],
) -> Result<(String, Command)> {
    // Read any `#!` shebang once up front: it is what disambiguates an
    // exec-bit-carrying *source* file (a `+x` `.ts` with no shebang is still
    // text that `execve` cannot run) from a genuine self-executable script
    // or native binary. An execute-only file (mode 0111) cannot be opened for
    // read, so the probe simply yields `None` (no shebang to honor) rather
    // than aborting — the kernel can still `execve` such a binary.
    let shebang = read_shebang(path);
    let routing = routing_for_extension(ctx, overrides, path);

    // 1. Directly executable → spawn directly, *unless* it is a recognized
    //    source file with no `#!` line. On Unix the kernel honors a real
    //    shebang itself; on Windows this covers native `.exe`/`.com`. A
    //    shebang-less source file with the exec bit (common on
    //    vfat/exfat/ntfs-3g mounts that report mode 0777 for every file)
    //    would otherwise hit `execve`, fail `ENOEXEC`, and never reach
    //    bun/deno/node/uv/python/go — so route it to the runtime (outcome 3).
    if is_directly_executable(path, meta)
        && (shebang.is_some() || matches!(routing, SourceRouting::Unrecognized))
    {
        let mut command = Command::new(path);
        command.args(args);
        return Ok((String::from("exec"), command));
    }

    // 2. A `#!` shebang names the interpreter explicitly — honor it even
    //    without the executable bit (and on Windows, which has none).
    if let Some(shebang) = shebang {
        return Ok(shebang_command(&shebang, path, args));
    }

    // 3. A recognized source extension runs via the project runtime — except
    //    a `.jsx`/`.tsx` file resolved to Node, which Node cannot execute
    //    (no JSX transform; type-stripping covers only `.ts`/`.mts`/`.cts`).
    //    Erroring is honest; `node app.tsx` would be guaranteed-broken.
    match routing {
        SourceRouting::Runtime(runtime) => {
            let (label, command) = command_for_runtime(runtime, path, args);
            return Ok((label.to_string(), command));
        }
        SourceRouting::NodeCannotRunJsx => bail!(
            "node cannot run {}: Node has no JSX/TSX transform (it type-strips only \
             .ts/.mts/.cts).\nhint: run it with bun or deno (a Bun/Deno project, or pass `--pm \
             bun`/`--pm deno`).",
            path.display(),
        ),
        SourceRouting::Unrecognized => {}
    }

    // 4. Out of options: a clear, actionable error (never a 404).
    bail!(
        "don't know how to run {}: it is not executable, has no `#!` shebang, and has no \
         recognized source extension.\nhint: add a shebang, mark it executable (chmod +x), or \
         give it a known extension (.ts/.tsx/.js/.mjs/.cjs/.py/.go).",
        path.display(),
    );
}

/// Whether `token` carries an explicit local-path prefix. Used to decide
/// whether a *missing* path-like token is a typo'd local file (→ a clear
/// error) or a remote spec like `@scope/pkg` (→ fall through to PM-exec).
///
/// Exposed to the rest of `cmd::run` so [`super::qualify::precheck_task`]
/// can mirror [`try_path_token`]'s precedence: an explicit-prefix path
/// outranks task/runner resolution, so precheck must wave it through
/// rather than fail it against an active `--runner`/`[task_runner].prefer`
/// constraint (which dispatch never applies to a local-file token).
pub(super) fn has_local_prefix(token: &str) -> bool {
    token.starts_with("./")
        || token.starts_with("../")
        || token.starts_with(".\\")
        || token.starts_with("..\\")
        || token.starts_with('/')
        || token.starts_with('\\')
        || token.starts_with('~')
        || is_windows_drive_abs(token)
}

/// Whether `token` starts with a Windows drive-letter root (`C:\` / `C:/`).
fn is_windows_drive_abs(token: &str) -> bool {
    let bytes = token.as_bytes();
    bytes.len() >= 3
        && bytes[0].is_ascii_alphabetic()
        && bytes[1] == b':'
        && (bytes[2] == b'/' || bytes[2] == b'\\')
}

/// Resolve `token` to an absolute path: expand a leading `~`, then anchor a
/// relative path on `base` — the detected project root (`ctx.root`), which
/// also becomes the spawned child's working directory and is what task
/// detection runs against. Anchoring here (rather than on the live process
/// cwd) keeps local-file lookup consistent with every other dispatch step,
/// so `--dir`/`RUNNER_DIR` points the path lookup at the same directory the
/// child will run in. An absolute path (or a `~`-expanded one) is passed to
/// the spawned command verbatim so the child's working directory cannot
/// reinterpret it.
fn resolve_path(base: &Path, token: &str) -> PathBuf {
    let expanded = crate::expand_tilde(Path::new(token));
    if expanded.is_absolute() {
        return expanded;
    }
    base.join(expanded)
}

/// Whether the OS can execute `path` directly without an explicit
/// interpreter: the executable bit on Unix, a native executable extension
/// on Windows.
fn is_directly_executable(path: &Path, meta: &fs::Metadata) -> bool {
    #[cfg(unix)]
    {
        use std::os::unix::fs::PermissionsExt as _;
        let _ = path;
        meta.permissions().mode() & 0o111 != 0
    }
    #[cfg(windows)]
    {
        let _ = meta;
        matches!(ext_lower(path).as_deref(), Some("exe" | "com"))
    }
    #[cfg(not(any(unix, windows)))]
    {
        let _ = (path, meta);
        false
    }
}

/// Lower-cased file extension of `path`, if any.
fn ext_lower(path: &Path) -> Option<String> {
    path.extension()
        .and_then(|ext| ext.to_str())
        .map(str::to_ascii_lowercase)
}

/// Parsed `#!` interpreter line.
#[derive(Debug, PartialEq, Eq)]
struct Shebang {
    program: String,
    args: Vec<String>,
}

/// Read the first line of `path` and parse a `#!` shebang, if present.
///
/// Reads a bounded prefix (a shebang line is short) so a binary file never
/// pulls a large read; a non-UTF-8 shebang line is treated as no shebang.
///
/// I/O failure is non-fatal and yields `None`: by the time this runs the path
/// is already a regular file (`fs::metadata` succeeded), so a failed
/// `open`/`read` means an execute-only file (Unix mode 0111 — a legitimate
/// mode for compiled binaries) that the kernel can `execve` but cannot be
/// opened `O_RDONLY` (EACCES). "No shebang we can honor" → defer to the exec
/// bit / extension rather than hard-failing the whole dispatch.
fn read_shebang(path: &Path) -> Option<Shebang> {
    use std::io::Read as _;

    let Ok(mut file) = fs::File::open(path) else {
        return None;
    };
    let mut buf = [0u8; 256];
    let Ok(read) = file.read(&mut buf) else {
        return None;
    };
    let head = &buf[..read];
    if !head.starts_with(b"#!") {
        return None;
    }
    let line_end = head
        .iter()
        .position(|&byte| byte == b'\n')
        .unwrap_or(head.len());
    let Ok(line) = std::str::from_utf8(&head[..line_end]) else {
        return None;
    };
    parse_shebang(line)
}

/// Parse a `#!` line into the interpreter program and its arguments.
///
/// The kernel hands the interpreter exactly one argument: everything after the
/// first whitespace, internal spaces intact — it does *not* whitespace-split.
/// So a direct interpreter and a plain `env` both keep the remainder as a
/// single argument. Only `env -S` / `--split-string` re-splits that argument,
/// using env's own quote-aware parser ([`split_env_string`]) — never a naive
/// `split_whitespace`, which would tear `--allow-read="a b"` in two and invent
/// argv for `#!/usr/bin/env python3 -O`.
fn parse_shebang(line: &str) -> Option<Shebang> {
    let body = line.strip_prefix("#!")?.trim();
    if body.is_empty() {
        return None;
    }
    let (interpreter, rest) = match body.split_once(char::is_whitespace) {
        Some((interp, rest)) => (interp, rest.trim()),
        None => (body, ""),
    };

    if is_env(interpreter) {
        // `env -S` / `--split-string` re-splits its single kernel argument with
        // env's own quote-aware parser, so quoted args (`-S deno run
        // --allow-read="a b"`) stay one token. Plain `env` (no `-S`) does NOT
        // split: the kernel's single argument is the program name, so
        // `#!/usr/bin/env python3 -O` resolves to the (bogus) program
        // `"python3 -O"` exactly as the kernel would run it — `-S` is what adds
        // the splitting.
        let split_form = rest
            .strip_prefix("--split-string=")
            .or_else(|| rest.strip_prefix("--split-string"))
            .or_else(|| rest.strip_prefix("-S"))
            .map(str::trim);
        let words = split_form.map_or_else(|| single_arg(rest), split_env_string);
        let mut parts = words.into_iter();
        let program = parts.next()?;
        let args = parts.collect();
        return Some(Shebang { program, args });
    }

    // Direct interpreter: the kernel passes the whole remainder as one argument.
    Some(Shebang {
        program: interpreter.to_string(),
        args: single_arg(rest),
    })
}

/// The interpreter's single argument as the kernel passes it: the entire
/// remainder kept intact (internal spaces and all), or no argument at all when
/// the remainder is empty. Used by plain `env` and direct-interpreter shebangs,
/// neither of which the kernel whitespace-splits.
fn single_arg(rest: &str) -> Vec<String> {
    if rest.is_empty() {
        Vec::new()
    } else {
        vec![rest.to_owned()]
    }
}

/// Split an `env -S` / `--split-string` argument the way GNU `env`'s
/// split-string parser does for the cases shebangs actually use: ASCII
/// whitespace separates words, while single quotes, double quotes, and a
/// backslash keep a run — embedded spaces and all — as one word. This
/// preserves quoted arguments (`--allow-read="a b"`) that `split_whitespace`
/// would tear in two. The exotic `\t`/`\n`/`\c` escape sequences env also
/// understands are vanishingly rare in shebangs and intentionally unmodeled.
fn split_env_string(s: &str) -> Vec<String> {
    let mut words = Vec::new();
    let mut current = String::new();
    let mut started = false;
    let mut chars = s.chars();
    while let Some(c) = chars.next() {
        match c {
            c if c.is_ascii_whitespace() => {
                if started {
                    words.push(std::mem::take(&mut current));
                    started = false;
                }
            }
            '\'' => {
                started = true;
                for quoted in chars.by_ref() {
                    if quoted == '\'' {
                        break;
                    }
                    current.push(quoted);
                }
            }
            '"' => {
                started = true;
                while let Some(quoted) = chars.next() {
                    match quoted {
                        '"' => break,
                        '\\' => match chars.next() {
                            Some(esc @ ('"' | '\\' | '$' | '`')) => current.push(esc),
                            Some(other) => {
                                current.push('\\');
                                current.push(other);
                            }
                            None => current.push('\\'),
                        },
                        _ => current.push(quoted),
                    }
                }
            }
            '\\' => {
                started = true;
                if let Some(escaped) = chars.next() {
                    current.push(escaped);
                }
            }
            _ => {
                started = true;
                current.push(c);
            }
        }
    }
    if started {
        words.push(current);
    }
    words
}

/// Whether `interpreter` is the `env` launcher (by file name).
fn is_env(interpreter: &str) -> bool {
    Path::new(interpreter)
        .file_name()
        .is_some_and(|name| name == "env")
}

/// Build the command for a shebang-described file: `<interp> [interp-args]
/// <file> [forwarded-args]`.
fn shebang_command(shebang: &Shebang, file: &Path, args: &[String]) -> (String, Command) {
    let mut command = tool::program::command(&shebang.program);
    command.args(&shebang.args).arg(file).args(args);
    (shebang_label(shebang), command)
}

/// Short trace label for a shebang interpreter (`deno run`, `python3`, …).
fn shebang_label(shebang: &Shebang) -> String {
    let program = Path::new(&shebang.program)
        .file_name()
        .and_then(|name| name.to_str())
        .unwrap_or(shebang.program.as_str());
    if shebang.args.is_empty() {
        program.to_string()
    } else {
        format!("{program} {}", shebang.args.join(" "))
    }
}

/// A language runtime that executes a local source file by extension.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Runtime {
    Bun,
    Deno,
    Node,
    Uv,
    Python,
    Go,
    #[cfg(windows)]
    WindowsScript,
}

/// How a local file's extension routes for execution.
#[derive(Debug, PartialEq, Eq)]
enum SourceRouting {
    /// Run the file through this language runtime (outcome 3).
    Runtime(Runtime),
    /// A `.jsx`/`.tsx` file resolved to the Node runtime, which cannot
    /// execute JSX/TSX: Node has no JSX transform and type-strips only
    /// `.ts`/`.mts`/`.cts`. A clear error, never a broken `node app.tsx`.
    NodeCannotRunJsx,
    /// Not a recognized source extension — defer to the exec bit / shebang
    /// (a native binary) or, failing that, outcome 4's error.
    Unrecognized,
}

/// Map a file's extension to how it should be executed, given the detected
/// project and any `--pm` override.
fn routing_for_extension(
    ctx: &ProjectContext,
    overrides: &ResolutionOverrides,
    path: &Path,
) -> SourceRouting {
    let Some(ext) = ext_lower(path) else {
        return SourceRouting::Unrecognized;
    };
    match ext.as_str() {
        "ts" | "mts" | "cts" | "js" | "mjs" | "cjs" => {
            SourceRouting::Runtime(js_runtime(ctx, overrides))
        }
        // `.jsx`/`.tsx` need a JSX-aware runtime. Bun and Deno run them
        // directly; Node cannot (no JSX transform), so route those to a clear
        // error instead of building an unrunnable `node app.tsx`.
        "jsx" | "tsx" => match js_runtime(ctx, overrides) {
            runtime @ (Runtime::Bun | Runtime::Deno) => SourceRouting::Runtime(runtime),
            _ => SourceRouting::NodeCannotRunJsx,
        },
        "py" => SourceRouting::Runtime(py_runtime(ctx, overrides)),
        "go" => SourceRouting::Runtime(Runtime::Go),
        #[cfg(windows)]
        "ps1" | "bat" | "cmd" => SourceRouting::Runtime(Runtime::WindowsScript),
        _ => SourceRouting::Unrecognized,
    }
}

/// JavaScript/TypeScript runtime: an explicit JS-capable `--pm`/config
/// override wins, then the detected project runtime (Deno or Bun are
/// runtimes in their own right), defaulting to Node.
fn js_runtime(ctx: &ProjectContext, overrides: &ResolutionOverrides) -> Runtime {
    if let Some(runtime) = js_runtime_from_override(overrides) {
        return runtime;
    }
    if ctx.package_managers.contains(&PackageManager::Deno) {
        return Runtime::Deno;
    }
    if ctx.package_managers.contains(&PackageManager::Bun) {
        return Runtime::Bun;
    }
    Runtime::Node
}

/// Resolve a JS runtime from an explicit override (`--pm`, `RUNNER_PM`, or
/// `runner.toml` `[pm].node`/`[pm].deno`). A non-JS override (e.g.
/// `--pm cargo`) yields `None` so detection decides instead.
fn js_runtime_from_override(overrides: &ResolutionOverrides) -> Option<Runtime> {
    let pm = overrides
        .pm
        .as_ref()
        .map(|over| over.pm)
        .or_else(|| {
            overrides
                .pm_by_ecosystem
                .get(&Ecosystem::Node)
                .map(|over| over.pm)
        })
        .or_else(|| {
            overrides
                .pm_by_ecosystem
                .get(&Ecosystem::Deno)
                .map(|over| over.pm)
        })?;
    match pm {
        PackageManager::Deno => Some(Runtime::Deno),
        PackageManager::Bun => Some(Runtime::Bun),
        node if node.is_node() => Some(Runtime::Node),
        _ => None,
    }
}

/// Python runtime: `uv run` when uv is overridden or detected, otherwise the
/// plain interpreter. A non-uv Python override (poetry/pipenv) uses the
/// plain interpreter — `uv run` would be wrong for those projects.
fn py_runtime(ctx: &ProjectContext, overrides: &ResolutionOverrides) -> Runtime {
    let overridden = overrides.pm.as_ref().map(|over| over.pm).or_else(|| {
        overrides
            .pm_by_ecosystem
            .get(&Ecosystem::Python)
            .map(|over| over.pm)
    });
    if let Some(pm) = overridden {
        if pm == PackageManager::Uv {
            return Runtime::Uv;
        }
        if pm.ecosystem() == Ecosystem::Python {
            return Runtime::Python;
        }
    }
    if ctx.package_managers.contains(&PackageManager::Uv) {
        Runtime::Uv
    } else {
        Runtime::Python
    }
}

/// Build the command (and trace label) for a runtime running `file`.
fn command_for_runtime(runtime: Runtime, file: &Path, args: &[String]) -> (&'static str, Command) {
    match runtime {
        Runtime::Bun => ("bun", tool::bun::run_file_cmd(file, args)),
        Runtime::Deno => ("deno run", tool::deno::run_file_cmd(file, args)),
        Runtime::Node => ("node", tool::node::run_file_cmd(file, args)),
        Runtime::Uv => ("uv run", tool::uv::run_file_cmd(file, args)),
        Runtime::Python => (
            tool::python::PYTHON_BIN,
            tool::python::run_file_cmd(file, args),
        ),
        Runtime::Go => ("go run", tool::go_pm::run_file_cmd(file, args)),
        #[cfg(windows)]
        Runtime::WindowsScript => windows_script_command(file, args),
    }
}

/// Build the command for a Windows native script: `.ps1` via PowerShell,
/// `.bat`/`.cmd` via `cmd /c` (neither is launchable through
/// `CreateProcess` directly).
#[cfg(windows)]
fn windows_script_command(file: &Path, args: &[String]) -> (&'static str, Command) {
    if ext_lower(file).as_deref() == Some("ps1") {
        let mut command = tool::program::command("powershell");
        command
            .arg("-NoProfile")
            .arg("-ExecutionPolicy")
            .arg("Bypass")
            .arg("-File")
            .arg(file)
            .args(args);
        ("powershell", command)
    } else {
        let mut command = tool::program::command("cmd");
        command.arg("/c").arg(file).args(args);
        ("cmd", command)
    }
}

#[cfg(test)]
mod tests {
    use std::path::{Path, PathBuf};

    use super::{
        Runtime, SourceRouting, bare_file_in, build_command, dispatch_for_path, has_local_prefix,
        js_runtime, parse_shebang, py_runtime, routing_for_extension, try_bare_file,
        try_path_token,
    };
    use crate::resolver::ResolutionOverrides;
    use crate::tool::test_support::TempDir;
    use crate::types::{PackageManager, ProjectContext};

    fn context(pms: Vec<PackageManager>) -> ProjectContext {
        ProjectContext {
            root: PathBuf::from("."),
            package_managers: pms,
            task_runners: Vec::new(),
            tasks: Vec::new(),
            node_version: None,
            current_node: None,
            is_monorepo: false,
            warnings: Vec::new(),
        }
    }

    fn context_rooted(pms: Vec<PackageManager>, root: PathBuf) -> ProjectContext {
        ProjectContext {
            root,
            ..context(pms)
        }
    }

    fn args_of(command: &std::process::Command) -> Vec<String> {
        command
            .get_args()
            .map(|arg| arg.to_string_lossy().into_owned())
            .collect()
    }

    #[test]
    fn has_local_prefix_distinguishes_paths_from_remote_specs() {
        assert!(has_local_prefix("./x"));
        assert!(has_local_prefix("../x"));
        assert!(has_local_prefix("/x"));
        assert!(has_local_prefix("~/x"));
        assert!(has_local_prefix(r".\x"));
        assert!(has_local_prefix(r"C:\x"));
        // Remote specs that merely contain a separator must not look local.
        assert!(!has_local_prefix("@scope/pkg"));
        assert!(!has_local_prefix("github.com/owner/tool"));
        assert!(!has_local_prefix("bin/x.ts"));
    }

    #[test]
    fn parse_shebang_plain_interpreter() {
        let parsed = parse_shebang("#!/bin/sh").expect("shebang should parse");
        assert_eq!(parsed.program, "/bin/sh");
        assert!(parsed.args.is_empty());
    }

    #[test]
    fn parse_shebang_interpreter_with_args() {
        let parsed = parse_shebang("#!/usr/bin/python3 -O").expect("shebang should parse");
        assert_eq!(parsed.program, "/usr/bin/python3");
        assert_eq!(parsed.args, ["-O"]);
    }

    #[test]
    fn parse_shebang_env_resolves_real_interpreter() {
        let parsed = parse_shebang("#!/usr/bin/env python3").expect("shebang should parse");
        assert_eq!(parsed.program, "python3");
        assert!(parsed.args.is_empty());
    }

    #[test]
    fn parse_shebang_env_split_string_forms() {
        for line in [
            "#!/usr/bin/env -S deno run -A",
            "#!/usr/bin/env --split-string=deno run -A",
            "#!/usr/bin/env --split-string deno run -A",
            "#!/usr/bin/env -Sdeno run -A",
        ] {
            let parsed = parse_shebang(line).unwrap_or_else(|| panic!("should parse {line:?}"));
            assert_eq!(parsed.program, "deno", "program from {line:?}");
            assert_eq!(parsed.args, ["run", "-A"], "args from {line:?}");
        }
    }

    #[test]
    fn parse_shebang_env_split_string_preserves_quoted_args() {
        // `env -S` honors quotes; `split_whitespace` would tear
        // `--allow-read="a b"` into two words. Double and single quotes both
        // keep the embedded space inside one argument.
        let double = parse_shebang(r#"#!/usr/bin/env -S deno run --allow-read="a b""#)
            .expect("double-quoted -S shebang should parse");
        assert_eq!(double.program, "deno");
        assert_eq!(double.args, ["run", "--allow-read=a b"]);

        let single = parse_shebang("#!/usr/bin/env -S deno run --allow-read='a b'")
            .expect("single-quoted -S shebang should parse");
        assert_eq!(single.args, ["run", "--allow-read=a b"]);
    }

    #[test]
    fn parse_shebang_plain_env_keeps_tail_as_single_argument() {
        // Plain `env` (no `-S`) gets ONE argument from the kernel — the whole
        // remainder — and treats it as the program name. It does not split on
        // whitespace (that's what `-S` is for), so `env python3 -O` resolves to
        // the program `"python3 -O"`, matching how the kernel runs the file.
        let parsed = parse_shebang("#!/usr/bin/env python3 -O").expect("should parse");
        assert_eq!(parsed.program, "python3 -O");
        assert!(parsed.args.is_empty());

        // Single-word plain env is unchanged: the remainder is the interpreter.
        let one = parse_shebang("#!/usr/bin/env python3").expect("should parse");
        assert_eq!(one.program, "python3");
        assert!(one.args.is_empty());
    }

    #[test]
    fn parse_shebang_direct_interpreter_keeps_tail_as_single_argument() {
        // A direct interpreter also receives a single kernel argument: the
        // whole remainder, internal spaces intact — never re-split.
        let parsed = parse_shebang("#!/bin/awk -f -v").expect("should parse");
        assert_eq!(parsed.program, "/bin/awk");
        assert_eq!(parsed.args, ["-f -v"]);
    }

    #[test]
    fn parse_shebang_rejects_non_shebang() {
        assert!(parse_shebang("not a shebang").is_none());
        assert!(parse_shebang("#!").is_none());
        assert!(parse_shebang("#!   ").is_none());
    }

    #[test]
    fn js_runtime_follows_detected_project() {
        let defaults = ResolutionOverrides::default();
        assert_eq!(
            js_runtime(&context(vec![PackageManager::Deno]), &defaults),
            Runtime::Deno,
        );
        assert_eq!(
            js_runtime(&context(vec![PackageManager::Bun]), &defaults),
            Runtime::Bun,
        );
        assert_eq!(
            js_runtime(&context(vec![PackageManager::Pnpm]), &defaults),
            Runtime::Node,
        );
        assert_eq!(js_runtime(&context(vec![]), &defaults), Runtime::Node);
    }

    #[test]
    fn py_runtime_prefers_uv_when_detected() {
        let defaults = ResolutionOverrides::default();
        assert_eq!(
            py_runtime(&context(vec![PackageManager::Uv]), &defaults),
            Runtime::Uv,
        );
        assert_eq!(
            py_runtime(&context(vec![PackageManager::Poetry]), &defaults),
            Runtime::Python,
        );
        assert_eq!(py_runtime(&context(vec![]), &defaults), Runtime::Python);
    }

    #[test]
    fn routing_for_extension_maps_known_sources() {
        let ctx = context(vec![PackageManager::Bun]);
        let defaults = ResolutionOverrides::default();
        assert_eq!(
            routing_for_extension(&ctx, &defaults, Path::new("a.ts")),
            SourceRouting::Runtime(Runtime::Bun),
        );
        assert_eq!(
            routing_for_extension(&ctx, &defaults, Path::new("a.go")),
            SourceRouting::Runtime(Runtime::Go),
        );
        assert_eq!(
            routing_for_extension(&ctx, &defaults, Path::new("a.py")),
            SourceRouting::Runtime(Runtime::Python),
        );
        assert_eq!(
            routing_for_extension(&ctx, &defaults, Path::new("a.txt")),
            SourceRouting::Unrecognized,
        );
    }

    #[test]
    fn routing_for_extension_routes_jsx_tsx_to_bun_or_deno() {
        let defaults = ResolutionOverrides::default();
        for ext in ["a.jsx", "a.tsx"] {
            assert_eq!(
                routing_for_extension(
                    &context(vec![PackageManager::Bun]),
                    &defaults,
                    Path::new(ext),
                ),
                SourceRouting::Runtime(Runtime::Bun),
                "{ext} should run via bun in a bun project",
            );
            assert_eq!(
                routing_for_extension(
                    &context(vec![PackageManager::Deno]),
                    &defaults,
                    Path::new(ext),
                ),
                SourceRouting::Runtime(Runtime::Deno),
                "{ext} should run via deno in a deno project",
            );
        }
    }

    #[test]
    fn routing_for_extension_rejects_jsx_tsx_on_node() {
        // Node has no JSX transform: `node app.jsx`/`node app.tsx` are
        // categorically unrunnable, so a node-only (or no-PM) project routes
        // them to a clear error rather than an unrunnable `node` command.
        let defaults = ResolutionOverrides::default();
        for ext in ["a.jsx", "a.tsx"] {
            assert_eq!(
                routing_for_extension(
                    &context(vec![PackageManager::Pnpm]),
                    &defaults,
                    Path::new(ext),
                ),
                SourceRouting::NodeCannotRunJsx,
                "{ext} must not route to bare node",
            );
            assert_eq!(
                routing_for_extension(&context(vec![]), &defaults, Path::new(ext)),
                SourceRouting::NodeCannotRunJsx,
                "{ext} must not route to bare node in a no-PM project",
            );
        }
    }

    #[test]
    fn build_command_runs_ts_via_detected_runtime() {
        let dir = TempDir::new("local-ts");
        let file = dir.path().join("script.ts");
        std::fs::write(&file, "console.log('hi')\n").expect("file should be written");
        let meta = std::fs::metadata(&file).expect("metadata should read");

        let (label, command) = build_command(
            &context(vec![PackageManager::Bun]),
            &ResolutionOverrides::default(),
            &file,
            &meta,
            &[String::from("--flag")],
        )
        .expect("ts file should build a command");

        assert_eq!(label, "bun");
        assert_eq!(command.get_program().to_string_lossy(), "bun");
        assert_eq!(
            args_of(&command),
            [file.to_string_lossy().into_owned(), String::from("--flag")],
        );
    }

    #[test]
    fn build_command_runs_py_via_uv_when_detected() {
        let dir = TempDir::new("local-py");
        let file = dir.path().join("task.py");
        std::fs::write(&file, "print('hi')\n").expect("file should be written");
        let meta = std::fs::metadata(&file).expect("metadata should read");

        let (label, command) = build_command(
            &context(vec![PackageManager::Uv]),
            &ResolutionOverrides::default(),
            &file,
            &meta,
            &[],
        )
        .expect("py file should build a command");

        assert_eq!(label, "uv run");
        assert_eq!(command.get_program().to_string_lossy(), "uv");
        assert_eq!(
            args_of(&command),
            [String::from("run"), file.to_string_lossy().into_owned()],
        );
    }

    #[test]
    fn build_command_honors_shebang_without_exec_bit() {
        let dir = TempDir::new("local-shebang");
        let file = dir.path().join("noexec.sh");
        std::fs::write(&file, "#!/usr/bin/env -S deno run -A\nconsole.log(1)\n")
            .expect("file should be written");
        let meta = std::fs::metadata(&file).expect("metadata should read");

        let (label, command) = build_command(
            &context(vec![PackageManager::Bun]),
            &ResolutionOverrides::default(),
            &file,
            &meta,
            &[String::from("x")],
        )
        .expect("shebang file should build a command");

        // The shebang wins over the extension/runtime default (Bun here).
        assert_eq!(label, "deno run -A");
        assert_eq!(command.get_program().to_string_lossy(), "deno");
        assert_eq!(
            args_of(&command),
            [
                String::from("run"),
                String::from("-A"),
                file.to_string_lossy().into_owned(),
                String::from("x"),
            ],
        );
    }

    #[test]
    fn build_command_errors_on_unrunnable_file() {
        let dir = TempDir::new("local-unknown");
        let file = dir.path().join("data.bin");
        std::fs::write(&file, [0u8, 1, 2, 3]).expect("file should be written");
        let meta = std::fs::metadata(&file).expect("metadata should read");

        let err = build_command(
            &context(vec![]),
            &ResolutionOverrides::default(),
            &file,
            &meta,
            &[],
        )
        .expect_err("a non-runnable file should error");

        assert!(format!("{err:#}").contains("don't know how to run"));
    }

    #[test]
    fn build_command_errors_on_jsx_in_node_project() {
        // A node-only project building `node app.tsx` would be guaranteed
        // broken (Node has no JSX transform). It must surface a clear error
        // rather than an unrunnable command.
        let dir = TempDir::new("local-tsx-node");
        let file = dir.path().join("app.tsx");
        std::fs::write(&file, "export const App = () => <div/>;\n")
            .expect("file should be written");
        let meta = std::fs::metadata(&file).expect("metadata should read");

        let err = build_command(
            &context(vec![PackageManager::Pnpm]),
            &ResolutionOverrides::default(),
            &file,
            &meta,
            &[],
        )
        .expect_err("a .tsx in a node project should error, not build `node app.tsx`");

        assert!(format!("{err:#}").contains("node cannot run"));
    }

    #[test]
    fn build_command_runs_tsx_via_bun_when_detected() {
        // A bun project can run `.tsx` directly, so it dispatches via bun.
        let dir = TempDir::new("local-tsx-bun");
        let file = dir.path().join("app.tsx");
        std::fs::write(&file, "export const App = () => <div/>;\n")
            .expect("file should be written");
        let meta = std::fs::metadata(&file).expect("metadata should read");

        let (label, command) = build_command(
            &context(vec![PackageManager::Bun]),
            &ResolutionOverrides::default(),
            &file,
            &meta,
            &[],
        )
        .expect("a .tsx in a bun project should build a bun command");

        assert_eq!(label, "bun");
        assert_eq!(command.get_program().to_string_lossy(), "bun");
    }

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

        // A native binary at mode 0111 (execute-only — a legitimate mode):
        // the kernel can `execve` it, but `open(O_RDONLY)` returns EACCES so
        // the shebang probe cannot read it. It must still spawn directly
        // (outcome 1) rather than hard-fail on the unreadable shebang read.
        let dir = TempDir::new("local-exec-only");
        let file = dir.path().join("tool");
        std::fs::write(&file, [0u8, 1, 2, 3]).expect("file should be written");
        std::fs::set_permissions(&file, std::fs::Permissions::from_mode(0o111))
            .expect("chmod should succeed");
        let meta = std::fs::metadata(&file).expect("metadata should read");

        let (label, command) = build_command(
            &context(vec![]),
            &ResolutionOverrides::default(),
            &file,
            &meta,
            &[String::from("arg")],
        )
        .expect("an execute-only binary should spawn directly, not fail the shebang read");

        assert_eq!(label, "exec");
        assert_eq!(PathBuf::from(command.get_program()), file);
        assert_eq!(args_of(&command), [String::from("arg")]);
    }

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

        // A recognized source file at mode 0111 (execute-only). The shebang
        // probe hits EACCES, but that must NOT propagate as an error (which
        // `bare_file_in`'s `.ok()` would swallow into a `bunx main.ts` 404).
        // It routes through the detected runtime instead, upholding the
        // no-bunx-on-a-local-file invariant.
        let dir = TempDir::new("local-exec-only-ts");
        let file = dir.path().join("main.ts");
        std::fs::write(&file, "console.log(1)\n").expect("file should be written");
        std::fs::set_permissions(&file, std::fs::Permissions::from_mode(0o111))
            .expect("chmod should succeed");
        let meta = std::fs::metadata(&file).expect("metadata should read");

        let (label, command) = build_command(
            &context(vec![PackageManager::Bun]),
            &ResolutionOverrides::default(),
            &file,
            &meta,
            &[],
        )
        .expect("an execute-only source file should route to its runtime");

        assert_eq!(label, "bun", "a 0111 .ts routes to bun, never bunx");
        assert_eq!(command.get_program().to_string_lossy(), "bun");
    }

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

        let dir = TempDir::new("local-exec");
        let file = dir.path().join("build.sh");
        std::fs::write(&file, "#!/bin/sh\necho hi\n").expect("file should be written");
        std::fs::set_permissions(&file, std::fs::Permissions::from_mode(0o755))
            .expect("chmod should succeed");
        let meta = std::fs::metadata(&file).expect("metadata should read");

        let (label, command) = build_command(
            &context(vec![]),
            &ResolutionOverrides::default(),
            &file,
            &meta,
            &[String::from("arg")],
        )
        .expect("executable should build a command");

        assert_eq!(label, "exec");
        assert_eq!(
            PathBuf::from(command.get_program()),
            file,
            "an executable file is spawned by its own path",
        );
        assert_eq!(args_of(&command), [String::from("arg")]);
    }

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

        // A `.ts` file that carries the exec bit but has no `#!` shebang is
        // still text: a raw `execve` would fail `ENOEXEC` (or be retried as a
        // broken `/bin/sh` parse) and never reach bun. It must dispatch
        // through the detected runtime instead. This is the real breakage on
        // vfat/exfat/ntfs-3g mounts that report mode 0777 for every file.
        let dir = TempDir::new("local-exec-ts");
        let file = dir.path().join("deploy.ts");
        std::fs::write(&file, "console.log('hi')\n").expect("file should be written");
        std::fs::set_permissions(&file, std::fs::Permissions::from_mode(0o755))
            .expect("chmod should succeed");
        let meta = std::fs::metadata(&file).expect("metadata should read");

        let (label, command) = build_command(
            &context(vec![PackageManager::Bun]),
            &ResolutionOverrides::default(),
            &file,
            &meta,
            &[String::from("--flag")],
        )
        .expect("an exec-bit source file should build a runtime command");

        assert_eq!(label, "bun", "a shebang-less +x .ts runs via bun, not exec");
        assert_eq!(command.get_program().to_string_lossy(), "bun");
        assert_eq!(
            args_of(&command),
            [file.to_string_lossy().into_owned(), String::from("--flag")],
        );
    }

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

        // A `.ts` file with BOTH the exec bit and a `#!` line is a genuine
        // self-executable script: the kernel honors the shebang, so spawn it
        // directly rather than second-guessing the runtime.
        let dir = TempDir::new("local-exec-ts-shebang");
        let file = dir.path().join("deploy.ts");
        std::fs::write(&file, "#!/usr/bin/env -S deno run -A\nconsole.log('hi')\n")
            .expect("file should be written");
        std::fs::set_permissions(&file, std::fs::Permissions::from_mode(0o755))
            .expect("chmod should succeed");
        let meta = std::fs::metadata(&file).expect("metadata should read");

        let (label, command) = build_command(
            &context(vec![PackageManager::Bun]),
            &ResolutionOverrides::default(),
            &file,
            &meta,
            &[],
        )
        .expect("an exec-bit shebang file should build a command");

        assert_eq!(label, "exec", "a +x file with a shebang spawns directly");
        assert_eq!(PathBuf::from(command.get_program()), file);
    }

    #[test]
    fn dispatch_for_path_passes_through_directories() {
        let dir = TempDir::new("local-dir");
        let result = dispatch_for_path(
            &context(vec![]),
            &ResolutionOverrides::default(),
            "./some-dir",
            dir.path(),
            &[],
        )
        .expect("a directory should not error");

        assert!(result.is_none(), "directories fall through, not run");
    }

    #[test]
    fn dispatch_for_path_errors_on_missing_explicit_path() {
        let dir = TempDir::new("local-missing");
        let missing = dir.path().join("ghost.ts");

        let err = dispatch_for_path(
            &context(vec![]),
            &ResolutionOverrides::default(),
            "./ghost.ts",
            &missing,
            &[],
        )
        .expect_err("an explicit missing path should error");

        assert!(format!("{err:#}").contains("no such file"));
    }

    #[test]
    fn dispatch_for_path_passes_through_missing_remote_spec() {
        let dir = TempDir::new("local-remote");
        let missing = dir.path().join("biome");

        let result = dispatch_for_path(
            &context(vec![]),
            &ResolutionOverrides::default(),
            "@biomejs/biome",
            &missing,
            &[],
        )
        .expect("a remote spec should not error");

        assert!(
            result.is_none(),
            "a separator-bearing remote spec falls through to PM-exec",
        );
    }

    #[test]
    fn try_path_token_ignores_bare_names() {
        let result = try_path_token(
            &context(vec![PackageManager::Bun]),
            &ResolutionOverrides::default(),
            "test",
            &[],
        )
        .expect("bare names should not error");

        assert!(
            result.is_none(),
            "bare names are handled by the bare-file fallback, not the path branch"
        );
    }

    #[test]
    fn bare_file_runs_a_runnable_file_in_base_dir() {
        // A bare `main.ts` in the base directory dispatches via the runtime
        // instead of falling through to package-exec.
        let dir = TempDir::new("bare-file");
        std::fs::write(dir.path().join("main.ts"), "console.log(1)\n")
            .expect("file should be written");

        let dispatch = bare_file_in(
            &context(vec![PackageManager::Deno]),
            &ResolutionOverrides::default(),
            dir.path(),
            "main.ts",
            &[],
        )
        .expect("a runnable bare file should not error")
        .expect("a runnable bare file should dispatch");

        assert_eq!(dispatch.label, "deno run");
        assert_eq!(dispatch.command.get_program().to_string_lossy(), "deno");
    }

    #[test]
    fn bare_file_missing_token_falls_through_to_pm_exec() {
        // A bare token with no matching file on disk is a real package-spec
        // candidate (`runner biome` → `bunx biome`), so it keeps falling
        // through to the PM-exec fallback rather than erroring.
        let dir = TempDir::new("bare-file-miss");
        let ctx = context(vec![PackageManager::Bun]);
        let defaults = ResolutionOverrides::default();

        assert!(
            bare_file_in(&ctx, &defaults, dir.path(), "biome", &[])
                .expect("a missing token should not error")
                .is_none(),
            "a non-existent token must fall through to PM-exec",
        );
    }

    #[test]
    fn bare_file_existing_unsupported_file_errors() {
        // Once the token names an existing regular file it is a local file: an
        // unsupported one (`data.bin` — no extension we run, no shebang, not
        // executable) surfaces the clear local-file error instead of falling
        // through to `bunx data.bin` and a registry 404, matching the explicit
        // `./data.bin` form (#69).
        let dir = TempDir::new("bare-file-unsupported");
        std::fs::write(dir.path().join("data.bin"), [0u8, 1, 2]).expect("file should be written");
        let ctx = context(vec![PackageManager::Bun]);
        let defaults = ResolutionOverrides::default();

        let err = bare_file_in(&ctx, &defaults, dir.path(), "data.bin", &[])
            .expect_err("an existing unsupported file must error, not fall through to PM-exec");
        assert!(format!("{err:#}").contains("don't know how to run"));
    }

    #[test]
    fn bare_file_errors_on_jsx_in_node_project() {
        // Regression for #69: a bare (no `./` prefix) `app.tsx` that exists on
        // disk in a node-only project must surface the same `node cannot run`
        // error the explicit `./app.tsx` form raises — never let `build_command`'s
        // error be swallowed into a `None` that falls through to `pnpm exec
        // app.tsx` / `npx app.tsx` and a registry 404.
        let dir = TempDir::new("bare-tsx-node");
        std::fs::write(
            dir.path().join("app.tsx"),
            "export const App = () => <div/>;\n",
        )
        .expect("file should be written");

        let err = bare_file_in(
            &context(vec![PackageManager::Pnpm]),
            &ResolutionOverrides::default(),
            dir.path(),
            "app.tsx",
            &[],
        )
        .expect_err("a bare .tsx in a node project must error, not build a pnpm/npx command");

        assert!(format!("{err:#}").contains("node cannot run"));
    }

    #[test]
    fn try_bare_file_declines_local_prefix_tokens() {
        // Prefix-bearing paths are the pre-task path branch's responsibility;
        // the after-miss fallback must decline them outright. A prefix-less
        // relative path like `bin/tool` is NOT declined here — see
        // `bare_file_resolves_relative_separator_token`.
        let ctx = context(vec![PackageManager::Bun]);
        let defaults = ResolutionOverrides::default();
        for token in ["./main.ts", "../main.ts", "/abs/main.ts", "~/main.ts"] {
            assert!(
                try_bare_file(&ctx, &defaults, token, &[])
                    .expect("a local-prefix token should not error")
                    .is_none(),
                "{token} carries a local prefix and must be declined here",
            );
        }
    }

    #[test]
    fn try_path_token_declines_relative_separator_tokens() {
        // `bin/tool` carries a separator but no explicit local prefix, so the
        // pre-task path branch must decline it (returning `None` without
        // touching the filesystem) — a matching `make bin/tool` task wins
        // first. Only an explicit `./bin/tool` outranks a task.
        let result = try_path_token(
            &context(vec![PackageManager::Bun]),
            &ResolutionOverrides::default(),
            "bin/tool",
            &[],
        )
        .expect("a relative separator token should not error");

        assert!(
            result.is_none(),
            "a prefix-less relative path is left to the after-miss fallback",
        );
    }

    #[test]
    fn bare_file_resolves_relative_separator_token() {
        // The after-miss fallback accepts a prefix-less relative path that
        // carries a separator (`bin/main.ts`), resolving it under the base
        // directory — so an unmatched make-style target name still runs as a
        // local file once task lookup has missed.
        let dir = TempDir::new("bare-file-nested");
        std::fs::create_dir(dir.path().join("bin")).expect("subdir should be created");
        std::fs::write(dir.path().join("bin").join("main.ts"), "console.log(1)\n")
            .expect("file should be written");

        let dispatch = bare_file_in(
            &context(vec![PackageManager::Deno]),
            &ResolutionOverrides::default(),
            dir.path(),
            "bin/main.ts",
            &[],
        )
        .expect("a runnable relative-separator file should not error")
        .expect("a runnable relative-separator file should dispatch");

        assert_eq!(dispatch.label, "deno run");
        assert_eq!(dispatch.command.get_program().to_string_lossy(), "deno");
    }

    #[test]
    fn try_bare_file_resolves_against_ctx_root() {
        // The bare-file fallback anchors on `ctx.root` (the detected project
        // dir / `--dir` target), not the live process cwd — a `main.ts` under
        // the project root runs even when the shell cwd is elsewhere. This is
        // what stops a `--dir`-set run from missing the file and mis-routing
        // into the `bunx main.ts` 404 fallback (issue #69).
        let dir = TempDir::new("bare-root");
        std::fs::write(dir.path().join("main.ts"), "console.log(1)\n")
            .expect("file should be written");

        let ctx = context_rooted(vec![PackageManager::Deno], dir.path().to_path_buf());
        let dispatch = try_bare_file(&ctx, &ResolutionOverrides::default(), "main.ts", &[])
            .expect("a runnable bare file under ctx.root should not error")
            .expect("a runnable bare file under ctx.root should dispatch");

        assert_eq!(dispatch.label, "deno run");
        assert_eq!(dispatch.command.get_program().to_string_lossy(), "deno");
    }

    #[test]
    fn try_path_token_resolves_relative_prefix_against_ctx_root() {
        // An explicit `./main.ts` is joined onto `ctx.root`, so it resolves the
        // project-root file regardless of the process cwd — consistent with
        // task detection and the spawned child's working directory.
        let dir = TempDir::new("path-root");
        std::fs::write(dir.path().join("main.ts"), "console.log(1)\n")
            .expect("file should be written");

        let ctx = context_rooted(vec![PackageManager::Bun], dir.path().to_path_buf());
        let dispatch = try_path_token(&ctx, &ResolutionOverrides::default(), "./main.ts", &[])
            .expect("an explicit relative path should not error")
            .expect("a runnable ./main.ts under ctx.root should dispatch");

        assert_eq!(dispatch.label, "bun");
        assert_eq!(dispatch.command.get_program().to_string_lossy(), "bun");
    }
}