Skip to main content

lumen_runtime/
app_kind.rs

1//! App-kind detection and build/run dispatch for SDK-authored apps.
2//!
3//! `lumenc`'s CLI historically assumed every app directory was a pure-markup
4//! app (`main.lmn` + `lumen.toml`) driven by the built-in runtime. The Rust,
5//! C++, and Python SDKs bypass the CLI entirely today: a Rust app is a cargo
6//! bin depending on the `lumenui` crate, a C++ app is a CMake project linking
7//! `liblumen`, and a Python app is a script importing `lumen`.
8//!
9//! This module closes that gap. [`detect`] inspects an app directory and
10//! classifies it as [`AppKind::Markup`], [`AppKind::Rust`], [`AppKind::Cpp`],
11//! or [`AppKind::Python`]. The CLI (`lumenc run` / `lumenc build`) resolves the
12//! kind - an optional `[app] kind` override in `lumen.toml` wins, otherwise
13//! auto-detection decides - and reroutes non-markup apps to their native
14//! toolchain (`cargo`, `cmake`, the Python interpreter) instead of the markup
15//! runtime.
16//!
17//! # Detection precedence
18//!
19//! SDK project markers take precedence over the markup fallback, because SDK
20//! app directories *also* carry `main.lmn` / `lumen.toml` for the runtime to
21//! load at boot. The order is Rust, then C++, then Python, then Markup:
22//!
23//! 1. **Rust** - `Cargo.toml` whose dependency tables name `lumenui` or
24//!    `lumen`.
25//! 2. **C++** - a `CMakeLists.txt`, or any `.cpp`/`.cc`/`.cxx` that includes a
26//!    `lumen` header.
27//! 3. **Python** - any `.py` that imports `lumen`, or a `pyproject.toml`
28//!    mentioning `lumen`.
29//! 4. **Markup** - the fallback (`main.lmn` + optional `main.css`).
30
31use serde::Deserialize;
32use std::path::{Path, PathBuf};
33use std::process::{Command, ExitCode};
34
35/// How a Lumen app directory is authored. Drives the CLI's build/run reroute.
36#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize)]
37#[serde(rename_all = "lowercase")]
38pub enum AppKind {
39    /// Pure-markup app: `main.lmn` (+ optional `main.css` / inline `<script>`)
40    /// driven by the built-in `lumenc` runtime. The default.
41    #[default]
42    Markup,
43    /// Rust SDK app: a cargo bin depending on the `lumenui` crate. Built/run
44    /// via `cargo`.
45    Rust,
46    /// C++ SDK app: a CMake project linking `liblumen`. Built via `cmake`.
47    Cpp,
48    /// Python SDK app: a script importing `lumen` (ctypes over `liblumen`).
49    /// Run via the Python interpreter.
50    Python,
51}
52
53/// Resolve the effective kind for `dir`: an explicit override (from
54/// `lumen.toml`'s `[app] kind`) always wins; otherwise [`detect`] decides.
55pub fn resolve(dir: &Path, override_kind: Option<AppKind>) -> AppKind {
56    override_kind.unwrap_or_else(|| detect(dir))
57}
58
59/// Classify the app directory by inspecting its files. See the module docs for
60/// the precedence rules and heuristics.
61pub 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
73// -- Per-kind detectors ------------------------------------------------------
74
75/// A `Cargo.toml` whose dependency tables reference `lumenui` / `lumen`.
76fn 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
83/// True when `Cargo.toml` source declares a `lumenui` or `lumen` dependency
84/// in any of its dependency tables (including `[target.*]` blocks). Parses the
85/// manifest as TOML; falls back to a line scan when the manifest is partial /
86/// unparseable.
87fn cargo_depends_on_lumen(src: &str) -> bool {
88    // `toml::from_str` parses a document; `str::parse::<Value>` parses a single
89    // TOML value and would reject every real manifest, silently dropping this
90    // function to its line-scan fallback.
91    if let Ok(val) = toml::from_str::<toml::Value>(src) {
92        if table_has_lumen_dep(&val) {
93            return true;
94        }
95        // Platform-gated deps: [target.'cfg(...)'.dependencies] etc.
96        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    // Unparseable manifest (edited mid-flight): conservative line scan.
106    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
117/// Inspect the `dependencies` / `dev-dependencies` / `build-dependencies`
118/// tables of a manifest (or a `[target.*]` sub-table) for a `lumen` dep key.
119fn 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
130/// A dependency key that identifies a Lumen SDK app.
131fn is_lumen_dep_key(key: &str) -> bool {
132    key == "lumen" || key == "lumenui"
133}
134
135/// A `CMakeLists.txt`, or a C++ source that includes a `lumen` header.
136fn 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
145/// A `.py` importing `lumen`, or a `pyproject.toml` naming `lumen`.
146fn 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
158/// True when `dir` holds at least one immediate-child file with one of `exts`
159/// whose contents satisfy `pred`. Non-recursive; unreadable files are skipped.
160fn 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// -- Dispatch: build the external toolchain command sequence per kind ---------
181
182/// Whether the CLI is dispatching a `run` or a `build`.
183#[derive(Debug, Clone, Copy, PartialEq, Eq)]
184pub enum Mode {
185    /// `lumenc run <dir>` - launch the app.
186    Run,
187    /// `lumenc build <dir>` - produce a release binary/artifact.
188    Build,
189}
190
191/// A single external process to spawn: program, args, working directory, and
192/// extra environment. Kept as plain data (rather than a live [`Command`]) so
193/// the dispatch tables are cheap to unit-test by asserting on argv / cwd.
194#[derive(Debug, Clone, PartialEq, Eq)]
195pub struct CommandSpec {
196    /// Executable name (resolved via `PATH`).
197    pub program: String,
198    /// Ordered arguments.
199    pub args: Vec<String>,
200    /// Working directory the child runs in.
201    pub cwd: PathBuf,
202    /// Extra environment overrides applied on top of the inherited env.
203    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    /// Materialise into a runnable [`Command`].
227    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
237/// Build the external command sequence for `kind` in `mode`, run from `dir`.
238///
239/// Returns `Err` for [`AppKind::Markup`] (the caller must take the built-in
240/// runtime path instead) and for unresolved inputs (e.g. a Python app with no
241/// discoverable entry script). An `Ok(vec![])` is a valid no-op (e.g. a Python
242/// `build`, which has nothing to compile).
243pub 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
254/// Rust: `cargo run` (Run) / `cargo build --release` (Build), in the app dir.
255/// The Rust bin reaches the Lumen runtime through the `lumen` crate itself.
256fn 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
267/// The environment a Rust app is compiled in so that it links the shared Lumen
268/// engine instead of compiling a copy of it into itself.
269///
270/// Two flags do it. `-C prefer-dynamic` tells the compiler to take the shared
271/// form of a crate wherever one exists, which is what makes the app pick the
272/// engine library over the static one beside it; it also applies to the Rust
273/// standard library, so the app and the engine end up sharing one copy of that
274/// too rather than each carrying its own. The second adds the app's own
275/// directory to the list the loader searches, so the shared libraries sitting
276/// beside a packaged executable are found without a `LD_LIBRARY_PATH` in front
277/// of it.
278///
279/// Windows gets neither, and the returned environment is empty there. No
280/// linkable engine is built for it - a shared library exporting a graph this
281/// large is past what its import-library format can describe - so a Rust app
282/// on Windows carries the runtime inside its own binary, which is what the
283/// absence of these flags produces.
284///
285/// `target` is a Rust target triple to cross-compile for, or `None` for this
286/// machine. Anything already in `RUSTFLAGS` is kept and these are added after
287/// it, because setting the variable at all makes cargo ignore the `rustflags`
288/// an app's own config may have set.
289pub 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
306/// C++: drive CMake. Configure then build; the Release toggle switches the
307/// build type. Launching the resulting binary is left to the caller (its name
308/// is project-defined) - see the TODO in [`run_app_external`].
309fn 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
321/// Python: `python3 <entry.py>` for Run; nothing to compile for Build.
322/// `LUMEN_LIBRARY_PATH` is seeded from `CARGO_TARGET_DIR` when set so the
323/// ctypes loader (`sdk/python/lumen/_ffi.py`) finds `liblumen`; absent
324/// that, the loader's own search (cwd `target/`, workspace walk) still applies.
325fn 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
340/// The Python interpreter name (`python3` everywhere except Windows, where
341/// `python` is the conventional launcher).
342fn python_program() -> &'static str {
343    if cfg!(windows) { "python" } else { "python3" }
344}
345
346/// Locate the Python entry script in `dir`. Prefers conventional names, then
347/// falls back to the sole `.py` that imports `lumen`.
348pub 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    // Fall back to a lone lumen-importing script.
355    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
389// -- Execution ---------------------------------------------------------------
390
391/// Spawn each spec in order, inheriting stdio. Stops at the first failure and
392/// surfaces the child's exit code.
393fn 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
416/// Reroute `lumenc run <dir>` to the SDK app's native toolchain.
417pub 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        // TODO(cpp-run): CMake produces one binary per project with a
428        // project-defined name; we cannot reliably know it here. For now we
429        // configure + build and leave launching to the developer. A follow-up
430        // can read the built target from `cmake --build`'s output or a
431        // `[app] entry` binary-name override and exec it.
432        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
442/// Reroute `lumenc build <dir>` to the SDK app's native toolchain.
443pub 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        // Python (and any future interpreted kind) has nothing to compile.
453        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    /// Unique scratch directory per call (no external tempfile dep).
469    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        // SDK app dirs also carry markup; detection must still say Rust.
496        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/>"); // would auto-detect Markup
573        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}