Skip to main content

lumen_runtime/
app_layout.rs

1//! Where an app keeps its code.
2//!
3//! An app is `lumen.toml` at its root and every code file - the markup, the
4//! stylesheet and the scripts - under `src/`, the same split a Cargo package
5//! uses. Assets, `locale/`, build outputs and anything a `[[hooks]]` command
6//! reads or writes stay at the root; `src/` is only what the compiler front-end
7//! opens. One root directory has a meaning of its own: `lib/`, where a script's
8//! `dylib` import finds its shared library.
9//!
10//! [`AppLayout::resolve`] is the one place that turns an app directory plus its
11//! `lumen.toml` into the paths the rest of the runtime reads, and the one place
12//! that rejects an app whose code is still at its root.
13
14use lumen_modules::language::LanguageTable;
15use std::path::{Path, PathBuf};
16
17use crate::config::LumenToml;
18
19/// Directory name an app keeps its code in.
20pub const SRC: &str = "src";
21
22/// Directory name an app keeps its native shared libraries in.
23pub const LIB: &str = "lib";
24
25/// Default markup entry, used when `[app] entry` is absent.
26const DEFAULT_ENTRY: &str = "main.lmn";
27
28/// The app stylesheet, always beside the entry.
29const STYLESHEET: &str = "main.css";
30
31/// Extensions that make a file code rather than an asset, besides the script
32/// extensions the language descriptors in reach declare.
33const CODE_EXTS: [&str; 2] = ["lmn", "css"];
34
35/// How many offending names the flat-layout message lists before it stops.
36const LISTED_FILES: usize = 3;
37
38/// The code paths of one app directory.
39#[derive(Debug, Clone, PartialEq, Eq)]
40pub struct AppLayout {
41    /// `<app>/src`, holding the markup, the stylesheet and the scripts.
42    pub src_dir: PathBuf,
43    /// The markup entry: `[app] entry` (default `main.lmn`) under `src/`.
44    pub entry_path: PathBuf,
45    /// The app stylesheet, `src/main.css`. Optional on disk.
46    pub css_path: PathBuf,
47    /// `<app>/lib`, where a script's `dylib` import finds its shared library
48    /// and where a `[[hooks]]` command that builds one writes it. Optional on
49    /// disk.
50    pub lib_dir: PathBuf,
51}
52
53impl AppLayout {
54    /// The paths for `dir`, with no look at what is on disk.
55    ///
56    /// For sources that never came from the app directory: in-memory markup
57    /// handed in by an SDK or a test, and a precompiled artifact, which carries
58    /// its code inside itself. Everything that reads the directory calls
59    /// [`Self::resolve`] instead.
60    pub fn of(dir: &Path, cfg: &LumenToml) -> Self {
61        let src_dir = src_dir(dir);
62        Self {
63            entry_path: src_dir.join(cfg.app.entry.as_deref().unwrap_or(DEFAULT_ENTRY)),
64            css_path: src_dir.join(STYLESHEET),
65            lib_dir: dir.join(LIB),
66            src_dir,
67        }
68    }
69
70    /// The paths for `dir`, rejecting an app whose code is still at its root.
71    ///
72    /// A directory with no code in it at all resolves fine: a packaged app has
73    /// its code compiled into the executable, and only its assets travel.
74    pub fn resolve(dir: &Path, cfg: &LumenToml) -> Result<Self, FlatLayout> {
75        let layout = Self::of(dir, cfg);
76        let stray = code_files(dir);
77        if !stray.is_empty() && code_files(&layout.src_dir).is_empty() {
78            return Err(FlatLayout {
79                dir: dir.to_path_buf(),
80                files: stray,
81            });
82        }
83        Ok(layout)
84    }
85}
86
87/// The directory `dir`'s code lives in.
88pub fn src_dir(dir: &Path) -> PathBuf {
89    dir.join(SRC)
90}
91
92/// The app's code sits at its root instead of under `src/`.
93///
94/// The message is the one migration hint: every path that resolves a layout
95/// reports this text, so an author sees the same instruction from `run`,
96/// `check`, `build`, `package` and the C ABI alike.
97#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
98#[error(
99    "{}: found code files at the app root ({}). Lumen apps keep their code \
100     under src/. Move the .lmn, .css and script files into src/; assets and \
101     locale/ stay at the app root.",
102    .dir.display(),
103    .files.join(", ")
104)]
105pub struct FlatLayout {
106    /// The app directory the code was found in.
107    pub dir: PathBuf,
108    /// The offending file names, sorted, up to the handful the message lists.
109    pub files: Vec<String>,
110}
111
112/// The code files directly in `dir`, sorted, capped at what the message lists.
113///
114/// Non-recursive on purpose: a nested directory of markup is what `src/` is,
115/// and an asset tree can be arbitrarily deep.
116fn code_files(dir: &Path) -> Vec<String> {
117    let Ok(rd) = std::fs::read_dir(dir) else {
118        return Vec::new();
119    };
120    let table = LanguageTable::discover(None).unwrap_or_default();
121    let script_exts = table.extensions();
122    let mut found: Vec<String> = rd
123        .flatten()
124        .map(|e| e.path())
125        .filter(|p| {
126            p.extension()
127                .and_then(|e| e.to_str())
128                .is_some_and(|e| CODE_EXTS.contains(&e) || script_exts.contains(&e))
129        })
130        .filter_map(|p| p.file_name().map(|n| n.to_string_lossy().into_owned()))
131        .collect();
132    found.sort();
133    found.truncate(LISTED_FILES);
134    found
135}
136
137#[cfg(test)]
138mod tests {
139    use super::*;
140
141    fn scratch(name: &str) -> PathBuf {
142        let dir = std::env::temp_dir().join(format!(
143            "lumen-app-layout-{}-{name}-{}",
144            std::process::id(),
145            std::time::SystemTime::now()
146                .duration_since(std::time::UNIX_EPOCH)
147                .map(|d| d.as_nanos())
148                .unwrap_or(0)
149        ));
150        std::fs::create_dir_all(&dir).expect("create scratch dir");
151        dir
152    }
153
154    fn write(dir: &Path, rel: &str, body: &str) {
155        let path = dir.join(rel);
156        if let Some(parent) = path.parent() {
157            std::fs::create_dir_all(parent).expect("create parent");
158        }
159        std::fs::write(path, body).expect("write file");
160    }
161
162    #[test]
163    fn the_entry_and_the_stylesheet_resolve_under_src() {
164        let dir = scratch("under-src");
165        write(&dir, "src/main.lmn", "<root/>");
166        let layout = AppLayout::resolve(&dir, &LumenToml::default()).expect("src layout resolves");
167        assert_eq!(layout.src_dir, dir.join("src"));
168        assert_eq!(layout.entry_path, dir.join("src").join("main.lmn"));
169        assert_eq!(layout.css_path, dir.join("src").join("main.css"));
170        // Native libraries are a build output, so they sit at the root beside
171        // the sources a hook builds them from.
172        assert_eq!(layout.lib_dir, dir.join("lib"));
173        let _ = std::fs::remove_dir_all(&dir);
174    }
175
176    #[test]
177    fn the_configured_entry_joins_src() {
178        let dir = scratch("entry-key");
179        write(&dir, "src/home.lmn", "<root/>");
180        let cfg: LumenToml = toml::from_str("[app]\nentry = \"home.lmn\"\n").expect("parse config");
181        let layout = AppLayout::resolve(&dir, &cfg).expect("src layout resolves");
182        assert_eq!(layout.entry_path, dir.join("src").join("home.lmn"));
183        let _ = std::fs::remove_dir_all(&dir);
184    }
185
186    #[test]
187    fn code_at_the_root_is_an_error_naming_the_files() {
188        let dir = scratch("flat");
189        write(&dir, "main.lmn", "<root/>");
190        write(&dir, "main.css", "root { bg: #000; }");
191        let err =
192            AppLayout::resolve(&dir, &LumenToml::default()).expect_err("flat app is rejected");
193        assert_eq!(err.files, vec!["main.css", "main.lmn"]);
194        let msg = err.to_string();
195        assert!(msg.contains("main.lmn"), "{msg}");
196        assert!(msg.contains("under src/"), "{msg}");
197        let _ = std::fs::remove_dir_all(&dir);
198    }
199
200    #[test]
201    fn a_directory_with_no_code_resolves() {
202        // A packaged app: its code is inside the executable, its assets
203        // travel beside it.
204        let dir = scratch("packaged");
205        write(&dir, "icons/app.png", "");
206        write(&dir, "lumen.toml", "[app]\nid = \"demo\"\n");
207        AppLayout::resolve(&dir, &LumenToml::default()).expect("an asset-only directory resolves");
208        let _ = std::fs::remove_dir_all(&dir);
209    }
210
211    #[test]
212    fn a_migrated_app_may_keep_stray_code_at_its_root() {
213        // Once `src/` holds the app's code, a leftover file at the root is
214        // something the app does not load rather than a broken layout.
215        let dir = scratch("migrated");
216        write(&dir, "src/main.lmn", "<root/>");
217        write(&dir, "notes.css", "");
218        AppLayout::resolve(&dir, &LumenToml::default()).expect("the src layout wins");
219        let _ = std::fs::remove_dir_all(&dir);
220    }
221
222    #[test]
223    fn an_sdk_source_tree_alone_does_not_count_as_migrated() {
224        // `src/main.rs` is the SDK app's own build source. The markup beside
225        // it is still at the root, so the app is flat.
226        let dir = scratch("sdk-flat");
227        write(&dir, "src/main.rs", "fn main() {}");
228        write(&dir, "main.lmn", "<root/>");
229        AppLayout::resolve(&dir, &LumenToml::default()).expect_err("flat app is rejected");
230        let _ = std::fs::remove_dir_all(&dir);
231    }
232}