Skip to main content

rws_http/app_manifest/
mod.rs

1//! `rws.app.toml`: the app manifest, and the conventional layout of an rws
2//! application (18.28.0, roadmap #87).
3//!
4//! One layout an agent (or a person) can rely on without reading the
5//! project first:
6//!
7//! | Role | Path | What goes there |
8//! |---|---|---|
9//! | `main` | `src/main.rs` | wiring only: settings, `dev_mcp`, `Server::run_tls` |
10//! | `lib` | `src/lib.rs` | `app()` — the routes and middleware, built in a function tests call |
11//! | `routes` | `src/routes/` | typed handlers (`#[get]` …), one module per resource |
12//! | `models` | `src/models/` | `#[derive(Model)]` types |
13//! | `jobs` | `src/jobs/` | background jobs and scheduled tasks |
14//! | `migrations` | `migrations/` | `NNNN_name.sql` with an optional `NNNN_name.down.sql` |
15//! | `templates` | `templates/` | Tera templates |
16//! | `tests` | `tests/` | integration tests against `app()` (`TestClient`) |
17//! | `static` | `static/` | files served as they are |
18//!
19//! The manifest at the project root names the app and says where a project
20//! differs from that layout; every key is optional, so an empty or absent
21//! file means "conventional":
22//!
23//! ```toml
24//! # rws.app.toml
25//! [app]
26//! name = "todos"
27//!
28//! [paths]
29//! static = "frontend/dist"   # only what differs from the layout
30//! ```
31//!
32//! ```rust
33//! # extern crate rws_http as rust_web_server;
34//! use rust_web_server::app_manifest::AppManifest;
35//!
36//! let manifest: AppManifest = "[paths]\nstatic = \"public\"".parse().unwrap();
37//! assert_eq!(manifest.paths.static_files, "public");
38//! assert_eq!(manifest.paths.routes, "src/routes"); // the convention
39//! ```
40//!
41//! The parser reads one line at a time (no recursion), takes at most
42//! [`MAX_MANIFEST_SIZE`] bytes, and answers bad input with a
43//! [`ManifestError`] naming the line — it never panics. Keys it doesn't know
44//! are kept as [`AppManifest::warnings`], not errors, so a manifest written
45//! for a newer release still loads.
46
47use 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
55/// The manifest's file name, at the project root.
56pub const FILE_NAME: &str = "rws.app.toml";
57
58/// The largest manifest [`AppManifest::from_str`] and [`AppManifest::load`]
59/// accept (64 KiB); a real one is a few hundred bytes.
60pub const MAX_MANIFEST_SIZE: usize = 64 * 1024;
61
62/// The largest `Cargo.toml` [`AppManifest::load`] reads the package name
63/// from (1 MiB).
64const MAX_CARGO_TOML_SIZE: u64 = 1024 * 1024;
65
66/// Where each part of the app lives, relative to the project root. The
67/// [`Default`] is the conventional layout (see the [module docs](self)).
68#[derive(Clone, Debug, PartialEq, Eq)]
69pub struct AppPaths {
70    /// The binary's entry point, wiring only (`src/main.rs`).
71    pub main: String,
72    /// The library that builds the app, `app()` (`src/lib.rs`).
73    pub lib: String,
74    /// Typed handlers, one module per resource (`src/routes`).
75    pub routes: String,
76    /// `#[derive(Model)]` types (`src/models`).
77    pub models: String,
78    /// Background jobs (`src/jobs`).
79    pub jobs: String,
80    /// SQL migrations, `NNNN_name.sql` and `NNNN_name.down.sql` (`migrations`).
81    pub migrations: String,
82    /// Templates (`templates`).
83    pub templates: String,
84    /// Integration tests (`tests`).
85    pub tests: String,
86    /// Static files (`static`; the key is `static` in the manifest).
87    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
106/// The roles in [`AppPaths`], as the manifest's `[paths]` keys name them,
107/// in layout order.
108pub const ROLES: [&str; 9] = ["main", "lib", "routes", "models", "jobs", "migrations", "templates", "tests", "static"];
109
110impl AppPaths {
111    /// `(role, path)` for every role, in [`ROLES`] order.
112    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    /// The path of `role` (a [`ROLES`] name), or `None` for another name.
127    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/// Whether a role's path is a file (`main`, `lib`) or a directory (the
148/// rest).
149#[derive(Clone, Copy, Debug, PartialEq, Eq)]
150pub enum EntryKind {
151    File,
152    Dir,
153}
154
155impl EntryKind {
156    /// `"file"` or `"dir"`.
157    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/// One role of the layout as found on disk ([`AppManifest::layout`]).
174#[derive(Clone, Debug, PartialEq, Eq)]
175pub struct LayoutEntry {
176    /// The role ([`ROLES`]).
177    pub role: &'static str,
178    /// Its path, relative to the project root.
179    pub path: String,
180    /// A file or a directory.
181    pub kind: EntryKind,
182    /// Whether that file (or directory) exists.
183    pub exists: bool,
184}
185
186/// `rws.app.toml`, parsed: the app's name and where each part of it lives.
187///
188/// [`Default`] is the conventional layout with no name.
189#[derive(Clone, Debug, Default, PartialEq, Eq)]
190pub struct AppManifest {
191    /// `[app] name` (or a top-level `name`). [`AppManifest::load`] falls back
192    /// to the `[package] name` of the project's `Cargo.toml`, then to the
193    /// directory's name; [`AppManifest::from_str`] leaves it empty.
194    pub name: String,
195    /// `[paths]`, the layout's defaults for every key not given.
196    pub paths: AppPaths,
197    /// The file it was read from — `None` when [`AppManifest::load`] found
198    /// none (the conventional layout) or for [`AppManifest::from_str`].
199    pub file: Option<PathBuf>,
200    /// Keys and tables the parser doesn't know, as `line N: …` notes.
201    pub warnings: Vec<String>,
202}
203
204/// Why a manifest couldn't be read: the line (1-based; `0` when not about
205/// one line) and what to change.
206#[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    /// The conventional layout for an app called `name`.
240    pub fn conventional(name: impl Into<String>) -> Self {
241        AppManifest { name: name.into(), ..AppManifest::default() }
242    }
243
244    /// Parse a manifest's text (also `text.parse::<AppManifest>()`).
245    ///
246    /// Tables `[app]` (`name`) and `[paths]` (the [`ROLES`]); values are
247    /// strings; paths are relative to the project root and stay inside it
248    /// (no leading `/`, no drive letter, no `..`). Unknown keys and tables
249    /// become [`Self::warnings`].
250    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            // A dotted key at the top level (`paths.routes = "…"`) is the
285            // same as the key in its table.
286            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 { &section });
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    /// The manifest of the project in `dir`: its `rws.app.toml`, or the
323    /// conventional layout when there is none. The name falls back to
324    /// `Cargo.toml`'s `[package] name`, then to the directory's name.
325    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    /// Every role of the layout, with whether its path exists under `dir`
352    /// (a regular file for `main`/`lib`, a directory for the rest).
353    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
371/// Drop a trailing `# comment`, ignoring `#` inside quoted strings.
372fn 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
382/// `key = value` split at the first `=` outside quotes.
383fn 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
393/// A quoted TOML string's contents; anything else is an error naming `key`.
394fn string_value(raw: &str, key: &str, line: usize) -> Result<String, ManifestError> {
395    // The closing quote must end the value: `"a" "b"` is not one string.
396    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
406/// The byte index just past the quote that closes the string `raw` starts
407/// with (`None` when it doesn't start with a quote or never closes).
408fn 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
427/// A `[paths]` value, checked to stay inside the project: relative, no
428/// `..`; a trailing `/` and leading `./` dropped.
429fn 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
447/// `[package] name` from `dir/Cargo.toml`, if it has one.
448fn 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}