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