1use std::fmt;
48use std::path::{Path, PathBuf};
49
50use crate::entry_point::config_file::toml_str::{unquote, Quotes};
51
52#[cfg(test)]
53mod tests;
54
55pub const FILE_NAME: &str = "rws.app.toml";
57
58pub const MAX_MANIFEST_SIZE: usize = 64 * 1024;
61
62const MAX_CARGO_TOML_SIZE: u64 = 1024 * 1024;
65
66#[derive(Clone, Debug, PartialEq, Eq)]
69pub struct AppPaths {
70 pub main: String,
72 pub lib: String,
74 pub routes: String,
76 pub models: String,
78 pub jobs: String,
80 pub migrations: String,
82 pub templates: String,
84 pub tests: String,
86 pub static_files: String,
88}
89
90impl Default for AppPaths {
91 fn default() -> Self {
92 AppPaths {
93 main: "src/main.rs".to_string(),
94 lib: "src/lib.rs".to_string(),
95 routes: "src/routes".to_string(),
96 models: "src/models".to_string(),
97 jobs: "src/jobs".to_string(),
98 migrations: "migrations".to_string(),
99 templates: "templates".to_string(),
100 tests: "tests".to_string(),
101 static_files: "static".to_string(),
102 }
103 }
104}
105
106pub const ROLES: [&str; 9] = ["main", "lib", "routes", "models", "jobs", "migrations", "templates", "tests", "static"];
109
110impl AppPaths {
111 pub fn entries(&self) -> [(&'static str, &str); 9] {
113 [
114 ("main", &self.main),
115 ("lib", &self.lib),
116 ("routes", &self.routes),
117 ("models", &self.models),
118 ("jobs", &self.jobs),
119 ("migrations", &self.migrations),
120 ("templates", &self.templates),
121 ("tests", &self.tests),
122 ("static", &self.static_files),
123 ]
124 }
125
126 pub fn get(&self, role: &str) -> Option<&str> {
128 self.entries().into_iter().find(|(r, _)| *r == role).map(|(_, p)| p)
129 }
130
131 fn slot(&mut self, role: &str) -> Option<&mut String> {
132 Some(match role {
133 "main" => &mut self.main,
134 "lib" => &mut self.lib,
135 "routes" => &mut self.routes,
136 "models" => &mut self.models,
137 "jobs" => &mut self.jobs,
138 "migrations" => &mut self.migrations,
139 "templates" => &mut self.templates,
140 "tests" => &mut self.tests,
141 "static" => &mut self.static_files,
142 _ => return None,
143 })
144 }
145}
146
147#[derive(Clone, Copy, Debug, PartialEq, Eq)]
150pub enum EntryKind {
151 File,
152 Dir,
153}
154
155impl EntryKind {
156 pub fn as_str(self) -> &'static str {
158 match self {
159 EntryKind::File => "file",
160 EntryKind::Dir => "dir",
161 }
162 }
163
164 fn of(role: &str) -> EntryKind {
165 if role == "main" || role == "lib" {
166 EntryKind::File
167 } else {
168 EntryKind::Dir
169 }
170 }
171}
172
173#[derive(Clone, Debug, PartialEq, Eq)]
175pub struct LayoutEntry {
176 pub role: &'static str,
178 pub path: String,
180 pub kind: EntryKind,
182 pub exists: bool,
184}
185
186#[derive(Clone, Debug, Default, PartialEq, Eq)]
190pub struct AppManifest {
191 pub name: String,
195 pub paths: AppPaths,
197 pub file: Option<PathBuf>,
200 pub warnings: Vec<String>,
202}
203
204#[derive(Clone, Debug, PartialEq, Eq)]
207pub struct ManifestError {
208 pub line: usize,
209 pub message: String,
210}
211
212impl ManifestError {
213 fn at(line: usize, message: impl Into<String>) -> Self {
214 ManifestError { line, message: message.into() }
215 }
216}
217
218impl fmt::Display for ManifestError {
219 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
220 if self.line == 0 {
221 write!(f, "{FILE_NAME}: {}", self.message)
222 } else {
223 write!(f, "{FILE_NAME} line {}: {}", self.line, self.message)
224 }
225 }
226}
227
228impl std::error::Error for ManifestError {}
229
230impl std::str::FromStr for AppManifest {
231 type Err = ManifestError;
232
233 fn from_str(text: &str) -> Result<Self, ManifestError> {
234 AppManifest::parse(text)
235 }
236}
237
238impl AppManifest {
239 pub fn conventional(name: impl Into<String>) -> Self {
241 AppManifest { name: name.into(), ..AppManifest::default() }
242 }
243
244 pub fn parse(text: &str) -> Result<Self, ManifestError> {
251 if text.len() > MAX_MANIFEST_SIZE {
252 return Err(ManifestError::at(0, format!("larger than {MAX_MANIFEST_SIZE} bytes")));
253 }
254 let text = text.strip_prefix('\u{feff}').unwrap_or(text);
255 let mut manifest = AppManifest::default();
256 let mut seen: Vec<String> = Vec::new();
257 let mut table = String::new();
258 for (index, raw) in text.lines().enumerate() {
259 let number = index + 1;
260 let line = strip_comment(raw).trim();
261 if line.is_empty() {
262 continue;
263 }
264 if line.starts_with("[[") {
265 return Err(ManifestError::at(number, "array tables ([[…]]) are not part of rws.app.toml; use [app] and [paths]"));
266 }
267 if let Some(rest) = line.strip_prefix('[') {
268 let Some(name) = rest.strip_suffix(']') else {
269 return Err(ManifestError::at(number, format!("`{line}` is not a table header; write [app] or [paths]")));
270 };
271 table = name.trim().to_string();
272 if table != "app" && table != "paths" {
273 manifest.warnings.push(format!("line {number}: unknown table [{table}] ignored (known: [app], [paths])"));
274 }
275 continue;
276 }
277 let Some((raw_key, raw_value)) = split_key_value(line) else {
278 return Err(ManifestError::at(number, format!("expected `key = \"value\"`, found `{line}`")));
279 };
280 let key = raw_key.trim();
281 if key.is_empty() {
282 return Err(ManifestError::at(number, "a key is missing before `=`"));
283 }
284 let (section, key) = match (table.is_empty(), key.split_once('.')) {
287 (true, Some((t, k))) => (t.trim().to_string(), k.trim()),
288 _ => (table.clone(), key),
289 };
290 if !section.is_empty() && section != "app" && section != "paths" {
291 if table.is_empty() {
292 manifest.warnings.push(format!("line {number}: unknown key `{section}.{key}` ignored"));
293 }
294 continue;
295 }
296 let value = string_value(raw_value.trim(), key, number)?;
297 let qualified = format!("{}.{key}", if section.is_empty() { "app" } else { §ion });
298 if seen.contains(&qualified) {
299 return Err(ManifestError::at(number, format!("`{key}` is set twice; keep one")));
300 }
301 match (section.as_str(), key) {
302 ("" | "app", "name") => {
303 if value.trim().is_empty() {
304 return Err(ManifestError::at(number, "`name` is empty; name the app or leave the key out"));
305 }
306 manifest.name = value.trim().to_string();
307 }
308 ("paths", role) => match manifest.paths.slot(role) {
309 Some(slot) => *slot = checked_path(&value, role, number)?,
310 None => manifest.warnings.push(format!(
311 "line {number}: unknown key `{role}` in [paths] ignored (known: {})",
312 ROLES.join(", ")
313 )),
314 },
315 (_, other) => manifest.warnings.push(format!("line {number}: unknown key `{other}` in [app] ignored (known: name)")),
316 }
317 seen.push(qualified);
318 }
319 Ok(manifest)
320 }
321
322 pub fn load(dir: impl AsRef<Path>) -> Result<Self, ManifestError> {
326 let dir = dir.as_ref();
327 let file = dir.join(FILE_NAME);
328 let mut manifest = match std::fs::metadata(&file) {
329 Err(e) if e.kind() == std::io::ErrorKind::NotFound => AppManifest::default(),
330 Err(e) => return Err(ManifestError::at(0, format!("{}: {e}", file.display()))),
331 Ok(meta) if !meta.is_file() => return Err(ManifestError::at(0, format!("{} is not a file", file.display()))),
332 Ok(meta) if meta.len() > MAX_MANIFEST_SIZE as u64 => {
333 return Err(ManifestError::at(0, format!("{} is larger than {MAX_MANIFEST_SIZE} bytes", file.display())))
334 }
335 Ok(_) => {
336 let bytes = std::fs::read(&file).map_err(|e| ManifestError::at(0, format!("{}: {e}", file.display())))?;
337 let text = String::from_utf8(bytes).map_err(|_| ManifestError::at(0, format!("{} is not UTF-8", file.display())))?;
338 let mut manifest = AppManifest::parse(&text)?;
339 manifest.file = Some(file);
340 manifest
341 }
342 };
343 if manifest.name.is_empty() {
344 manifest.name = cargo_package_name(dir)
345 .or_else(|| dir.canonicalize().ok().and_then(|d| d.file_name().map(|n| n.to_string_lossy().into_owned())))
346 .unwrap_or_default();
347 }
348 Ok(manifest)
349 }
350
351 pub fn layout(&self, dir: impl AsRef<Path>) -> Vec<LayoutEntry> {
354 let dir = dir.as_ref();
355 self.paths
356 .entries()
357 .into_iter()
358 .map(|(role, path)| {
359 let kind = EntryKind::of(role);
360 let full = dir.join(path);
361 let exists = match kind {
362 EntryKind::File => full.is_file(),
363 EntryKind::Dir => full.is_dir(),
364 };
365 LayoutEntry { role, path: path.to_string(), kind, exists }
366 })
367 .collect()
368 }
369}
370
371fn strip_comment(line: &str) -> &str {
373 let mut quotes = Quotes::default();
374 for (i, c) in line.char_indices() {
375 if !quotes.step(c) && c == '#' {
376 return &line[..i];
377 }
378 }
379 line
380}
381
382fn split_key_value(line: &str) -> Option<(&str, &str)> {
384 let mut quotes = Quotes::default();
385 for (i, c) in line.char_indices() {
386 if !quotes.step(c) && c == '=' {
387 return Some((&line[..i], &line[i + 1..]));
388 }
389 }
390 None
391}
392
393fn string_value(raw: &str, key: &str, line: usize) -> Result<String, ManifestError> {
395 if string_end(raw) != Some(raw.len()) {
397 return Err(ManifestError::at(line, format!("`{key}` must be a quoted string, e.g. {key} = \"…\" (found `{raw}`)")));
398 }
399 let value = unquote(raw);
400 if value.contains('\0') {
401 return Err(ManifestError::at(line, format!("`{key}` contains a NUL byte")));
402 }
403 Ok(value)
404}
405
406fn string_end(raw: &str) -> Option<usize> {
409 let mut chars = raw.char_indices();
410 let (_, quote) = chars.next()?;
411 if quote != '"' && quote != '\'' {
412 return None;
413 }
414 let mut escaped = false;
415 for (i, c) in chars {
416 if escaped {
417 escaped = false;
418 } else if quote == '"' && c == '\\' {
419 escaped = true;
420 } else if c == quote {
421 return Some(i + c.len_utf8());
422 }
423 }
424 None
425}
426
427fn checked_path(value: &str, role: &str, line: usize) -> Result<String, ManifestError> {
430 let path = value.trim();
431 let path = path.strip_prefix("./").unwrap_or(path);
432 let path = path.trim_end_matches(['/', '\\']);
433 if path.is_empty() {
434 return Err(ManifestError::at(line, format!("`{role}` is empty; give a path relative to the project root, or leave the key out")));
435 }
436 let bytes = path.as_bytes();
437 let drive = bytes.len() >= 2 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':';
438 if path.starts_with('/') || path.starts_with('\\') || drive {
439 return Err(ManifestError::at(line, format!("`{role}` = \"{path}\" is absolute; paths are relative to the project root")));
440 }
441 if path.split(['/', '\\']).any(|part| part == "..") {
442 return Err(ManifestError::at(line, format!("`{role}` = \"{path}\" leaves the project (`..`); keep it inside the project root")));
443 }
444 Ok(path.to_string())
445}
446
447fn cargo_package_name(dir: &Path) -> Option<String> {
449 let file = dir.join("Cargo.toml");
450 let meta = std::fs::metadata(&file).ok()?;
451 if !meta.is_file() || meta.len() > MAX_CARGO_TOML_SIZE {
452 return None;
453 }
454 let text = std::fs::read_to_string(&file).ok()?;
455 let mut in_package = false;
456 for raw in text.lines() {
457 let line = strip_comment(raw).trim();
458 if line.starts_with('[') {
459 in_package = line == "[package]";
460 continue;
461 }
462 if !in_package {
463 continue;
464 }
465 let Some((key, value)) = split_key_value(line) else { continue };
466 if key.trim() == "name" {
467 let name = unquote(value.trim());
468 return (!name.is_empty() && !name.contains('{')).then_some(name);
469 }
470 }
471 None
472}