Skip to main content

rws_proxy/generator/
mod.rs

1//! `rws gen model|route|migration|test` (18.29.0, roadmap #88): code
2//! generators for a project in the conventional layout
3//! ([`crate::app_manifest`]), as a library — the `rws gen` command and the
4//! dev MCP endpoint's `gen_*` tools ([`crate::dev_mcp`]) are front ends.
5//!
6//! | Generator | Writes | Adds to |
7//! |---|---|---|
8//! | [`Generate::Model`] | `src/models/<name>.rs` (`#[derive(Model)]`, `id` auto-increment, a test), `migrations/NNNN_create_<table>.sql` + `.down.sql` | `src/models/mod.rs`, `src/lib.rs` (`pub mod models;`), `Cargo.toml` (the `model-<db>` feature) |
9//! | [`Generate::Route`] | `src/routes/<resource>.rs` (typed handlers, `register(app)`) | `src/routes/mod.rs`, `app()` in `src/lib.rs` |
10//! | [`Generate::Migration`] | `migrations/NNNN_<name>.sql` + `.down.sql` | — |
11//! | [`Generate::Test`] | `tests/<resource>.rs` (`TestClient` against `app()`) | — |
12//!
13//! The paths come from `rws.app.toml`. The output is pinned to this
14//! release's API: it compiles against the `rust-web-server` it came with
15//! (CI generates into every starter and runs the generated tests).
16//!
17//! [`plan`] only reads; it answers a [`Plan`] — each file created, edited
18//! or left alone, with a unified diff. [`Plan::apply`] writes it. Nothing
19//! is ever overwritten: a file that exists with other content is a
20//! [`ChangeKind::Conflict`] (the diff shows what the generator would have
21//! written) and the whole plan is refused, so running a generator twice
22//! changes nothing the second time. Edits to existing files only add a
23//! line (`pub mod posts;`, a feature, a `register` call) and are skipped
24//! when it is there; what can't be added safely is a [`Plan::notes`] line
25//! saying what to add by hand.
26//!
27//! ```rust,no_run
28//! # extern crate rws_proxy as rust_web_server;
29//! use rust_web_server::generator::{plan, Field, Generate};
30//!
31//! let request = Generate::Model {
32//!     name: "Post".into(),
33//!     fields: vec![Field::parse("title:String").unwrap(), Field::parse("views:i64").unwrap()],
34//!     db: None, // from Cargo.toml's model-* feature, else SQLite
35//!     timestamps: false,
36//!     soft_delete: false,
37//! };
38//! let plan = plan(".", &request).unwrap();
39//! println!("{}", plan.render(true)); // what would change, with diffs
40//! if !plan.has_conflicts() {
41//!     plan.apply(".").unwrap();
42//! }
43//! ```
44//!
45//! Names are identifiers (letters, digits, `_`), so no path leaves the
46//! project; field types are a fixed list ([`FieldType`]).
47
48use std::fmt;
49use std::path::{Path, PathBuf};
50
51use crate::app_manifest::AppManifest;
52use crate::core::json::escape as json_escape;
53
54mod cli;
55mod diff;
56mod names;
57mod scan;
58mod templates;
59#[cfg(test)]
60mod tests;
61
62pub use cli::{requested, run, EXIT_CONFLICT, EXIT_OK, EXIT_USAGE};
63pub use names::{parse_fields, BaseType, Field, FieldType, FIELD_TYPES, MAX_NAME_LEN};
64
65use names::{check_snake, check_type_name, pluralize, singularize, to_camel, to_snake};
66
67/// The database a model's migration is written for.
68#[derive(Clone, Copy, Debug, PartialEq, Eq)]
69pub enum Db {
70    Sqlite,
71    Postgres,
72    Mysql,
73}
74
75impl Db {
76    /// `sqlite`, `postgres` (or `postgresql`), `mysql`.
77    pub fn parse(text: &str) -> Option<Db> {
78        match text.trim().to_ascii_lowercase().as_str() {
79            "sqlite" => Some(Db::Sqlite),
80            "postgres" | "postgresql" => Some(Db::Postgres),
81            "mysql" => Some(Db::Mysql),
82            _ => None,
83        }
84    }
85
86    /// The `rust-web-server` feature for it (`model-sqlite`, …).
87    pub fn feature(self) -> &'static str {
88        match self {
89            Db::Sqlite => "model-sqlite",
90            Db::Postgres => "model-postgres",
91            Db::Mysql => "model-mysql",
92        }
93    }
94
95    /// `sqlite`, `postgres`, `mysql`.
96    pub fn as_str(self) -> &'static str {
97        match self {
98            Db::Sqlite => "sqlite",
99            Db::Postgres => "postgres",
100            Db::Mysql => "mysql",
101        }
102    }
103
104    fn label(self) -> &'static str {
105        match self {
106            Db::Sqlite => "SQLite",
107            Db::Postgres => "PostgreSQL",
108            Db::Mysql => "MySQL",
109        }
110    }
111}
112
113/// One of the routes `rws gen route` writes for a resource.
114#[derive(Clone, Copy, Debug, PartialEq, Eq)]
115pub enum RouteAction {
116    /// `GET /posts`
117    List,
118    /// `GET /posts/{id}`
119    Show,
120    /// `POST /posts`
121    Create,
122    /// `PUT /posts/{id}`
123    Update,
124    /// `DELETE /posts/{id}`
125    Remove,
126}
127
128impl RouteAction {
129    /// Every action, in the order the routes are written.
130    pub const ALL: [RouteAction; 5] = [RouteAction::List, RouteAction::Show, RouteAction::Create, RouteAction::Update, RouteAction::Remove];
131
132    /// `list`, `show`, `create`, `update`, `remove` (also `delete`).
133    pub fn parse(text: &str) -> Option<RouteAction> {
134        match text.trim() {
135            "list" | "index" => Some(RouteAction::List),
136            "show" | "get" => Some(RouteAction::Show),
137            "create" => Some(RouteAction::Create),
138            "update" => Some(RouteAction::Update),
139            "remove" | "delete" | "destroy" => Some(RouteAction::Remove),
140            _ => None,
141        }
142    }
143
144    /// `list,show` → the actions in [`Self::ALL`] order, once each.
145    pub fn parse_list(text: &str) -> Result<Vec<RouteAction>, GenError> {
146        let mut wanted = Vec::new();
147        for part in text.split(',').map(str::trim).filter(|p| !p.is_empty()) {
148            let action = RouteAction::parse(part)
149                .ok_or_else(|| GenError::usage(format!("unknown method `{part}`: use list, show, create, update, remove")))?;
150            wanted.push(action);
151        }
152        if wanted.is_empty() {
153            return Err(GenError::usage("--methods is empty: name at least one of list, show, create, update, remove"));
154        }
155        Ok(RouteAction::ALL.into_iter().filter(|a| wanted.contains(a)).collect())
156    }
157
158    /// The handler's name, also the action's name.
159    pub fn handler(self) -> &'static str {
160        match self {
161            RouteAction::List => "list",
162            RouteAction::Show => "show",
163            RouteAction::Create => "create",
164            RouteAction::Update => "update",
165            RouteAction::Remove => "remove",
166        }
167    }
168
169    fn verb(self) -> &'static str {
170        self.handler()
171    }
172
173    fn macro_name(self) -> &'static str {
174        match self {
175            RouteAction::List | RouteAction::Show => "get",
176            RouteAction::Create => "post",
177            RouteAction::Update => "put",
178            RouteAction::Remove => "delete",
179        }
180    }
181
182    fn method(self) -> &'static str {
183        match self {
184            RouteAction::List | RouteAction::Show => "GET",
185            RouteAction::Create => "POST",
186            RouteAction::Update => "PUT",
187            RouteAction::Remove => "DELETE",
188        }
189    }
190
191    fn with_id(self) -> bool {
192        matches!(self, RouteAction::Show | RouteAction::Update | RouteAction::Remove)
193    }
194}
195
196/// What to generate.
197#[derive(Clone, Debug, PartialEq, Eq)]
198pub enum Generate {
199    /// `rws gen model <Name> field:type…`: a `#[derive(Model)]` type and
200    /// the migration creating its table. `db: None` takes the database
201    /// from the `model-*` feature in `Cargo.toml`, else SQLite.
202    /// `timestamps` (`--timestamps`, 18.30.0) adds `#[timestamps]` with
203    /// `created_at` / `updated_at` columns, `soft_delete`
204    /// (`--soft-delete`) `#[soft_delete]` with a nullable `deleted_at`.
205    Model { name: String, fields: Vec<Field>, db: Option<Db>, timestamps: bool, soft_delete: bool },
206    /// `rws gen route <resource> [field:type…]`: typed handlers for
207    /// `actions` under `prefix` + `/<resource>`, and `register(app)`.
208    /// Without fields: the fields of `src/models/<singular>.rs` when
209    /// there is one, else `name: String`.
210    Route { resource: String, fields: Vec<Field>, actions: Vec<RouteAction>, prefix: String },
211    /// `rws gen migration <name>`: an up/down pair to fill in.
212    Migration { name: String },
213    /// `rws gen test <resource>`: `TestClient` tests for the routes in
214    /// `src/routes/<resource>.rs`. `app_expr` is how a test builds the
215    /// app (default `app()`, or what the existing tests pass to
216    /// `TestClient::new` when `app` takes arguments).
217    Test { resource: String, app_expr: Option<String> },
218}
219
220impl Generate {
221    /// `model`, `route`, `migration`, `test`.
222    pub fn kind(&self) -> &'static str {
223        match self {
224            Generate::Model { .. } => "model",
225            Generate::Route { .. } => "route",
226            Generate::Migration { .. } => "migration",
227            Generate::Test { .. } => "test",
228        }
229    }
230
231    /// The command line that asks for this (written into the files).
232    pub fn command_line(&self) -> String {
233        let mut words = vec!["rws".to_string(), "gen".to_string(), self.kind().to_string()];
234        match self {
235            Generate::Model { name, fields, db, timestamps, soft_delete } => {
236                words.push(name.clone());
237                words.extend(fields.iter().map(Field::spec));
238                if let Some(db) = db {
239                    words.push(format!("--db {}", db.as_str()));
240                }
241                if *timestamps {
242                    words.push("--timestamps".to_string());
243                }
244                if *soft_delete {
245                    words.push("--soft-delete".to_string());
246                }
247            }
248            Generate::Route { resource, fields, actions, prefix } => {
249                words.push(resource.clone());
250                words.extend(fields.iter().map(Field::spec));
251                if actions.as_slice() != RouteAction::ALL {
252                    let names: Vec<&str> = actions.iter().map(|a| a.handler()).collect();
253                    words.push(format!("--methods {}", names.join(",")));
254                }
255                let prefix = prefix.trim().trim_end_matches('/');
256                if !prefix.is_empty() {
257                    words.push(format!("--prefix {prefix}"));
258                }
259            }
260            Generate::Migration { name } => words.push(name.clone()),
261            Generate::Test { resource, app_expr } => {
262                words.push(resource.clone());
263                if let Some(expr) = app_expr {
264                    words.push(format!("--app-expr '{expr}'"));
265                }
266            }
267        }
268        words.join(" ")
269    }
270}
271
272/// Why a generator refused.
273#[derive(Clone, Debug, PartialEq, Eq)]
274pub struct GenError {
275    /// `true`: the request itself is wrong (a bad name or type, a missing
276    /// argument) — exit code 2; `false`: the project isn't in a state the
277    /// generator can work with — exit code 1.
278    pub usage: bool,
279    pub message: String,
280}
281
282impl GenError {
283    fn usage(message: impl Into<String>) -> Self {
284        GenError { usage: true, message: message.into() }
285    }
286
287    fn refused(message: impl Into<String>) -> Self {
288        GenError { usage: false, message: message.into() }
289    }
290
291    /// `{"status":"refused"|"usage","error":…}`.
292    pub fn to_json(&self) -> String {
293        format!(r#"{{"status":"{}","error":"{}"}}"#, if self.usage { "usage" } else { "refused" }, json_escape(&self.message))
294    }
295}
296
297impl fmt::Display for GenError {
298    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
299        f.write_str(&self.message)
300    }
301}
302
303impl std::error::Error for GenError {}
304
305/// What happens to one file.
306#[derive(Clone, Copy, Debug, PartialEq, Eq)]
307pub enum ChangeKind {
308    /// A new file.
309    Create,
310    /// An existing file gets a line.
311    Edit,
312    /// The file is already what the generator would write.
313    Unchanged,
314    /// The file exists with other content: not overwritten.
315    Conflict,
316}
317
318impl ChangeKind {
319    pub fn as_str(self) -> &'static str {
320        match self {
321            ChangeKind::Create => "create",
322            ChangeKind::Edit => "edit",
323            ChangeKind::Unchanged => "unchanged",
324            ChangeKind::Conflict => "conflict",
325        }
326    }
327}
328
329/// One file of a [`Plan`].
330#[derive(Clone, Debug, PartialEq, Eq)]
331pub struct FileChange {
332    /// Relative to the project root, `/`-separated.
333    pub path: String,
334    pub kind: ChangeKind,
335    /// The file's content after the change (for a conflict: what the
336    /// generator would have written).
337    pub content: String,
338    /// The file's content now (`None`: it doesn't exist).
339    pub original: Option<String>,
340    /// A unified diff from `original` to `content` (empty when
341    /// unchanged).
342    pub diff: String,
343}
344
345/// What a generator would do: the files, and what to add by hand.
346#[derive(Clone, Debug, PartialEq, Eq)]
347pub struct Plan {
348    /// The command line it answers (`rws gen model Post title:String`).
349    pub command: String,
350    pub changes: Vec<FileChange>,
351    /// Steps the generator leaves to the person (a line it couldn't add
352    /// safely, a model to register with the dev MCP endpoint).
353    pub notes: Vec<String>,
354}
355
356impl Plan {
357    /// Any file in the way ([`ChangeKind::Conflict`]).
358    pub fn has_conflicts(&self) -> bool {
359        self.changes.iter().any(|c| c.kind == ChangeKind::Conflict)
360    }
361
362    /// Nothing to write: every file is already as generated.
363    pub fn is_unchanged(&self) -> bool {
364        self.changes.iter().all(|c| c.kind == ChangeKind::Unchanged)
365    }
366
367    /// `conflict`, `unchanged`, or `planned` (something to write).
368    pub fn status(&self) -> &'static str {
369        if self.has_conflicts() {
370            "conflict"
371        } else if self.is_unchanged() {
372            "unchanged"
373        } else {
374            "planned"
375        }
376    }
377
378    /// Write the plan under `dir`: new files are created (never replacing
379    /// one that appeared since [`plan`]), edited files rewritten only if
380    /// they still hold what was read. Refused whole when there is a
381    /// conflict. Answers the paths written.
382    pub fn apply(&self, dir: impl AsRef<Path>) -> Result<Vec<String>, GenError> {
383        let dir = dir.as_ref();
384        if self.has_conflicts() {
385            let paths: Vec<&str> = self.changes.iter().filter(|c| c.kind == ChangeKind::Conflict).map(|c| c.path.as_str()).collect();
386            return Err(GenError::refused(format!("not written: {} already exist(s) with other content", paths.join(", "))));
387        }
388        // Check every edit before writing anything.
389        for change in self.changes.iter().filter(|c| c.kind == ChangeKind::Edit) {
390            let now = std::fs::read_to_string(dir.join(&change.path)).ok();
391            if now != change.original {
392                return Err(GenError::refused(format!("{} changed since the plan was made; run the generator again", change.path)));
393            }
394        }
395        let mut written = Vec::new();
396        for change in &self.changes {
397            let path = dir.join(&change.path);
398            match change.kind {
399                ChangeKind::Create => {
400                    if let Some(parent) = path.parent() {
401                        std::fs::create_dir_all(parent).map_err(|e| GenError::refused(format!("{}: {e}", parent.display())))?;
402                    }
403                    use std::io::Write;
404                    let mut file = std::fs::OpenOptions::new()
405                        .write(true)
406                        .create_new(true)
407                        .open(&path)
408                        .map_err(|e| GenError::refused(format!("{}: {e}", change.path)))?;
409                    file.write_all(change.content.as_bytes()).map_err(|e| GenError::refused(format!("{}: {e}", change.path)))?;
410                }
411                ChangeKind::Edit => {
412                    std::fs::write(&path, &change.content).map_err(|e| GenError::refused(format!("{}: {e}", change.path)))?;
413                }
414                ChangeKind::Unchanged | ChangeKind::Conflict => continue,
415            }
416            written.push(change.path.clone());
417        }
418        Ok(written)
419    }
420
421    /// For people: one line per file (`create  src/models/post.rs`), the
422    /// diffs of conflicts — and of every change with `diffs` — then the
423    /// notes.
424    pub fn render(&self, diffs: bool) -> String {
425        let mut out = String::new();
426        for change in &self.changes {
427            out.push_str(&format!("{:<10} {}\n", change.kind.as_str(), change.path));
428        }
429        for change in &self.changes {
430            let show = change.kind == ChangeKind::Conflict || (diffs && change.kind != ChangeKind::Unchanged);
431            if show && !change.diff.is_empty() {
432                out.push('\n');
433                out.push_str(&change.diff);
434            }
435        }
436        if !self.notes.is_empty() {
437            out.push('\n');
438            for note in &self.notes {
439                out.push_str(&format!("note: {note}\n"));
440            }
441        }
442        out
443    }
444
445    /// For agents: `{"command":…,"status":…,"written":…,"changes":[{"path","action","diff"}],"notes":[…]}`.
446    /// `written`: whether [`Plan::apply`] ran.
447    pub fn to_json(&self, written: bool) -> String {
448        let changes: Vec<String> = self
449            .changes
450            .iter()
451            .map(|c| format!(r#"{{"path":"{}","action":"{}","diff":"{}"}}"#, json_escape(&c.path), c.kind.as_str(), json_escape(&c.diff)))
452            .collect();
453        let notes: Vec<String> = self.notes.iter().map(|n| format!("\"{}\"", json_escape(n))).collect();
454        let status = if written && !self.has_conflicts() && !self.is_unchanged() { "written" } else { self.status() };
455        format!(
456            r#"{{"command":"{}","status":"{status}","written":{written},"changes":[{}],"notes":[{}]}}"#,
457            json_escape(&self.command),
458            changes.join(","),
459            notes.join(",")
460        )
461    }
462}
463
464/// What a generator reads from the project. [`Disk`] is the real one.
465pub(crate) trait Files {
466    fn read(&self, path: &str) -> Option<String>;
467    /// File names in a directory (not recursive).
468    fn list(&self, dir: &str) -> Vec<String>;
469    fn manifest(&self) -> Result<AppManifest, GenError>;
470}
471
472/// The project on disk.
473pub(crate) struct Disk(pub PathBuf);
474
475/// The largest project file a generator reads (1 MiB).
476const MAX_READ: u64 = 1024 * 1024;
477
478impl Files for Disk {
479    fn read(&self, path: &str) -> Option<String> {
480        let full = self.0.join(path);
481        let meta = std::fs::metadata(&full).ok()?;
482        if !meta.is_file() || meta.len() > MAX_READ {
483            return None;
484        }
485        std::fs::read_to_string(full).ok()
486    }
487
488    fn list(&self, dir: &str) -> Vec<String> {
489        let Ok(read) = std::fs::read_dir(self.0.join(dir)) else { return Vec::new() };
490        let mut names: Vec<String> = read.filter_map(Result::ok).map(|e| e.file_name().to_string_lossy().into_owned()).take(10_000).collect();
491        names.sort();
492        names
493    }
494
495    fn manifest(&self) -> Result<AppManifest, GenError> {
496        AppManifest::load(&self.0).map_err(|e| GenError::refused(e.to_string()))
497    }
498}
499
500/// What `request` would change in the project at `dir` (reads only).
501pub fn plan(dir: impl AsRef<Path>, request: &Generate) -> Result<Plan, GenError> {
502    let dir = dir.as_ref();
503    if !dir.join("Cargo.toml").is_file() {
504        return Err(GenError::refused(format!("{} has no Cargo.toml: run rws gen in the project's root (or pass --dir)", dir.display())));
505    }
506    plan_with(&Disk(dir.to_path_buf()), request)
507}
508
509/// Collects the changes of one plan.
510struct Builder<'a> {
511    files: &'a dyn Files,
512    changes: Vec<FileChange>,
513    notes: Vec<String>,
514}
515
516impl<'a> Builder<'a> {
517    /// A file the generator owns: created, unchanged, or a conflict.
518    fn create(&mut self, path: &str, content: String) {
519        let original = self.files.read(path);
520        let (kind, diff) = match &original {
521            None => (ChangeKind::Create, diff::unified(path, None, &content)),
522            Some(now) if *now == content => (ChangeKind::Unchanged, String::new()),
523            Some(now) => (ChangeKind::Conflict, diff::unified(path, Some(now), &content)),
524        };
525        self.changes.push(FileChange { path: path.to_string(), kind, content, original, diff });
526    }
527
528    /// An existing file's next content: `None` = unchanged. Later edits of
529    /// the same path build on earlier ones.
530    fn current(&self, path: &str) -> Option<String> {
531        match self.changes.iter().rev().find(|c| c.path == path) {
532            Some(change) => Some(change.content.clone()),
533            None => self.files.read(path),
534        }
535    }
536
537    fn edit(&mut self, path: &str, new: Option<String>) {
538        let Some(new) = new else {
539            if !self.changes.iter().any(|c| c.path == path) {
540                if let Some(now) = self.files.read(path) {
541                    self.changes.push(FileChange { path: path.to_string(), kind: ChangeKind::Unchanged, content: now.clone(), original: Some(now), diff: String::new() });
542                }
543            }
544            return;
545        };
546        if let Some(existing) = self.changes.iter_mut().find(|c| c.path == path) {
547            existing.diff = diff::unified(path, existing.original.as_deref(), &new);
548            existing.content = new;
549            if existing.kind == ChangeKind::Unchanged {
550                existing.kind = ChangeKind::Edit;
551            }
552            return;
553        }
554        let original = self.files.read(path);
555        let kind = if original.is_some() { ChangeKind::Edit } else { ChangeKind::Create };
556        let diff = diff::unified(path, original.as_deref(), &new);
557        self.changes.push(FileChange { path: path.to_string(), kind, content: new, original, diff });
558    }
559
560    fn note(&mut self, note: impl Into<String>) {
561        self.notes.push(note.into());
562    }
563}
564
565/// `src/models` relative to `src/lib.rs` → the module name `models`, when
566/// the directory sits beside `lib.rs`.
567fn module_beside(lib: &str, dir: &str) -> Option<String> {
568    let lib_dir = lib.rsplit_once('/').map(|(d, _)| d).unwrap_or("");
569    let (parent, name) = dir.trim_end_matches('/').rsplit_once('/').unwrap_or(("", dir));
570    (parent == lib_dir && check_snake(name, "module").is_ok()).then(|| name.to_string())
571}
572
573fn join(dir: &str, file: &str) -> String {
574    let dir = dir.trim_end_matches('/');
575    if dir.is_empty() || dir == "." {
576        file.to_string()
577    } else {
578        format!("{dir}/{file}")
579    }
580}
581
582/// `pub mod <module>;` in `<dir>/mod.rs` (created when missing, with
583/// `fresh`), and `pub mod <dir module>;` in `lib.rs` when the directory is
584/// new to it.
585fn add_module(b: &mut Builder<'_>, manifest: &AppManifest, dir: &str, module: &str, fresh: String) {
586    let mod_rs = join(dir, "mod.rs");
587    match b.current(&mod_rs) {
588        Some(text) => {
589            let edited = scan::add_mod_line(&text, module);
590            b.edit(&mod_rs, edited);
591        }
592        None => b.edit(&mod_rs, Some(fresh)),
593    }
594    let lib = manifest.paths.lib.clone();
595    let Some(parent) = module_beside(&lib, dir) else {
596        b.note(format!("{dir} isn't beside {lib}: declare the module where it belongs (`mod …;`)"));
597        return;
598    };
599    match b.current(&lib) {
600        Some(text) => {
601            let edited = scan::add_mod_line(&text, &parent);
602            b.edit(&lib, edited);
603        }
604        None => b.note(format!("there is no {lib}: add `pub mod {parent};` to the crate root")),
605    }
606}
607
608/// The next migration number in `dir` (after every `NNNN_*.sql`) and its
609/// width (at least 4).
610fn next_migration(files: &dyn Files, dir: &str) -> (u64, usize) {
611    let mut max = 0;
612    let mut width = 4;
613    for name in files.list(dir) {
614        if let Some((n, w)) = scan::migration_number(&name) {
615            max = max.max(n);
616            width = width.max(w);
617        }
618    }
619    (max + 1, width)
620}
621
622/// An existing `NNNN_<suffix>.sql` in `dir` (so a second run compares
623/// with it instead of numbering a new one).
624fn existing_migration(files: &dyn Files, dir: &str, suffix: &str) -> Option<String> {
625    let wanted = format!("_{suffix}.sql");
626    files.list(dir).into_iter().find(|name| scan::migration_number(name).is_some() && name.ends_with(&wanted) && !name.ends_with(".down.sql"))
627}
628
629/// The migration pair for `suffix`: `(up path, down path, stem)`.
630fn migration_paths(files: &dyn Files, dir: &str, suffix: &str) -> (String, String, String) {
631    let stem = match existing_migration(files, dir, suffix) {
632        Some(name) => name.trim_end_matches(".sql").to_string(),
633        None => {
634            let (n, width) = next_migration(files, dir);
635            format!("{n:0width$}_{suffix}")
636        }
637    };
638    (join(dir, &format!("{stem}.sql")), join(dir, &format!("{stem}.down.sql")), stem)
639}
640
641fn cargo(files: &dyn Files) -> Result<String, GenError> {
642    files.read("Cargo.toml").ok_or_else(|| GenError::refused("there is no Cargo.toml in the project's root"))
643}
644
645/// The database from `Cargo.toml`'s `model-*` features when `db` isn't
646/// given: one of them, else SQLite.
647fn choose_db(cargo: &str, db: Option<Db>) -> Result<Db, GenError> {
648    if let Some(db) = db {
649        return Ok(db);
650    }
651    let features = scan::rws_features(cargo).unwrap_or_default();
652    let enabled: Vec<Db> = [Db::Sqlite, Db::Postgres, Db::Mysql].into_iter().filter(|d| features.iter().any(|f| f == d.feature())).collect();
653    match enabled.as_slice() {
654        [] => Ok(Db::Sqlite),
655        [one] => Ok(*one),
656        _ => Err(GenError::usage("Cargo.toml enables more than one model-* feature: say which database with --db sqlite|postgres|mysql")),
657    }
658}
659
660pub(crate) fn plan_with(files: &dyn Files, request: &Generate) -> Result<Plan, GenError> {
661    let manifest = files.manifest()?;
662    let mut b = Builder { files, changes: Vec::new(), notes: Vec::new() };
663    let command = request.command_line();
664    match request {
665        Generate::Model { name, fields, db, timestamps, soft_delete } => {
666            let conventions = templates::Conventions { timestamps: *timestamps, soft_delete: *soft_delete };
667            plan_model(&mut b, &manifest, &command, name, fields, *db, conventions)?
668        }
669        Generate::Route { resource, fields, actions, prefix } => plan_route(&mut b, &manifest, &command, resource, fields, actions, prefix)?,
670        Generate::Migration { name } => {
671            check_snake(name, "migration")?;
672            let dir = manifest.paths.migrations.clone();
673            let (up, down, stem) = migration_paths(files, &dir, name);
674            b.create(&up, templates::empty_up(&command, &stem));
675            b.create(&down, templates::empty_down(&stem));
676        }
677        Generate::Test { resource, app_expr } => plan_test(&mut b, &manifest, &command, resource, app_expr.as_deref())?,
678    }
679    Ok(Plan { command, changes: b.changes, notes: b.notes })
680}
681
682fn plan_model(
683    b: &mut Builder<'_>,
684    manifest: &AppManifest,
685    command: &str,
686    name: &str,
687    fields: &[Field],
688    db: Option<Db>,
689    conventions: templates::Conventions,
690) -> Result<(), GenError> {
691    let module = to_snake(name, "model")?;
692    let type_name = to_camel(&module);
693    check_type_name(&type_name, "model")?;
694    if fields.is_empty() {
695        return Err(GenError::usage("a model needs at least one field besides `id`, e.g. `rws gen model Post title:String`"));
696    }
697    if let Some(f) = fields.iter().find(|f| conventions.columns().contains(&f.name.as_str())) {
698        return Err(GenError::usage(format!(
699            "`{}` is written by --timestamps / --soft-delete: leave it out of the fields",
700            f.name
701        )));
702    }
703    let table = pluralize(&module);
704    let cargo = cargo(b.files)?;
705    let db = choose_db(&cargo, db)?;
706    let migrations = manifest.paths.migrations.clone();
707    let (up, down, _) = migration_paths(b.files, &migrations, &format!("create_{table}"));
708    let sqlite_test = (db == Db::Sqlite && scan::dependency_has(&cargo, "tokio", "macros")).then_some(migrations.as_str());
709    if db == Db::Sqlite && sqlite_test.is_none() {
710        b.note("no tokio dependency with the `macros` feature: the model's test checks the mapping only, not a round trip through SQLite");
711    }
712    let file = join(&manifest.paths.models, &format!("{module}.rs"));
713    let text = templates::ModelText { command, type_name: &type_name, table: &table, fields, migration: &up, sqlite_test, conventions };
714    b.create(&file, templates::model(&text));
715    let models_dir = manifest.paths.models.clone();
716    add_module(b, manifest, &models_dir, &module, templates::models_mod(&module, &type_name));
717    let mod_rs = join(&models_dir, "mod.rs");
718    if let Some(text) = b.current(&mod_rs) {
719        let edited = scan::add_use_line(&text, &format!("pub use {module}::{type_name};"));
720        b.edit(&mod_rs, edited);
721    }
722    match scan::add_rws_feature(&cargo, db.feature()) {
723        Ok(edited) => b.edit("Cargo.toml", edited),
724        Err(()) => b.note(format!("add the `{}` feature to the rust-web-server dependency in Cargo.toml", db.feature())),
725    }
726    if conventions != templates::Conventions::default() {
727        // The model derives Serialize/Deserialize; Timestamp has them with `serde`.
728        let now = b.current("Cargo.toml").unwrap_or_else(|| cargo.clone());
729        match scan::add_rws_feature(&now, "serde") {
730            Ok(edited) => b.edit("Cargo.toml", edited),
731            Err(()) => b.note("add the `serde` feature to the rust-web-server dependency in Cargo.toml (Timestamp's Serialize and Deserialize)"),
732        }
733    }
734    b.create(&up, templates::create_table_up_with(command, &table, fields, db, conventions));
735    b.create(&down, templates::create_table_down(&table));
736    let models_mod = module_beside(&manifest.paths.lib, &models_dir).unwrap_or_else(|| "models".to_string());
737    b.note(format!(
738        "to list it in the dev MCP endpoint's `models` tool, add `.model::<{models_mod}::{type_name}>()` to the DevTools in {}",
739        manifest.paths.lib
740    ));
741    Ok(())
742}
743
744/// The resource's fields when none are given: the model's, else `name`.
745fn default_fields(b: &mut Builder<'_>, manifest: &AppManifest, singular: &str, type_name: &str) -> Vec<Field> {
746    let model = join(&manifest.paths.models, &format!("{singular}.rs"));
747    if let Some(text) = b.files.read(&model) {
748        if let Some(found) = scan::struct_fields(&text, type_name) {
749            let fields: Option<Vec<Field>> = found
750                .into_iter()
751                // The key and the columns the repository writes (--timestamps, --soft-delete).
752                .filter(|f| !matches!(f, Ok((name, _)) if ["id", "created_at", "updated_at", "deleted_at"].contains(&name.as_str())))
753                .map(|f| f.ok().and_then(|(name, ty)| Field::parse(&format!("{name}:{ty}")).ok()))
754                .collect();
755            if let Some(fields) = fields.filter(|f| !f.is_empty()) {
756                b.note(format!("fields taken from {model}"));
757                return fields;
758            }
759        }
760    }
761    vec![Field { name: "name".to_string(), ty: FieldType { base: BaseType::String, optional: false } }]
762}
763
764fn check_prefix(prefix: &str) -> Result<String, GenError> {
765    let prefix = prefix.trim().trim_end_matches('/');
766    if prefix.is_empty() {
767        return Ok(String::new());
768    }
769    let ok = prefix.starts_with('/')
770        && prefix.len() <= 128
771        && prefix[1..].split('/').all(|seg| !seg.is_empty() && seg.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_'));
772    if ok {
773        Ok(prefix.to_string())
774    } else {
775        Err(GenError::usage(format!("--prefix `{prefix}`: a path like /api or /api/v1 (letters, digits, - and _ per segment)")))
776    }
777}
778
779/// `app()` in `lib.rs`, or why `gen route`/`gen test` can't use it.
780fn app_fn(b: &Builder<'_>, manifest: &AppManifest) -> Result<(String, scan::AppFn), GenError> {
781    let lib = manifest.paths.lib.clone();
782    let text = b.current(&lib).ok_or_else(|| GenError::refused(format!("there is no {lib}: the conventional layout builds the app in `pub fn app()` there")))?;
783    let app = scan::find_app_fn(&text).ok_or_else(|| {
784        GenError::refused(format!(
785            "{lib} has no `pub fn app(…) -> AppWithState<…>` or `-> AsyncAppWithState<…>`: rws gen route and rws gen test write HTTP routes for such an app (an MCP server's tools are written by hand)"
786        ))
787    })?;
788    Ok((text, app))
789}
790
791fn plan_route(
792    b: &mut Builder<'_>,
793    manifest: &AppManifest,
794    command: &str,
795    resource: &str,
796    fields: &[Field],
797    actions: &[RouteAction],
798    prefix: &str,
799) -> Result<(), GenError> {
800    check_snake(resource, "resource")?;
801    let singular = singularize(resource);
802    let type_name = to_camel(&singular);
803    check_type_name(&type_name, "resource")?;
804    if actions.is_empty() {
805        return Err(GenError::usage("no routes asked for: --methods takes list, show, create, update, remove"));
806    }
807    let prefix = check_prefix(prefix)?;
808    let (lib_text, app) = app_fn(b, manifest)?;
809    let fields = if fields.is_empty() { default_fields(b, manifest, &singular, &type_name) } else { fields.to_vec() };
810    let base = format!("{prefix}/{resource}");
811    let routes_dir = manifest.paths.routes.clone();
812    let text = templates::RouteText {
813        command,
814        resource,
815        base: &base,
816        type_name: &type_name,
817        singular: &singular,
818        fields: &fields,
819        actions,
820        kind: app.kind,
821        state: &app.state,
822    };
823    b.create(&join(&routes_dir, &format!("{resource}.rs")), templates::route(&text));
824    add_module(b, manifest, &routes_dir, resource, templates::routes_mod(resource));
825    let lib = manifest.paths.lib.clone();
826    let routes_mod = module_beside(&lib, &routes_dir);
827    let lib_now = b.current(&lib).unwrap_or(lib_text);
828    // Re-find app(): a `pub mod` line added above it moved its body.
829    let app = scan::find_app_fn(&lib_now).unwrap_or(app);
830    match routes_mod.as_deref().map(|m| scan::wire_register(&lib_now, &app, m, resource)) {
831        Some(Ok(edited)) => b.edit(&lib, edited),
832        _ => b.note(format!(
833            "register the routes in app() ({lib}): `let app = {}::{resource}::register(app);`",
834            routes_mod.as_deref().unwrap_or("routes")
835        )),
836    }
837    Ok(())
838}
839
840fn plan_test(b: &mut Builder<'_>, manifest: &AppManifest, command: &str, resource: &str, app_expr: Option<&str>) -> Result<(), GenError> {
841    check_snake(resource, "resource")?;
842    let (_, app) = app_fn(b, manifest)?;
843    let route_file = join(&manifest.paths.routes, &format!("{resource}.rs"));
844    let route_text = b
845        .files
846        .read(&route_file)
847        .ok_or_else(|| GenError::refused(format!("there is no {route_file}: generate the routes first (rws gen route {resource})")))?;
848    let cargo = cargo(b.files)?;
849    let crate_name = scan::crate_name(&cargo).ok_or_else(|| GenError::refused("Cargo.toml has no [package] name"))?;
850    let app_expr = match app_expr.map(str::trim) {
851        Some(expr) => {
852            let ok = expr.starts_with("app(") && expr.ends_with(')') && expr.len() <= 200 && !expr.contains(['\n', ';', '{', '}']);
853            if !ok {
854                return Err(GenError::usage("--app-expr: a call of app, e.g. 'app(Store::memory())' (one line, no `;` or braces)"));
855            }
856            expr.to_string()
857        }
858        None if app.no_params => "app()".to_string(),
859        None => {
860            let tests = manifest.paths.tests.clone();
861            let found = b.files.list(&tests).into_iter().filter(|n| n.ends_with(".rs")).find_map(|n| b.files.read(&join(&tests, &n)).and_then(|t| scan::test_client_app_expr(&t)));
862            found.ok_or_else(|| {
863                GenError::usage(format!("app() takes arguments and no test in {tests}/ shows how to call it: pass --app-expr 'app(…)'"))
864            })?
865        }
866    };
867    // The routes the module declares, as `rws gen route` writes them.
868    let routes = scan::declared_routes(&route_text);
869    let collection = routes.iter().find(|r| !r.pattern.contains('{')).map(|r| r.pattern.clone()).or_else(|| {
870        routes.iter().find_map(|r| r.pattern.strip_suffix("/{id}").map(str::to_string))
871    });
872    let Some(base) = collection else {
873        return Err(GenError::refused(format!("{route_file} declares no `#[get(\"/…\")]`-style routes: write the test by hand")));
874    };
875    let actions: Vec<RouteAction> = RouteAction::ALL
876        .into_iter()
877        .filter(|a| {
878            let pattern = if a.with_id() { format!("{base}/{{id}}") } else { base.clone() };
879            routes.iter().any(|r| r.method == a.method() && r.pattern == pattern)
880        })
881        .collect();
882    if actions.is_empty() {
883        return Err(GenError::refused(format!("{route_file} has none of the routes rws gen route writes under {base}: write the test by hand")));
884    }
885    let needs_body = actions.contains(&RouteAction::Create) || actions.contains(&RouteAction::Update);
886    let mut fields = Vec::new();
887    if needs_body {
888        let type_name = format!("New{}", to_camel(&singularize(resource)));
889        let found = scan::struct_fields(&route_text, &type_name)
890            .ok_or_else(|| GenError::refused(format!("{route_file} has no `pub struct {type_name} {{ … }}` to build a request body from")))?;
891        for entry in found {
892            let (name, ty) = entry.map_err(|line| GenError::refused(format!("{route_file}: can't read the field `{line}` of {type_name}")))?;
893            let field = Field::parse(&format!("{name}:{ty}"))
894                .map_err(|e| GenError::refused(format!("{route_file}: {type_name}.{name}: {}", e.message)))?;
895            fields.push(field);
896        }
897    }
898    let text = templates::TestText { command, crate_name: &crate_name, resource, base: &base, app_expr: &app_expr, actions: &actions, fields: &fields };
899    b.create(&join(&manifest.paths.tests, &format!("{resource}.rs")), templates::test(&text));
900    Ok(())
901}
902
903/// Every scanner of project text on `input` as each kind of file — the
904/// fuzz target `generator` and the stack-depth test. Not an API.
905#[doc(hidden)]
906pub fn fuzz(input: &str) {
907    if let Some(app) = scan::find_app_fn(input) {
908        let _ = scan::wire_register(input, &app, "routes", "posts");
909    }
910    let _ = scan::add_mod_line(input, "posts");
911    let _ = scan::add_use_line(input, "pub use post::Post;");
912    let _ = scan::add_rws_feature(input, "model-sqlite");
913    let _ = scan::rws_features(input);
914    let _ = scan::crate_name(input);
915    let _ = scan::dependency_has(input, "tokio", "macros");
916    let _ = scan::test_client_app_expr(input);
917    let _ = scan::declared_routes(input);
918    let _ = scan::struct_fields(input, "NewPost");
919    for line in input.lines().take(64) {
920        let _ = Field::parse(line);
921        let _ = scan::migration_number(line);
922        let _ = names::to_snake(line, "name");
923        let _ = RouteAction::parse_list(line);
924    }
925    let _ = diff::unified("fuzz.rs", Some(input), "pub mod posts;\n");
926}
927