1use std::path::{Path, PathBuf};
21use std::process::Command;
22
23use serde::Serialize;
24
25use crate::{EXIT_FAILURE, EXIT_USAGE, Rendered};
26
27const NODE_HOOK: &str = "keelrun/hook";
31
32#[derive(Debug, Clone, PartialEq, Eq)]
36pub struct RunPlan {
37 pub program: String,
39 pub argv: Vec<String>,
41 pub disable: bool,
43}
44
45#[derive(Debug, Clone, PartialEq, Eq)]
47pub enum RunError {
48 NotFound { target: String },
50 UnknownKind { target: String },
52 NoEntry { target: String },
54 AmbiguousEntry {
57 target: String,
58 candidates: Vec<String>,
59 },
60 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#[derive(Debug, Serialize)]
127struct RunErrorReport<'a> {
128 error: &'static str,
129 next: &'a str,
130 what: &'a str,
131 why: &'a str,
132}
133
134const NODE_EXTS: &[&str] = &["mjs", "js", "ts", "cjs", "mts", "cts", "jsx", "tsx"];
136
137pub fn plan(target: &str, args: &[String], disable: bool) -> Result<RunPlan, RunError> {
140 let path = Path::new(target);
141
142 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
183fn 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
218fn 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
243fn 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
263const 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
275fn 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
322fn 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
346pub fn exec(plan: &RunPlan) -> Result<i32, Rendered> {
349 exec_with(plan, |_cmd| {})
350}
351
352pub(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
395pub 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 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 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 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 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 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 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 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 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}