lumen_runtime/
app_layout.rs1use lumen_modules::language::LanguageTable;
15use std::path::{Path, PathBuf};
16
17use crate::config::LumenToml;
18
19pub const SRC: &str = "src";
21
22pub const LIB: &str = "lib";
24
25const DEFAULT_ENTRY: &str = "main.lmn";
27
28const STYLESHEET: &str = "main.css";
30
31const CODE_EXTS: [&str; 2] = ["lmn", "css"];
34
35const LISTED_FILES: usize = 3;
37
38#[derive(Debug, Clone, PartialEq, Eq)]
40pub struct AppLayout {
41 pub src_dir: PathBuf,
43 pub entry_path: PathBuf,
45 pub css_path: PathBuf,
47 pub lib_dir: PathBuf,
51}
52
53impl AppLayout {
54 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 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
87pub fn src_dir(dir: &Path) -> PathBuf {
89 dir.join(SRC)
90}
91
92#[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 pub dir: PathBuf,
108 pub files: Vec<String>,
110}
111
112fn 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 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 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 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 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}