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