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}
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#[derive(Debug, Serialize)]
113struct RunErrorReport<'a> {
114 error: &'static str,
115 next: &'a str,
116 what: &'a str,
117 why: &'a str,
118}
119
120const NODE_EXTS: &[&str] = &["mjs", "js", "ts", "cjs", "mts", "cts", "jsx", "tsx"];
122
123pub fn plan(target: &str, args: &[String], disable: bool) -> Result<RunPlan, RunError> {
126 let path = Path::new(target);
127
128 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
169fn 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
199fn 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
219const 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
231fn 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
278fn 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
302pub fn exec(plan: &RunPlan) -> Result<i32, Rendered> {
305 exec_with(plan, |_cmd| {})
306}
307
308pub(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 `keelrun` package (`pip install keelrun`)."
331 } else {
332 "Install Node.js and the `keelrun` package (`npm i -D keelrun`)."
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
351pub 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], "keelrun/hook");
404 assert!(plan.disable);
405 }
406
407 #[test]
408 fn node_dash_named_target_is_pinned_as_a_path_operand() {
409 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 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 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 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 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 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}