1use serde::Deserialize;
32use std::path::{Path, PathBuf};
33use std::process::{Command, ExitCode};
34
35#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize)]
37#[serde(rename_all = "lowercase")]
38pub enum AppKind {
39 #[default]
42 Markup,
43 Rust,
46 Cpp,
48 Python,
51}
52
53pub fn resolve(dir: &Path, override_kind: Option<AppKind>) -> AppKind {
56 override_kind.unwrap_or_else(|| detect(dir))
57}
58
59pub fn detect(dir: &Path) -> AppKind {
62 if is_rust(dir) {
63 AppKind::Rust
64 } else if is_cpp(dir) {
65 AppKind::Cpp
66 } else if is_python(dir) {
67 AppKind::Python
68 } else {
69 AppKind::Markup
70 }
71}
72
73fn is_rust(dir: &Path) -> bool {
77 match std::fs::read_to_string(dir.join("Cargo.toml")) {
78 Ok(src) => cargo_depends_on_lumen(&src),
79 Err(_) => false,
80 }
81}
82
83fn cargo_depends_on_lumen(src: &str) -> bool {
88 if let Ok(val) = toml::from_str::<toml::Value>(src) {
92 if table_has_lumen_dep(&val) {
93 return true;
94 }
95 if let Some(targets) = val.get("target").and_then(|v| v.as_table()) {
97 for tv in targets.values() {
98 if table_has_lumen_dep(tv) {
99 return true;
100 }
101 }
102 }
103 return false;
104 }
105 src.lines().any(|line| {
107 let t = line.trim_start();
108 t.starts_with("lumen ")
109 || t.starts_with("lumen=")
110 || t.starts_with("lumen.")
111 || t.starts_with("lumenui")
112 || t.starts_with("\"lumen\"")
113 || t.starts_with("\"lumenui\"")
114 })
115}
116
117fn table_has_lumen_dep(val: &toml::Value) -> bool {
120 for table in ["dependencies", "dev-dependencies", "build-dependencies"] {
121 if let Some(t) = val.get(table).and_then(|v| v.as_table())
122 && t.keys().any(|k| is_lumen_dep_key(k))
123 {
124 return true;
125 }
126 }
127 false
128}
129
130fn is_lumen_dep_key(key: &str) -> bool {
132 key == "lumen" || key == "lumenui"
133}
134
135fn is_cpp(dir: &Path) -> bool {
137 if dir.join("CMakeLists.txt").is_file() {
138 return true;
139 }
140 dir_has_matching_file(dir, &["cpp", "cc", "cxx"], |body| {
141 body.contains("lumen.hpp") || body.contains("lumen.h") || body.contains("<lumen")
142 })
143}
144
145fn is_python(dir: &Path) -> bool {
147 if dir_has_matching_file(dir, &["py"], |body| {
148 body.contains("import lumen") || body.contains("from lumen")
149 }) {
150 return true;
151 }
152 match std::fs::read_to_string(dir.join("pyproject.toml")) {
153 Ok(src) => src.contains("lumen"),
154 Err(_) => false,
155 }
156}
157
158fn dir_has_matching_file(dir: &Path, exts: &[&str], pred: impl Fn(&str) -> bool) -> bool {
161 let Ok(entries) = std::fs::read_dir(dir) else {
162 return false;
163 };
164 for entry in entries.flatten() {
165 let path = entry.path();
166 let has_ext = path
167 .extension()
168 .and_then(|e| e.to_str())
169 .is_some_and(|e| exts.iter().any(|want| e.eq_ignore_ascii_case(want)));
170 if has_ext
171 && let Ok(body) = std::fs::read_to_string(&path)
172 && pred(&body)
173 {
174 return true;
175 }
176 }
177 false
178}
179
180#[derive(Debug, Clone, Copy, PartialEq, Eq)]
184pub enum Mode {
185 Run,
187 Build,
189}
190
191#[derive(Debug, Clone, PartialEq, Eq)]
195pub struct CommandSpec {
196 pub program: String,
198 pub args: Vec<String>,
200 pub cwd: PathBuf,
202 pub env: Vec<(String, String)>,
204}
205
206impl CommandSpec {
207 fn new(program: &str, cwd: &Path) -> Self {
208 Self {
209 program: program.to_string(),
210 args: Vec::new(),
211 cwd: cwd.to_path_buf(),
212 env: Vec::new(),
213 }
214 }
215
216 fn arg(mut self, a: impl Into<String>) -> Self {
217 self.args.push(a.into());
218 self
219 }
220
221 fn envv(mut self, k: impl Into<String>, v: impl Into<String>) -> Self {
222 self.env.push((k.into(), v.into()));
223 self
224 }
225
226 fn to_command(&self) -> Command {
228 let mut cmd = Command::new(&self.program);
229 cmd.args(&self.args).current_dir(&self.cwd);
230 for (k, v) in &self.env {
231 cmd.env(k, v);
232 }
233 cmd
234 }
235}
236
237pub fn dispatch(kind: AppKind, dir: &Path, mode: Mode) -> Result<Vec<CommandSpec>, String> {
244 match kind {
245 AppKind::Markup => {
246 Err("markup apps use the built-in runtime, not an external toolchain".into())
247 }
248 AppKind::Rust => Ok(rust_specs(dir, mode)),
249 AppKind::Cpp => Ok(cpp_specs(dir, mode)),
250 AppKind::Python => python_specs(dir, mode),
251 }
252}
253
254fn rust_specs(dir: &Path, mode: Mode) -> Vec<CommandSpec> {
257 let mut spec = match mode {
258 Mode::Run => CommandSpec::new("cargo", dir).arg("run"),
259 Mode::Build => CommandSpec::new("cargo", dir).arg("build").arg("--release"),
260 };
261 for (key, value) in rust_dynamic_env(None) {
262 spec = spec.envv(key, value);
263 }
264 vec![spec]
265}
266
267pub fn rust_dynamic_env(target: Option<&str>) -> Vec<(String, String)> {
290 let (is_macos, is_windows) = match target {
291 Some(triple) => (triple.contains("apple"), triple.contains("windows")),
292 None => (cfg!(target_os = "macos"), cfg!(target_os = "windows")),
293 };
294 if is_windows {
295 return Vec::new();
296 }
297 let rpath = if is_macos { "@loader_path" } else { "$ORIGIN" };
298 let flags = format!("-C prefer-dynamic -C link-arg=-Wl,-rpath,{rpath}");
299 let combined = match std::env::var("RUSTFLAGS") {
300 Ok(existing) if !existing.trim().is_empty() => format!("{existing} {flags}"),
301 _ => flags,
302 };
303 vec![("RUSTFLAGS".to_string(), combined)]
304}
305
306fn cpp_specs(dir: &Path, mode: Mode) -> Vec<CommandSpec> {
310 let configure = match mode {
311 Mode::Run => CommandSpec::new("cmake", dir).arg("-B").arg("build"),
312 Mode::Build => CommandSpec::new("cmake", dir)
313 .arg("-B")
314 .arg("build")
315 .arg("-DCMAKE_BUILD_TYPE=Release"),
316 };
317 let build = CommandSpec::new("cmake", dir).arg("--build").arg("build");
318 vec![configure, build]
319}
320
321fn python_specs(dir: &Path, mode: Mode) -> Result<Vec<CommandSpec>, String> {
326 match mode {
327 Mode::Build => Ok(Vec::new()),
328 Mode::Run => {
329 let entry = python_entry(dir)?;
330 let mut spec = CommandSpec::new(python_program(), dir).arg(entry);
331 if let Ok(target) = std::env::var("CARGO_TARGET_DIR") {
332 let lib_dir = Path::new(&target).join("debug");
333 spec = spec.envv("LUMEN_LIBRARY_PATH", lib_dir.to_string_lossy().to_string());
334 }
335 Ok(vec![spec])
336 }
337 }
338}
339
340fn python_program() -> &'static str {
343 if cfg!(windows) { "python" } else { "python3" }
344}
345
346pub fn python_entry(dir: &Path) -> Result<String, String> {
349 for candidate in ["main.py", "app.py", "__main__.py", "run.py"] {
350 if dir.join(candidate).is_file() {
351 return Ok(candidate.to_string());
352 }
353 }
354 let Ok(entries) = std::fs::read_dir(dir) else {
356 return Err(format!("cannot read app directory {}", dir.display()));
357 };
358 let mut hits: Vec<String> = Vec::new();
359 for entry in entries.flatten() {
360 let path = entry.path();
361 let is_py = path
362 .extension()
363 .and_then(|e| e.to_str())
364 .is_some_and(|e| e.eq_ignore_ascii_case("py"));
365 if is_py
366 && let Ok(body) = std::fs::read_to_string(&path)
367 && (body.contains("import lumen") || body.contains("from lumen"))
368 && let Some(name) = path.file_name().and_then(|n| n.to_str())
369 {
370 hits.push(name.to_string());
371 }
372 }
373 match hits.len() {
374 0 => Err(format!(
375 "no Python entry script found in {} (expected main.py / app.py, \
376 or a single .py importing lumen)",
377 dir.display()
378 )),
379 1 => Ok(hits.remove(0)),
380 _ => Err(format!(
381 "multiple Python entry candidates in {} ({}); add a main.py or set \
382 [app] entry",
383 dir.display(),
384 hits.join(", ")
385 )),
386 }
387}
388
389fn execute(specs: &[CommandSpec]) -> ExitCode {
394 for spec in specs {
395 let shown = if spec.args.is_empty() {
396 spec.program.clone()
397 } else {
398 format!("{} {}", spec.program, spec.args.join(" "))
399 };
400 eprintln!("lumenc: {} (in {})", shown, spec.cwd.display());
401 match spec.to_command().status() {
402 Ok(status) if status.success() => {}
403 Ok(status) => {
404 let code = status.code().unwrap_or(1);
405 return ExitCode::from(code.clamp(0, 255) as u8);
406 }
407 Err(e) => {
408 eprintln!("lumenc: failed to spawn `{}`: {e}", spec.program);
409 return ExitCode::FAILURE;
410 }
411 }
412 }
413 ExitCode::SUCCESS
414}
415
416pub fn run_app_external(kind: AppKind, dir: &Path) -> ExitCode {
418 let specs = match dispatch(kind, dir, Mode::Run) {
419 Ok(specs) => specs,
420 Err(e) => {
421 eprintln!("lumenc run: {e}");
422 return ExitCode::from(2);
423 }
424 };
425 let code = execute(&specs);
426 if kind == AppKind::Cpp {
427 eprintln!(
433 "lumenc run: built the CMake project in {}. Launch the produced \
434 binary from {}/build (binary name is project-defined).",
435 dir.display(),
436 dir.display()
437 );
438 }
439 code
440}
441
442pub fn build_app_external(kind: AppKind, dir: &Path) -> ExitCode {
444 let specs = match dispatch(kind, dir, Mode::Build) {
445 Ok(specs) => specs,
446 Err(e) => {
447 eprintln!("lumenc build: {e}");
448 return ExitCode::from(2);
449 }
450 };
451 if specs.is_empty() {
452 eprintln!(
454 "lumenc build: {kind:?} apps have no compile step; run it directly \
455 with `lumenc run {}`.",
456 dir.display()
457 );
458 return ExitCode::SUCCESS;
459 }
460 execute(&specs)
461}
462
463#[cfg(test)]
464mod tests {
465 use super::*;
466 use std::sync::atomic::{AtomicU32, Ordering};
467
468 fn scratch(tag: &str) -> PathBuf {
470 static N: AtomicU32 = AtomicU32::new(0);
471 let n = N.fetch_add(1, Ordering::Relaxed);
472 let dir = std::env::temp_dir().join(format!(
473 "lumenc-appkind-{}-{}-{}",
474 std::process::id(),
475 tag,
476 n
477 ));
478 let _ = std::fs::remove_dir_all(&dir);
479 std::fs::create_dir_all(&dir).unwrap();
480 dir
481 }
482
483 fn write(dir: &Path, name: &str, body: &str) {
484 std::fs::write(dir.join(name), body).unwrap();
485 }
486
487 #[test]
488 fn detects_rust_from_cargo_dep() {
489 let dir = scratch("rust");
490 write(
491 &dir,
492 "Cargo.toml",
493 "[package]\nname = \"demo\"\n\n[dependencies]\nlumen = { path = \"../..\" }\n",
494 );
495 write(&dir, "main.lmn", "<root/>");
497 assert_eq!(detect(&dir), AppKind::Rust);
498 let _ = std::fs::remove_dir_all(&dir);
499 }
500
501 #[test]
502 fn detects_rust_from_ffi_dep() {
503 let dir = scratch("rust-ffi");
504 write(
505 &dir,
506 "Cargo.toml",
507 "[package]\nname = \"demo\"\n\n[dependencies]\nlumenui = \"0.4\"\n",
508 );
509 assert_eq!(detect(&dir), AppKind::Rust);
510 let _ = std::fs::remove_dir_all(&dir);
511 }
512
513 #[test]
514 fn cargo_without_lumen_is_markup() {
515 let dir = scratch("plain-cargo");
516 write(
517 &dir,
518 "Cargo.toml",
519 "[package]\nname = \"demo\"\n\n[dependencies]\nserde = \"1\"\n",
520 );
521 write(&dir, "main.lmn", "<root/>");
522 assert_eq!(detect(&dir), AppKind::Markup);
523 let _ = std::fs::remove_dir_all(&dir);
524 }
525
526 #[test]
527 fn detects_cpp_from_cmake() {
528 let dir = scratch("cpp-cmake");
529 write(&dir, "CMakeLists.txt", "project(demo)\n");
530 assert_eq!(detect(&dir), AppKind::Cpp);
531 let _ = std::fs::remove_dir_all(&dir);
532 }
533
534 #[test]
535 fn detects_cpp_from_source_include() {
536 let dir = scratch("cpp-src");
537 write(&dir, "main.cpp", "#include <lumen.hpp>\nint main(){}\n");
538 assert_eq!(detect(&dir), AppKind::Cpp);
539 let _ = std::fs::remove_dir_all(&dir);
540 }
541
542 #[test]
543 fn detects_python_from_import() {
544 let dir = scratch("py");
545 write(&dir, "app.py", "import lumen\n");
546 assert_eq!(detect(&dir), AppKind::Python);
547 let _ = std::fs::remove_dir_all(&dir);
548 }
549
550 #[test]
551 fn detects_markup_fallback() {
552 let dir = scratch("markup");
553 write(&dir, "main.lmn", "<root/>");
554 write(&dir, "lumen.toml", "[app]\nentry = \"main.lmn\"\n");
555 assert_eq!(detect(&dir), AppKind::Markup);
556 let _ = std::fs::remove_dir_all(&dir);
557 }
558
559 #[test]
560 fn rust_precedence_over_markup() {
561 let dir = scratch("rust-prec");
562 write(&dir, "Cargo.toml", "[dependencies]\nlumen = \"0\"\n");
563 write(&dir, "main.lmn", "<root/>");
564 write(&dir, "CMakeLists.txt", "project(x)\n");
565 assert_eq!(detect(&dir), AppKind::Rust);
566 let _ = std::fs::remove_dir_all(&dir);
567 }
568
569 #[test]
570 fn resolve_override_wins() {
571 let dir = scratch("override");
572 write(&dir, "main.lmn", "<root/>"); assert_eq!(resolve(&dir, Some(AppKind::Rust)), AppKind::Rust);
574 assert_eq!(resolve(&dir, None), AppKind::Markup);
575 let _ = std::fs::remove_dir_all(&dir);
576 }
577
578 #[test]
579 fn rust_run_command_shape() {
580 let dir = scratch("rust-run-cmd");
581 let specs = dispatch(AppKind::Rust, &dir, Mode::Run).unwrap();
582 assert_eq!(specs.len(), 1);
583 assert_eq!(specs[0].program, "cargo");
584 assert_eq!(specs[0].args, vec!["run"]);
585 assert_eq!(specs[0].cwd, dir);
586 let _ = std::fs::remove_dir_all(&dir);
587 }
588
589 #[test]
590 fn rust_build_command_shape() {
591 let dir = scratch("rust-build-cmd");
592 let specs = dispatch(AppKind::Rust, &dir, Mode::Build).unwrap();
593 assert_eq!(specs.len(), 1);
594 assert_eq!(specs[0].program, "cargo");
595 assert_eq!(specs[0].args, vec!["build", "--release"]);
596 let _ = std::fs::remove_dir_all(&dir);
597 }
598
599 #[test]
600 fn cpp_build_command_sequence() {
601 let dir = scratch("cpp-build-cmd");
602 let specs = dispatch(AppKind::Cpp, &dir, Mode::Build).unwrap();
603 assert_eq!(specs.len(), 2);
604 assert_eq!(specs[0].program, "cmake");
605 assert_eq!(
606 specs[0].args,
607 vec!["-B", "build", "-DCMAKE_BUILD_TYPE=Release"]
608 );
609 assert_eq!(specs[1].args, vec!["--build", "build"]);
610 let _ = std::fs::remove_dir_all(&dir);
611 }
612
613 #[test]
614 fn cpp_run_command_sequence() {
615 let dir = scratch("cpp-run-cmd");
616 let specs = dispatch(AppKind::Cpp, &dir, Mode::Run).unwrap();
617 assert_eq!(specs.len(), 2);
618 assert_eq!(specs[0].args, vec!["-B", "build"]);
619 assert_eq!(specs[1].args, vec!["--build", "build"]);
620 let _ = std::fs::remove_dir_all(&dir);
621 }
622
623 #[test]
624 fn python_run_command_shape() {
625 let dir = scratch("py-run-cmd");
626 write(&dir, "main.py", "import lumen\n");
627 let specs = dispatch(AppKind::Python, &dir, Mode::Run).unwrap();
628 assert_eq!(specs.len(), 1);
629 assert_eq!(specs[0].program, python_program());
630 assert_eq!(specs[0].args, vec!["main.py"]);
631 assert_eq!(specs[0].cwd, dir);
632 let _ = std::fs::remove_dir_all(&dir);
633 }
634
635 #[test]
636 fn python_entry_prefers_conventional_name() {
637 let dir = scratch("py-entry");
638 write(&dir, "helper.py", "import lumen\n");
639 write(&dir, "app.py", "import lumen\n");
640 assert_eq!(python_entry(&dir).unwrap(), "app.py");
641 let _ = std::fs::remove_dir_all(&dir);
642 }
643
644 #[test]
645 fn python_entry_missing_errors() {
646 let dir = scratch("py-empty");
647 assert!(python_entry(&dir).is_err());
648 let _ = std::fs::remove_dir_all(&dir);
649 }
650
651 #[test]
652 fn python_build_is_noop() {
653 let dir = scratch("py-build");
654 let specs = dispatch(AppKind::Python, &dir, Mode::Build).unwrap();
655 assert!(specs.is_empty());
656 let _ = std::fs::remove_dir_all(&dir);
657 }
658
659 #[test]
660 fn markup_dispatch_errors() {
661 let dir = scratch("markup-dispatch");
662 assert!(dispatch(AppKind::Markup, &dir, Mode::Run).is_err());
663 let _ = std::fs::remove_dir_all(&dir);
664 }
665}