1use std::path::{Path, PathBuf};
16use std::process::Command;
17
18use serde::Serialize;
19
20use crate::{EXIT_FAILURE, EXIT_USAGE, Rendered};
21
22const NODE_HOOK: &str = "keel/hook";
26
27#[derive(Debug, Clone, PartialEq, Eq)]
31pub struct RunPlan {
32 pub program: String,
34 pub argv: Vec<String>,
36 pub disable: bool,
38}
39
40#[derive(Debug, Clone, PartialEq, Eq)]
42pub enum RunError {
43 NotFound { target: String },
45 UnknownKind { target: String },
47 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#[derive(Debug, Serialize)]
91struct RunErrorReport<'a> {
92 error: &'static str,
93 next: &'a str,
94 what: &'a str,
95 why: &'a str,
96}
97
98const NODE_EXTS: &[&str] = &["mjs", "js", "ts", "cjs", "mts", "cts", "jsx", "tsx"];
100
101pub fn plan(target: &str, args: &[String], disable: bool) -> Result<RunPlan, RunError> {
104 let path = Path::new(target);
105
106 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
149fn 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
179fn 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
199pub fn exec(plan: &RunPlan) -> Result<i32, Rendered> {
202 exec_with(plan, |_cmd| {})
203}
204
205pub(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
248pub 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 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 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 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 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 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}