Skip to main content

keel_cli/
run.rs

1//! `keel run <script> [args…]` — dispatch a program into its language front end.
2//!
3//! The heavy lifting (bootstrap, import hook, adapters, discovery) lives in the
4//! Python and Node packages; `run` is only the dispatcher (dx-spec §1, Level 0):
5//!
6//! - `*.py`                     → `python3 -m keel run <script> [args…]`
7//! - `*.{mjs,js,ts,cjs,…}`      → `node --import keelrun/hook <script> [args…]`
8//! - a `package.json`, or a dir containing one → resolve its `main`, then
9//!   the Node path
10//! - any other dir              → a conventional entry name (`main.py`,
11//!   `__main__.py`, `index.*`), then — if exactly one Python or Node source
12//!   file sits directly inside it — that file; ambiguous or empty is a
13//!   precise error, never a guess
14//! - anything else              → a precise what/why/next error, exit 2
15//!
16//! The child inherits the environment (so every `KEEL_*` var passes through);
17//! `--disable` layers `KEEL_DISABLE=1` on top. The child's exit code is the
18//! process's exit code — wrapping is invisible on the success path.
19
20use std::path::{Path, PathBuf};
21use std::process::Command;
22
23use serde::Serialize;
24
25use crate::{EXIT_FAILURE, EXIT_USAGE, Rendered};
26
27/// Node's resolver name for the preload hook (the `keelrun` package's `./hook`
28/// export). Resolved from the project's `node_modules`, exactly as
29/// `node --import keelrun/hook` would in a project that installed `keelrun`.
30const NODE_HOOK: &str = "keelrun/hook";
31
32/// The concrete plan: which interpreter to exec with which argv, and whether to
33/// disable Keel in the child. Pure data, so dispatch is unit-testable without
34/// spawning anything.
35#[derive(Debug, Clone, PartialEq, Eq)]
36pub struct RunPlan {
37    /// The program to exec (`python3` or `node`).
38    pub program: String,
39    /// Its full argument vector (excluding `program` itself).
40    pub argv: Vec<String>,
41    /// Whether to set `KEEL_DISABLE=1` in the child.
42    pub disable: bool,
43}
44
45/// Why a target could not be dispatched — each rendered as what/why/next.
46#[derive(Debug, Clone, PartialEq, Eq)]
47pub enum RunError {
48    /// The target file/dir does not exist.
49    NotFound { target: String },
50    /// The extension is not one `keel run` knows how to dispatch.
51    UnknownKind { target: String },
52    /// A Node package dir/`package.json` had no resolvable entry file.
53    NoEntry { target: String },
54    /// A directory had more than one plausible entry file and no
55    /// `package.json`/conventional name to disambiguate.
56    AmbiguousEntry {
57        target: String,
58        candidates: Vec<String>,
59    },
60    /// A Node target's `node_modules` (walked up to the filesystem root) has
61    /// no `keelrun` — `node --import keelrun/hook` would spawn fine and then
62    /// die inside Node's own ESM loader with a raw, unbranded
63    /// `ERR_MODULE_NOT_FOUND`, so this is caught before exec'ing at all.
64    MissingKeelrun { target: String },
65}
66
67impl RunError {
68    pub(crate) fn render(&self) -> Rendered {
69        let (what, why, next, kind) = match self {
70            Self::NotFound { target } => (
71                format!("Cannot run `{target}`: no such file or directory."),
72                "The path does not exist relative to the current directory.".to_owned(),
73                "Check the path; `keel run` takes a script file, a package.json, or a project directory.".to_owned(),
74                "not-found",
75            ),
76            Self::UnknownKind { target } => (
77                format!("Cannot run `{target}`: unrecognized program type."),
78                "`keel run` dispatches Python (.py) and Node (.mjs/.js/.ts/.cjs/.mts/.cts/.jsx/.tsx, or a package.json main); this target is neither.".to_owned(),
79                "Rename to a supported extension, point at the project's package.json, or invoke the interpreter directly.".to_owned(),
80                "unknown-kind",
81            ),
82            Self::NoEntry { target } => (
83                format!("Cannot run `{target}`: no entry file found."),
84                "The directory/package.json has no resolvable `main` (and no index.js).".to_owned(),
85                "Add a `main` to package.json, or pass the entry script directly.".to_owned(),
86                "no-entry",
87            ),
88            Self::AmbiguousEntry { target, candidates } => (
89                format!("Cannot run `{target}`: multiple possible entry files."),
90                format!(
91                    "No package.json or conventional entry (main.py, __main__.py, index.*) — \
92                     found {} candidate scripts directly inside this directory: {}.",
93                    candidates.len(),
94                    candidates.join(", ")
95                ),
96                "Pass the entry script directly, e.g. `keel run <path-to-script>`.".to_owned(),
97                "ambiguous-entry",
98            ),
99            Self::MissingKeelrun { target } => (
100                format!("Cannot run `{target}`: the `keelrun` package is not installed."),
101                "Node targets are dispatched via `node --import keelrun/hook`, which needs the \
102                 `keelrun` package in `node_modules` for runtime interception — only \
103                 `keelrun-cli` (the binary) was found."
104                    .to_owned(),
105                "Run `npm install keelrun` alongside `keelrun-cli` in this project.".to_owned(),
106                "missing-keelrun",
107            ),
108        };
109        let human = format!("keel \u{25b8} {what}\n  why:  {why}\n  next: {next}");
110        let report = RunErrorReport {
111            error: kind,
112            next: &next,
113            what: &what,
114            why: &why,
115        };
116        Rendered {
117            human,
118            json: crate::render::to_json(&report),
119            exit: EXIT_USAGE,
120            to_stderr: true,
121        }
122    }
123}
124
125/// The machine twin of a dispatch failure.
126#[derive(Debug, Serialize)]
127struct RunErrorReport<'a> {
128    error: &'static str,
129    next: &'a str,
130    what: &'a str,
131    why: &'a str,
132}
133
134/// Node source extensions `keel run` dispatches.
135const NODE_EXTS: &[&str] = &["mjs", "js", "ts", "cjs", "mts", "cts", "jsx", "tsx"];
136
137/// Build the [`RunPlan`] for `target` and `args`. Reads the filesystem to
138/// classify the target and (for a package) to resolve its entry file.
139pub fn plan(target: &str, args: &[String], disable: bool) -> Result<RunPlan, RunError> {
140    let path = Path::new(target);
141
142    // package.json passed explicitly, or a directory containing one.
143    if path.file_name().is_some_and(|n| n == "package.json") {
144        return node_package(path, args, disable);
145    }
146    if path.is_dir() {
147        let manifest = path.join("package.json");
148        if manifest.exists() {
149            return node_package(&manifest, args, disable);
150        }
151        return resolve_directory(target, path, args, disable);
152    }
153    if !path.exists() {
154        return Err(RunError::NotFound {
155            target: target.to_owned(),
156        });
157    }
158
159    match path.extension().and_then(|e| e.to_str()) {
160        Some("py") => Ok(python_plan(target, args, disable)),
161        Some(ext) if NODE_EXTS.contains(&ext) => node_plan(target, args, disable),
162        _ => Err(RunError::UnknownKind {
163            target: target.to_owned(),
164        }),
165    }
166}
167
168fn python_plan(target: &str, extra: &[String], disable: bool) -> RunPlan {
169    let mut argv = vec![
170        "-m".to_owned(),
171        "keel".to_owned(),
172        "run".to_owned(),
173        target.to_owned(),
174    ];
175    argv.extend_from_slice(extra);
176    RunPlan {
177        program: "python3".to_owned(),
178        argv,
179        disable,
180    }
181}
182
183/// Pin a Node target as a path operand, never a Node option. Node parses any
184/// argv entry beginning with `-` (before the entry point) as a flag, so a file
185/// literally named `--inspect-brk=0.0.0.0:9229.js` — a valid filename that
186/// passes the exists/extension checks — would open an unauthenticated debug port
187/// instead of running as a script. Prefixing a relative target with `./`
188/// (absolute paths and already-dot-prefixed paths are left as-is) makes it
189/// unambiguously a path. The Python path is immune (its target lands after
190/// `-m keel run`).
191fn as_script_operand(target: &str) -> String {
192    if target.starts_with('/') || target.starts_with("./") || target.starts_with("../") {
193        target.to_owned()
194    } else {
195        format!("./{target}")
196    }
197}
198
199fn node_plan(target: &str, extra: &[String], disable: bool) -> Result<RunPlan, RunError> {
200    if !keelrun_resolvable(Path::new(target)) {
201        return Err(RunError::MissingKeelrun {
202            target: target.to_owned(),
203        });
204    }
205    let mut argv = vec![
206        "--import".to_owned(),
207        NODE_HOOK.to_owned(),
208        as_script_operand(target),
209    ];
210    argv.extend_from_slice(extra);
211    Ok(RunPlan {
212        program: "node".to_owned(),
213        argv,
214        disable,
215    })
216}
217
218/// Whether `keelrun` would resolve from `target`, mirroring Node's own
219/// `node_modules` walk-up: starting at `target`'s containing directory
220/// (canonicalized, so a bare relative filename resolves against the real
221/// current directory), check `<dir>/node_modules/keelrun`, then each parent
222/// in turn up to and including the filesystem root.
223fn keelrun_resolvable(target: &Path) -> bool {
224    let start = if target.is_dir() {
225        target.to_path_buf()
226    } else {
227        match target.parent() {
228            Some(p) if !p.as_os_str().is_empty() => p.to_path_buf(),
229            _ => PathBuf::from("."),
230        }
231    };
232    let mut dir = std::fs::canonicalize(&start).unwrap_or(start);
233    loop {
234        if dir.join("node_modules").join("keelrun").is_dir() {
235            return true;
236        }
237        if !dir.pop() {
238            return false;
239        }
240    }
241}
242
243/// Resolve a package's entry file from its `package.json` `main` (default
244/// `index.js`), then dispatch it via the Node path.
245fn node_package(manifest: &Path, args: &[String], disable: bool) -> Result<RunPlan, RunError> {
246    let dir = manifest.parent().unwrap_or_else(|| Path::new("."));
247    let text = std::fs::read_to_string(manifest).map_err(|_| RunError::NoEntry {
248        target: manifest.display().to_string(),
249    })?;
250    let main = serde_json::from_str::<serde_json::Value>(&text)
251        .ok()
252        .and_then(|v| v.get("main").and_then(|m| m.as_str()).map(str::to_owned))
253        .unwrap_or_else(|| "index.js".to_owned());
254    let entry: PathBuf = dir.join(main);
255    if !entry.exists() {
256        return Err(RunError::NoEntry {
257            target: manifest.display().to_string(),
258        });
259    }
260    node_plan(&entry.to_string_lossy(), args, disable)
261}
262
263/// Conventional entry file names tried, in order, before falling back to a
264/// directory walk.
265const PY_CONVENTIONAL_ENTRIES: &[&str] = &["main.py", "__main__.py"];
266const NODE_CONVENTIONAL_ENTRIES: &[&str] = &[
267    "index.mjs",
268    "index.js",
269    "index.cjs",
270    "index.ts",
271    "index.mts",
272    "index.cts",
273];
274
275/// Resolve a directory target with no `package.json`: a conventional entry
276/// name first, then — if exactly one Python or Node source file sits
277/// directly inside the directory (not recursively; a nested script is never
278/// guessed at) — that file. Ambiguous or empty is a precise error, never a
279/// silent guess (dx-spec's "a Level 0 surprise is a P0 bug" invariant).
280fn resolve_directory(
281    target: &str,
282    dir: &Path,
283    args: &[String],
284    disable: bool,
285) -> Result<RunPlan, RunError> {
286    for name in PY_CONVENTIONAL_ENTRIES {
287        let candidate = dir.join(name);
288        if candidate.is_file() {
289            return Ok(python_plan(&candidate.to_string_lossy(), args, disable));
290        }
291    }
292    for name in NODE_CONVENTIONAL_ENTRIES {
293        let candidate = dir.join(name);
294        if candidate.is_file() {
295            return node_plan(&candidate.to_string_lossy(), args, disable);
296        }
297    }
298
299    let py_files = top_level_files_with_extension(dir, &["py"]);
300    let node_files = top_level_files_with_extension(dir, NODE_EXTS);
301    let mut candidates: Vec<PathBuf> = py_files.iter().chain(&node_files).cloned().collect();
302    candidates.sort();
303
304    match candidates.as_slice() {
305        [] => Err(RunError::UnknownKind {
306            target: target.to_owned(),
307        }),
308        [only] => {
309            if py_files.contains(only) {
310                Ok(python_plan(&only.to_string_lossy(), args, disable))
311            } else {
312                node_plan(&only.to_string_lossy(), args, disable)
313            }
314        }
315        many => Err(RunError::AmbiguousEntry {
316            target: target.to_owned(),
317            candidates: many.iter().map(|p| p.display().to_string()).collect(),
318        }),
319    }
320}
321
322/// Regular files directly inside `dir` (no recursion into subdirectories)
323/// whose extension is one of `extensions`, sorted. Deliberately shallow —
324/// unlike [`crate::scan::collect_files`]'s recursive project-wide scan, `keel
325/// run`'s directory disambiguation only ever considers the top level.
326fn top_level_files_with_extension(dir: &Path, extensions: &[&str]) -> Vec<PathBuf> {
327    let mut out = Vec::new();
328    let Ok(entries) = std::fs::read_dir(dir) else {
329        return out;
330    };
331    for entry in entries.flatten() {
332        let path = entry.path();
333        if path.is_file()
334            && path
335                .extension()
336                .and_then(|e| e.to_str())
337                .is_some_and(|e| extensions.contains(&e))
338        {
339            out.push(path);
340        }
341    }
342    out.sort();
343    out
344}
345
346/// Execute a [`RunPlan`], inheriting the environment (so `KEEL_*` passes
347/// through). Returns the child's exit code, or a rendered spawn error.
348pub fn exec(plan: &RunPlan) -> Result<i32, Rendered> {
349    exec_with(plan, |_cmd| {})
350}
351
352/// Like [`exec`], but lets the caller layer extra environment onto the child
353/// before it spawns (`keel record run` sets `KEEL_RECORD` this way — see
354/// `crate::record`). `exec` is exactly `exec_with(plan, |_| {})`.
355pub(crate) fn exec_with(
356    plan: &RunPlan,
357    configure: impl FnOnce(&mut Command),
358) -> Result<i32, Rendered> {
359    let mut cmd = Command::new(&plan.program);
360    cmd.args(&plan.argv);
361    if plan.disable {
362        cmd.env("KEEL_DISABLE", "1");
363    }
364    configure(&mut cmd);
365    match cmd.status() {
366        Ok(status) => Ok(status.code().unwrap_or(EXIT_FAILURE)),
367        Err(err) => {
368            let what = format!("Cannot run `{}`: {err}.", plan.program);
369            let why = format!(
370                "`{}` was not found on PATH or could not be started.",
371                plan.program
372            );
373            let next = if plan.program == "python3" {
374                "Install Python 3 and the `keelrun` package (`pip install keelrun`)."
375            } else {
376                "Install Node.js and the `keelrun` package (`npm i -D keelrun`)."
377            };
378            let human = format!("keel \u{25b8} {what}\n  why:  {why}\n  next: {next}");
379            let report = RunErrorReport {
380                error: "spawn-failed",
381                next,
382                what: &what,
383                why: &why,
384            };
385            Err(Rendered {
386                human,
387                json: crate::render::to_json(&report),
388                exit: EXIT_FAILURE,
389                to_stderr: true,
390            })
391        }
392    }
393}
394
395/// The whole `keel run` command: plan, then exec. On a dispatch error render it;
396/// on success return the child's exit code.
397pub fn run(target: &str, args: &[String], disable: bool) -> (Option<Rendered>, i32) {
398    match plan(target, args, disable) {
399        Err(e) => {
400            let r = e.render();
401            let code = r.exit;
402            (Some(r), code)
403        }
404        Ok(plan) => match exec(&plan) {
405            Ok(code) => (None, code),
406            Err(r) => {
407                let code = r.exit;
408                (Some(r), code)
409            }
410        },
411    }
412}
413
414#[cfg(test)]
415mod tests {
416    use super::*;
417    use std::fs;
418    use tempfile::TempDir;
419
420    #[test]
421    fn python_target_dispatches_to_python_module() {
422        let dir = TempDir::new().unwrap();
423        let script = dir.path().join("app.py");
424        fs::write(&script, "print('hi')\n").unwrap();
425        let plan = plan(&script.to_string_lossy(), &["--flag".into()], false).unwrap();
426        assert_eq!(plan.program, "python3");
427        assert_eq!(
428            plan.argv,
429            vec![
430                "-m",
431                "keel",
432                "run",
433                script.to_string_lossy().as_ref(),
434                "--flag"
435            ]
436        );
437    }
438
439    #[test]
440    fn node_target_dispatches_with_hook_import() {
441        let dir = TempDir::new().unwrap();
442        let script = dir.path().join("app.mjs");
443        fs::write(&script, "console.log('hi')\n").unwrap();
444        fs::create_dir_all(dir.path().join("node_modules").join("keelrun")).unwrap();
445        let plan = plan(&script.to_string_lossy(), &[], true).unwrap();
446        assert_eq!(plan.program, "node");
447        assert_eq!(plan.argv[0], "--import");
448        assert_eq!(plan.argv[1], "keelrun/hook");
449        assert!(plan.disable);
450    }
451
452    #[test]
453    fn node_target_without_keelrun_installed_is_a_precise_error() {
454        // keelrun-cli alone (no `node_modules/keelrun`) — the documented
455        // CLI-only install gap (issue #33): node would spawn fine and then
456        // die inside its own ESM loader with a raw, unbranded error.
457        let dir = TempDir::new().unwrap();
458        let script = dir.path().join("app.mjs");
459        fs::write(&script, "console.log('hi')\n").unwrap();
460        let err = plan(&script.to_string_lossy(), &[], false).unwrap_err();
461        assert_eq!(
462            err,
463            RunError::MissingKeelrun {
464                target: script.to_string_lossy().into_owned()
465            }
466        );
467        let rendered = err.render();
468        assert_eq!(rendered.exit, EXIT_USAGE);
469        assert!(rendered.human.contains("npm install keelrun"));
470        assert_eq!(rendered.json["error"], "missing-keelrun");
471    }
472
473    #[test]
474    fn node_target_finds_keelrun_in_an_ancestor_node_modules() {
475        // Mirrors Node's own walk-up resolution: `node_modules/keelrun` one
476        // level above the script's directory still resolves.
477        let dir = TempDir::new().unwrap();
478        fs::create_dir_all(dir.path().join("node_modules").join("keelrun")).unwrap();
479        let sub = dir.path().join("src");
480        fs::create_dir_all(&sub).unwrap();
481        let script = sub.join("app.mjs");
482        fs::write(&script, "console.log('hi')\n").unwrap();
483        let plan = plan(&script.to_string_lossy(), &[], false).unwrap();
484        assert_eq!(plan.program, "node");
485    }
486
487    #[test]
488    fn node_dash_named_target_is_pinned_as_a_path_operand() {
489        // A relative target that would parse as a Node option is prefixed with
490        // `./`; absolute and dot-prefixed paths are already unambiguous.
491        assert_eq!(
492            as_script_operand("--inspect-brk=0.0.0.0:9229.js"),
493            "./--inspect-brk=0.0.0.0:9229.js"
494        );
495        assert_eq!(as_script_operand("app.mjs"), "./app.mjs");
496        assert_eq!(as_script_operand("sub/app.mjs"), "./sub/app.mjs");
497        assert_eq!(as_script_operand("/abs/app.mjs"), "/abs/app.mjs");
498        assert_eq!(as_script_operand("./app.mjs"), "./app.mjs");
499        assert_eq!(as_script_operand("../app.mjs"), "../app.mjs");
500    }
501
502    #[test]
503    fn package_json_main_is_resolved() {
504        let dir = TempDir::new().unwrap();
505        fs::write(
506            dir.path().join("package.json"),
507            "{ \"main\": \"start.mjs\" }",
508        )
509        .unwrap();
510        fs::write(dir.path().join("start.mjs"), "// entry\n").unwrap();
511        fs::create_dir_all(dir.path().join("node_modules").join("keelrun")).unwrap();
512        let plan = plan(&dir.path().to_string_lossy(), &[], false).unwrap();
513        assert_eq!(plan.program, "node");
514        assert!(plan.argv[2].ends_with("start.mjs"));
515    }
516
517    #[test]
518    fn package_json_defaults_to_index_js() {
519        let dir = TempDir::new().unwrap();
520        fs::write(dir.path().join("package.json"), "{}").unwrap();
521        fs::write(dir.path().join("index.js"), "// entry\n").unwrap();
522        fs::create_dir_all(dir.path().join("node_modules").join("keelrun")).unwrap();
523        let plan = plan(&dir.path().to_string_lossy(), &[], false).unwrap();
524        assert!(plan.argv[2].ends_with("index.js"));
525    }
526
527    #[test]
528    fn missing_file_is_not_found() {
529        assert_eq!(
530            plan("does-not-exist.py", &[], false),
531            Err(RunError::NotFound {
532                target: "does-not-exist.py".into()
533            })
534        );
535    }
536
537    #[test]
538    fn unknown_extension_is_a_precise_error() {
539        let dir = TempDir::new().unwrap();
540        let f = dir.path().join("script.rb");
541        fs::write(&f, "puts 1\n").unwrap();
542        let err = plan(&f.to_string_lossy(), &[], false).unwrap_err();
543        assert!(matches!(err, RunError::UnknownKind { .. }));
544        let rendered = err.render();
545        assert_eq!(rendered.exit, EXIT_USAGE);
546        assert!(rendered.human.contains("next:"));
547        assert_eq!(rendered.json["error"], "unknown-kind");
548    }
549
550    #[test]
551    fn package_dir_without_entry_errors() {
552        let dir = TempDir::new().unwrap();
553        fs::write(dir.path().join("package.json"), "{ \"main\": \"nope.js\" }").unwrap();
554        let err = plan(&dir.path().to_string_lossy(), &[], false).unwrap_err();
555        assert!(matches!(err, RunError::NoEntry { .. }));
556    }
557
558    #[test]
559    fn dir_with_conventional_python_entry_resolves_to_main_py() {
560        let dir = TempDir::new().unwrap();
561        fs::write(dir.path().join("main.py"), "print('hi')\n").unwrap();
562        fs::write(dir.path().join("helpers.py"), "# not the entry\n").unwrap();
563        let plan = plan(&dir.path().to_string_lossy(), &[], false).unwrap();
564        assert_eq!(plan.program, "python3");
565        assert!(plan.argv.last().unwrap().ends_with("main.py"));
566    }
567
568    #[test]
569    fn dir_with_dunder_main_resolves_when_no_main_py() {
570        let dir = TempDir::new().unwrap();
571        fs::write(dir.path().join("__main__.py"), "print('hi')\n").unwrap();
572        let plan = plan(&dir.path().to_string_lossy(), &[], false).unwrap();
573        assert!(plan.argv.last().unwrap().ends_with("__main__.py"));
574    }
575
576    #[test]
577    fn dir_with_conventional_node_entry_resolves_without_package_json() {
578        let dir = TempDir::new().unwrap();
579        fs::write(dir.path().join("index.mjs"), "// entry\n").unwrap();
580        fs::create_dir_all(dir.path().join("node_modules").join("keelrun")).unwrap();
581        let plan = plan(&dir.path().to_string_lossy(), &[], false).unwrap();
582        assert_eq!(plan.program, "node");
583        assert!(plan.argv[2].ends_with("index.mjs"));
584    }
585
586    #[test]
587    fn dir_with_sole_python_file_resolves_by_walk() {
588        let dir = TempDir::new().unwrap();
589        fs::write(dir.path().join("pipeline.py"), "print('hi')\n").unwrap();
590        let plan = plan(&dir.path().to_string_lossy(), &[], false).unwrap();
591        assert_eq!(plan.program, "python3");
592        assert!(plan.argv.last().unwrap().ends_with("pipeline.py"));
593    }
594
595    #[test]
596    fn dir_with_multiple_candidates_is_ambiguous() {
597        let dir = TempDir::new().unwrap();
598        fs::write(dir.path().join("a.py"), "print(1)\n").unwrap();
599        fs::write(dir.path().join("b.py"), "print(2)\n").unwrap();
600        let err = plan(&dir.path().to_string_lossy(), &[], false).unwrap_err();
601        assert!(matches!(err, RunError::AmbiguousEntry { .. }));
602        let rendered = err.render();
603        assert_eq!(rendered.exit, EXIT_USAGE);
604        assert_eq!(rendered.json["error"], "ambiguous-entry");
605    }
606
607    #[test]
608    fn empty_dir_is_still_unknown_kind() {
609        let dir = TempDir::new().unwrap();
610        let err = plan(&dir.path().to_string_lossy(), &[], false).unwrap_err();
611        assert!(matches!(err, RunError::UnknownKind { .. }));
612    }
613
614    #[test]
615    fn nested_scripts_are_never_guessed_at() {
616        // A subdirectory's scripts are not candidates — resolution is
617        // top-level only, so an otherwise-empty dir with only nested files
618        // still errors rather than reaching into a subdirectory.
619        let dir = TempDir::new().unwrap();
620        let sub = dir.path().join("nested");
621        fs::create_dir(&sub).unwrap();
622        fs::write(sub.join("deep.py"), "print('hi')\n").unwrap();
623        let err = plan(&dir.path().to_string_lossy(), &[], false).unwrap_err();
624        assert!(matches!(err, RunError::UnknownKind { .. }));
625    }
626
627    #[test]
628    fn child_exit_code_propagates() {
629        // `keel run` is invisible on the success path — the child's exit code is
630        // the process's exit code (dx-spec §1). Drive the exec path directly with
631        // a shell that exits 7.
632        let plan = RunPlan {
633            program: "sh".to_owned(),
634            argv: vec!["-c".to_owned(), "exit 7".to_owned()],
635            disable: false,
636        };
637        assert_eq!(exec(&plan).expect("sh should spawn"), 7);
638    }
639
640    #[test]
641    fn exec_with_layers_extra_env_onto_the_child() {
642        // `keel record run` (crate::record) relies on this to thread
643        // `KEEL_RECORD` into the child without duplicating `exec`'s spawn
644        // logic — prove the closure actually reaches the child's environment.
645        let plan = RunPlan {
646            program: "sh".to_owned(),
647            argv: vec![
648                "-c".to_owned(),
649                "[ \"$KEEL_RECORD_TEST\" = \"marker\" ] && exit 0 || exit 9".to_owned(),
650            ],
651            disable: false,
652        };
653        let code = exec_with(&plan, |cmd| {
654            cmd.env("KEEL_RECORD_TEST", "marker");
655        })
656        .expect("sh should spawn");
657        assert_eq!(code, 0);
658    }
659
660    #[test]
661    fn spawn_failure_is_a_framed_error_with_exit_1() {
662        // A program that cannot be spawned surfaces a what/why/next error on
663        // stderr and exit 1 (an underlying failure, not a usage error).
664        let plan = RunPlan {
665            program: "keel-nonexistent-program-9f3a".to_owned(),
666            argv: vec![],
667            disable: false,
668        };
669        let rendered = exec(&plan).expect_err("nonexistent program cannot spawn");
670
671        assert_eq!(rendered.exit, EXIT_FAILURE);
672        assert!(rendered.to_stderr);
673        assert_eq!(rendered.json["error"], "spawn-failed");
674        // The human message is framed: what (keel ▸ …), why, and next.
675        assert!(rendered.human.starts_with("keel \u{25b8} "));
676        assert!(rendered.human.contains("keel-nonexistent-program-9f3a"));
677        assert!(rendered.human.contains("why:"));
678        assert!(rendered.human.contains("next:"));
679    }
680}