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