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 dir / `package.json`     → resolve its `main`, then the Node path
9//! - anything else              → a precise what/why/next error, exit 2
10//!
11//! The child inherits the environment (so every `KEEL_*` var passes through);
12//! `--disable` layers `KEEL_DISABLE=1` on top. The child's exit code is the
13//! process's exit code — wrapping is invisible on the success path.
14
15use std::path::{Path, PathBuf};
16use std::process::Command;
17
18use serde::Serialize;
19
20use crate::{EXIT_FAILURE, EXIT_USAGE, Rendered};
21
22/// Node's resolver name for the preload hook (the `keel` package's `./hook`
23/// export). Resolved from the project's `node_modules`, exactly as
24/// `node --import keel/hook` would in a project that installed `keel`.
25const NODE_HOOK: &str = "keel/hook";
26
27/// The concrete plan: which interpreter to exec with which argv, and whether to
28/// disable Keel in the child. Pure data, so dispatch is unit-testable without
29/// spawning anything.
30#[derive(Debug, Clone, PartialEq, Eq)]
31pub struct RunPlan {
32    /// The program to exec (`python3` or `node`).
33    pub program: String,
34    /// Its full argument vector (excluding `program` itself).
35    pub argv: Vec<String>,
36    /// Whether to set `KEEL_DISABLE=1` in the child.
37    pub disable: bool,
38}
39
40/// Why a target could not be dispatched — each rendered as what/why/next.
41#[derive(Debug, Clone, PartialEq, Eq)]
42pub enum RunError {
43    /// The target file/dir does not exist.
44    NotFound { target: String },
45    /// The extension is not one `keel run` knows how to dispatch.
46    UnknownKind { target: String },
47    /// A Node package dir/`package.json` had no resolvable entry file.
48    NoEntry { target: String },
49}
50
51impl RunError {
52    pub(crate) fn render(&self) -> Rendered {
53        let (what, why, next, kind) = match self {
54            Self::NotFound { target } => (
55                format!("Cannot run `{target}`: no such file or directory."),
56                "The path does not exist relative to the current directory.".to_owned(),
57                "Check the path; `keel run` takes a script file, a package.json, or a project directory.".to_owned(),
58                "not-found",
59            ),
60            Self::UnknownKind { target } => (
61                format!("Cannot run `{target}`: unrecognized program type."),
62                "`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(),
63                "Rename to a supported extension, point at the project's package.json, or invoke the interpreter directly.".to_owned(),
64                "unknown-kind",
65            ),
66            Self::NoEntry { target } => (
67                format!("Cannot run `{target}`: no entry file found."),
68                "The directory/package.json has no resolvable `main` (and no index.js).".to_owned(),
69                "Add a `main` to package.json, or pass the entry script directly.".to_owned(),
70                "no-entry",
71            ),
72        };
73        let human = format!("keel \u{25b8} {what}\n  why:  {why}\n  next: {next}");
74        let report = RunErrorReport {
75            error: kind,
76            next: &next,
77            what: &what,
78            why: &why,
79        };
80        Rendered {
81            human,
82            json: crate::render::to_json(&report),
83            exit: EXIT_USAGE,
84            to_stderr: true,
85        }
86    }
87}
88
89/// The machine twin of a dispatch failure.
90#[derive(Debug, Serialize)]
91struct RunErrorReport<'a> {
92    error: &'static str,
93    next: &'a str,
94    what: &'a str,
95    why: &'a str,
96}
97
98/// Node source extensions `keel run` dispatches.
99const NODE_EXTS: &[&str] = &["mjs", "js", "ts", "cjs", "mts", "cts", "jsx", "tsx"];
100
101/// Build the [`RunPlan`] for `target` and `args`. Reads the filesystem to
102/// classify the target and (for a package) to resolve its entry file.
103pub fn plan(target: &str, args: &[String], disable: bool) -> Result<RunPlan, RunError> {
104    let path = Path::new(target);
105
106    // package.json passed explicitly, or a directory containing one.
107    if path.file_name().is_some_and(|n| n == "package.json") {
108        return node_package(path, args, disable);
109    }
110    if path.is_dir() {
111        let manifest = path.join("package.json");
112        if manifest.exists() {
113            return node_package(&manifest, args, disable);
114        }
115        return Err(RunError::UnknownKind {
116            target: target.to_owned(),
117        });
118    }
119    if !path.exists() {
120        return Err(RunError::NotFound {
121            target: target.to_owned(),
122        });
123    }
124
125    match path.extension().and_then(|e| e.to_str()) {
126        Some("py") => Ok(python_plan(target, args, disable)),
127        Some(ext) if NODE_EXTS.contains(&ext) => Ok(node_plan(target, args, disable)),
128        _ => Err(RunError::UnknownKind {
129            target: target.to_owned(),
130        }),
131    }
132}
133
134fn python_plan(target: &str, extra: &[String], disable: bool) -> RunPlan {
135    let mut argv = vec![
136        "-m".to_owned(),
137        "keel".to_owned(),
138        "run".to_owned(),
139        target.to_owned(),
140    ];
141    argv.extend_from_slice(extra);
142    RunPlan {
143        program: "python3".to_owned(),
144        argv,
145        disable,
146    }
147}
148
149/// Pin a Node target as a path operand, never a Node option. Node parses any
150/// argv entry beginning with `-` (before the entry point) as a flag, so a file
151/// literally named `--inspect-brk=0.0.0.0:9229.js` — a valid filename that
152/// passes the exists/extension checks — would open an unauthenticated debug port
153/// instead of running as a script. Prefixing a relative target with `./`
154/// (absolute paths and already-dot-prefixed paths are left as-is) makes it
155/// unambiguously a path. The Python path is immune (its target lands after
156/// `-m keel run`).
157fn as_script_operand(target: &str) -> String {
158    if target.starts_with('/') || target.starts_with("./") || target.starts_with("../") {
159        target.to_owned()
160    } else {
161        format!("./{target}")
162    }
163}
164
165fn node_plan(target: &str, extra: &[String], disable: bool) -> RunPlan {
166    let mut argv = vec![
167        "--import".to_owned(),
168        NODE_HOOK.to_owned(),
169        as_script_operand(target),
170    ];
171    argv.extend_from_slice(extra);
172    RunPlan {
173        program: "node".to_owned(),
174        argv,
175        disable,
176    }
177}
178
179/// Resolve a package's entry file from its `package.json` `main` (default
180/// `index.js`), then dispatch it via the Node path.
181fn node_package(manifest: &Path, args: &[String], disable: bool) -> Result<RunPlan, RunError> {
182    let dir = manifest.parent().unwrap_or_else(|| Path::new("."));
183    let text = std::fs::read_to_string(manifest).map_err(|_| RunError::NoEntry {
184        target: manifest.display().to_string(),
185    })?;
186    let main = serde_json::from_str::<serde_json::Value>(&text)
187        .ok()
188        .and_then(|v| v.get("main").and_then(|m| m.as_str()).map(str::to_owned))
189        .unwrap_or_else(|| "index.js".to_owned());
190    let entry: PathBuf = dir.join(main);
191    if !entry.exists() {
192        return Err(RunError::NoEntry {
193            target: manifest.display().to_string(),
194        });
195    }
196    Ok(node_plan(&entry.to_string_lossy(), args, disable))
197}
198
199/// Execute a [`RunPlan`], inheriting the environment (so `KEEL_*` passes
200/// through). Returns the child's exit code, or a rendered spawn error.
201pub fn exec(plan: &RunPlan) -> Result<i32, Rendered> {
202    exec_with(plan, |_cmd| {})
203}
204
205/// Like [`exec`], but lets the caller layer extra environment onto the child
206/// before it spawns (`keel record run` sets `KEEL_RECORD` this way — see
207/// `crate::record`). `exec` is exactly `exec_with(plan, |_| {})`.
208pub(crate) fn exec_with(
209    plan: &RunPlan,
210    configure: impl FnOnce(&mut Command),
211) -> Result<i32, Rendered> {
212    let mut cmd = Command::new(&plan.program);
213    cmd.args(&plan.argv);
214    if plan.disable {
215        cmd.env("KEEL_DISABLE", "1");
216    }
217    configure(&mut cmd);
218    match cmd.status() {
219        Ok(status) => Ok(status.code().unwrap_or(EXIT_FAILURE)),
220        Err(err) => {
221            let what = format!("Cannot run `{}`: {err}.", plan.program);
222            let why = format!(
223                "`{}` was not found on PATH or could not be started.",
224                plan.program
225            );
226            let next = if plan.program == "python3" {
227                "Install Python 3 and the `keel` package (`pip install keel`)."
228            } else {
229                "Install Node.js and the `keel` package (`npm i -D keel`)."
230            };
231            let human = format!("keel \u{25b8} {what}\n  why:  {why}\n  next: {next}");
232            let report = RunErrorReport {
233                error: "spawn-failed",
234                next,
235                what: &what,
236                why: &why,
237            };
238            Err(Rendered {
239                human,
240                json: crate::render::to_json(&report),
241                exit: EXIT_FAILURE,
242                to_stderr: true,
243            })
244        }
245    }
246}
247
248/// The whole `keel run` command: plan, then exec. On a dispatch error render it;
249/// on success return the child's exit code.
250pub fn run(target: &str, args: &[String], disable: bool) -> (Option<Rendered>, i32) {
251    match plan(target, args, disable) {
252        Err(e) => {
253            let r = e.render();
254            let code = r.exit;
255            (Some(r), code)
256        }
257        Ok(plan) => match exec(&plan) {
258            Ok(code) => (None, code),
259            Err(r) => {
260                let code = r.exit;
261                (Some(r), code)
262            }
263        },
264    }
265}
266
267#[cfg(test)]
268mod tests {
269    use super::*;
270    use std::fs;
271    use tempfile::TempDir;
272
273    #[test]
274    fn python_target_dispatches_to_python_module() {
275        let dir = TempDir::new().unwrap();
276        let script = dir.path().join("app.py");
277        fs::write(&script, "print('hi')\n").unwrap();
278        let plan = plan(&script.to_string_lossy(), &["--flag".into()], false).unwrap();
279        assert_eq!(plan.program, "python3");
280        assert_eq!(
281            plan.argv,
282            vec![
283                "-m",
284                "keel",
285                "run",
286                script.to_string_lossy().as_ref(),
287                "--flag"
288            ]
289        );
290    }
291
292    #[test]
293    fn node_target_dispatches_with_hook_import() {
294        let dir = TempDir::new().unwrap();
295        let script = dir.path().join("app.mjs");
296        fs::write(&script, "console.log('hi')\n").unwrap();
297        let plan = plan(&script.to_string_lossy(), &[], true).unwrap();
298        assert_eq!(plan.program, "node");
299        assert_eq!(plan.argv[0], "--import");
300        assert_eq!(plan.argv[1], "keel/hook");
301        assert!(plan.disable);
302    }
303
304    #[test]
305    fn node_dash_named_target_is_pinned_as_a_path_operand() {
306        // A relative target that would parse as a Node option is prefixed with
307        // `./`; absolute and dot-prefixed paths are already unambiguous.
308        assert_eq!(
309            as_script_operand("--inspect-brk=0.0.0.0:9229.js"),
310            "./--inspect-brk=0.0.0.0:9229.js"
311        );
312        assert_eq!(as_script_operand("app.mjs"), "./app.mjs");
313        assert_eq!(as_script_operand("sub/app.mjs"), "./sub/app.mjs");
314        assert_eq!(as_script_operand("/abs/app.mjs"), "/abs/app.mjs");
315        assert_eq!(as_script_operand("./app.mjs"), "./app.mjs");
316        assert_eq!(as_script_operand("../app.mjs"), "../app.mjs");
317    }
318
319    #[test]
320    fn package_json_main_is_resolved() {
321        let dir = TempDir::new().unwrap();
322        fs::write(
323            dir.path().join("package.json"),
324            "{ \"main\": \"start.mjs\" }",
325        )
326        .unwrap();
327        fs::write(dir.path().join("start.mjs"), "// entry\n").unwrap();
328        let plan = plan(&dir.path().to_string_lossy(), &[], false).unwrap();
329        assert_eq!(plan.program, "node");
330        assert!(plan.argv[2].ends_with("start.mjs"));
331    }
332
333    #[test]
334    fn package_json_defaults_to_index_js() {
335        let dir = TempDir::new().unwrap();
336        fs::write(dir.path().join("package.json"), "{}").unwrap();
337        fs::write(dir.path().join("index.js"), "// entry\n").unwrap();
338        let plan = plan(&dir.path().to_string_lossy(), &[], false).unwrap();
339        assert!(plan.argv[2].ends_with("index.js"));
340    }
341
342    #[test]
343    fn missing_file_is_not_found() {
344        assert_eq!(
345            plan("does-not-exist.py", &[], false),
346            Err(RunError::NotFound {
347                target: "does-not-exist.py".into()
348            })
349        );
350    }
351
352    #[test]
353    fn unknown_extension_is_a_precise_error() {
354        let dir = TempDir::new().unwrap();
355        let f = dir.path().join("script.rb");
356        fs::write(&f, "puts 1\n").unwrap();
357        let err = plan(&f.to_string_lossy(), &[], false).unwrap_err();
358        assert!(matches!(err, RunError::UnknownKind { .. }));
359        let rendered = err.render();
360        assert_eq!(rendered.exit, EXIT_USAGE);
361        assert!(rendered.human.contains("next:"));
362        assert_eq!(rendered.json["error"], "unknown-kind");
363    }
364
365    #[test]
366    fn package_dir_without_entry_errors() {
367        let dir = TempDir::new().unwrap();
368        fs::write(dir.path().join("package.json"), "{ \"main\": \"nope.js\" }").unwrap();
369        let err = plan(&dir.path().to_string_lossy(), &[], false).unwrap_err();
370        assert!(matches!(err, RunError::NoEntry { .. }));
371    }
372
373    #[test]
374    fn child_exit_code_propagates() {
375        // `keel run` is invisible on the success path — the child's exit code is
376        // the process's exit code (dx-spec §1). Drive the exec path directly with
377        // a shell that exits 7.
378        let plan = RunPlan {
379            program: "sh".to_owned(),
380            argv: vec!["-c".to_owned(), "exit 7".to_owned()],
381            disable: false,
382        };
383        assert_eq!(exec(&plan).expect("sh should spawn"), 7);
384    }
385
386    #[test]
387    fn exec_with_layers_extra_env_onto_the_child() {
388        // `keel record run` (crate::record) relies on this to thread
389        // `KEEL_RECORD` into the child without duplicating `exec`'s spawn
390        // logic — prove the closure actually reaches the child's environment.
391        let plan = RunPlan {
392            program: "sh".to_owned(),
393            argv: vec![
394                "-c".to_owned(),
395                "[ \"$KEEL_RECORD_TEST\" = \"marker\" ] && exit 0 || exit 9".to_owned(),
396            ],
397            disable: false,
398        };
399        let code = exec_with(&plan, |cmd| {
400            cmd.env("KEEL_RECORD_TEST", "marker");
401        })
402        .expect("sh should spawn");
403        assert_eq!(code, 0);
404    }
405
406    #[test]
407    fn spawn_failure_is_a_framed_error_with_exit_1() {
408        // A program that cannot be spawned surfaces a what/why/next error on
409        // stderr and exit 1 (an underlying failure, not a usage error).
410        let plan = RunPlan {
411            program: "keel-nonexistent-program-9f3a".to_owned(),
412            argv: vec![],
413            disable: false,
414        };
415        let rendered = exec(&plan).expect_err("nonexistent program cannot spawn");
416
417        assert_eq!(rendered.exit, EXIT_FAILURE);
418        assert!(rendered.to_stderr);
419        assert_eq!(rendered.json["error"], "spawn-failed");
420        // The human message is framed: what (keel ▸ …), why, and next.
421        assert!(rendered.human.starts_with("keel \u{25b8} "));
422        assert!(rendered.human.contains("keel-nonexistent-program-9f3a"));
423        assert!(rendered.human.contains("why:"));
424        assert!(rendered.human.contains("next:"));
425    }
426}