Skip to main content

rws_proxy/generator/
cli.rs

1//! `rws gen …`: the command line of the generators.
2
3use std::io::Write;
4use std::path::PathBuf;
5
6use super::names::parse_fields;
7use super::{plan, Db, GenError, Generate, RouteAction};
8
9/// Written (or nothing to write: every file already as generated).
10pub const EXIT_OK: i32 = 0;
11/// A conflict (a file exists with other content) or the project refused
12/// (no `app()`, no route module, …): nothing written.
13pub const EXIT_CONFLICT: i32 = 1;
14/// Bad arguments.
15pub const EXIT_USAGE: i32 = 2;
16
17const USAGE: &str = "\
18Usage: rws gen <generator> <name> [args] [--dir <project>] [--dry-run] [--json]
19
20Generators:
21  model <Name> field:type...      src/models/<name>.rs (#[derive(Model)], id auto-increment)
22        [--db sqlite|postgres|mysql]  and migrations/NNNN_create_<table>.sql + .down.sql
23        [--timestamps] [--soft-delete]  created_at/updated_at, deleted_at kept by save/delete
24  route <resource> [field:type...]  src/routes/<resource>.rs: typed handlers and register(app)
25        [--methods list,show,create,update,remove] [--prefix /api]
26  migration <name>                migrations/NNNN_<name>.sql + .down.sql to fill in
27        [--from-models [--database-url <url>] [--db …]]  written from the models: CREATE TABLE /
28                                  ADD COLUMN for what the database (DATABASE_URL / RWS_DB_*) lacks
29  test <resource> [--app-expr 'app(...)']  tests/<resource>.rs: TestClient against app()
30  deploy --image <image> [--port 7878] [--replicas 2] [--max-replicas 10] [--out deploy/k8s]
31                                  Kubernetes manifests: Deployment (probes, graceful shutdown),
32                                  Service, PodDisruptionBudget, HPA, ServiceAccount, kustomization
33
34Field types: i64, i32, f64, bool, String, Option<T> (T one of those).
35Paths come from rws.app.toml (the conventional layout without one). --db
36defaults to the model-* feature in Cargo.toml, else sqlite.
37
38Nothing is overwritten: a file that exists with other content is a conflict,
39shown as a diff, and nothing is written. Running a generator twice changes
40nothing the second time. --dry-run prints the plan and the diffs without
41writing; --json prints it for agents.
42Exit codes: 0 written (or already as generated), 1 conflict or refused, 2 usage.
43";
44
45/// Run `rws gen <args>` (the arguments after `gen`), writing to `out` and
46/// `err`; answers the exit code ([`EXIT_OK`], [`EXIT_CONFLICT`],
47/// [`EXIT_USAGE`]).
48pub fn run(args: &[String], out: &mut dyn Write, err: &mut dyn Write) -> i32 {
49    let parsed = match parse(args) {
50        Ok(Parsed::Help) => {
51            let _ = write!(out, "{USAGE}");
52            return EXIT_OK;
53        }
54        Ok(Parsed::Run(cli)) => cli,
55        Err(e) => return fail(&e, args.iter().any(|a| a == "--json"), out, err),
56    };
57    let planned = match (&parsed.request, &parsed.from_models) {
58        (Request::Deploy(spec), _) => super::plan_deploy(&parsed.dir, spec),
59        (Request::Generate(request), from_models) => match from_models {
60        Some((url, db)) => match (request, existing_columns(url.clone())) {
61            (Generate::Migration { name }, Ok(existing)) => super::plan_model_migration(&parsed.dir, name, &existing, *db),
62            (_, Err(e)) => Err(GenError::refused(e)),
63            _ => Err(GenError::usage("--from-models applies to rws gen migration only")),
64        },
65        None => plan(&parsed.dir, request),
66        },
67    };
68    let plan = match planned {
69        Ok(plan) => plan,
70        Err(e) => return fail(&e, parsed.json, out, err),
71    };
72    if parsed.from_models.is_some() && plan.changes.is_empty() {
73        if parsed.json {
74            let _ = writeln!(out, "{}", plan.to_json(false));
75        } else {
76            let _ = write!(out, "{}", plan.render(false));
77        }
78        return EXIT_OK;
79    }
80    if plan.has_conflicts() || parsed.dry_run || plan.is_unchanged() {
81        if parsed.json {
82            let _ = writeln!(out, "{}", plan.to_json(false));
83        } else {
84            let _ = write!(out, "{}", plan.render(parsed.dry_run));
85            if plan.has_conflicts() {
86                let _ = writeln!(err, "rws gen: not written — a file above exists with other content (the diff is what the generator would write). Move it aside or merge by hand.");
87            } else if plan.is_unchanged() {
88                let _ = writeln!(out, "nothing to do: every file is already as generated");
89            }
90        }
91        return if plan.has_conflicts() { EXIT_CONFLICT } else { EXIT_OK };
92    }
93    match plan.apply(&parsed.dir) {
94        Ok(_) => {
95            if parsed.json {
96                let _ = writeln!(out, "{}", plan.to_json(true));
97            } else {
98                let _ = write!(out, "{}", plan.render(false));
99            }
100            EXIT_OK
101        }
102        Err(e) => fail(&e, parsed.json, out, err),
103    }
104}
105
106fn fail(e: &GenError, json: bool, out: &mut dyn Write, err: &mut dyn Write) -> i32 {
107    if json {
108        let _ = writeln!(out, "{}", e.to_json());
109    } else if e.usage {
110        let _ = writeln!(err, "rws gen: {}\n(rws gen --help lists the generators and their arguments)", e.message);
111    } else {
112        let _ = writeln!(err, "rws gen: {}", e.message);
113    }
114    if e.usage {
115        EXIT_USAGE
116    } else {
117        EXIT_CONFLICT
118    }
119}
120
121enum Request {
122    Generate(Generate),
123    /// `rws gen deploy`: not a [`Generate`] variant (adding one would break
124    /// exhaustive matches on it).
125    Deploy(super::DeploySpec),
126}
127
128struct Cli {
129    request: Request,
130    dir: PathBuf,
131    dry_run: bool,
132    json: bool,
133    /// `--from-models`: the database URL (`--database-url`) and `--db`.
134    from_models: Option<(Option<String>, Option<Db>)>,
135}
136
137/// The database's tables and columns, for `--from-models`: `url`, else
138/// `DATABASE_URL`, else the `RWS_DB_*` variables (as `rws db`).
139#[cfg(any(feature = "model-sqlite", feature = "model-postgres", feature = "model-mysql"))]
140fn existing_columns(url: Option<String>) -> Result<super::ExistingColumns, String> {
141    use crate::model::DbPool;
142    let url = url.or_else(|| std::env::var("DATABASE_URL").ok().filter(|u| !u.trim().is_empty()));
143    crate::dev_mcp::db::run(async move {
144        let pool = match &url {
145            Some(url) => DbPool::connect(url).await?,
146            None => DbPool::from_env().await.map_err(|e| crate::model::DbError::new(format!("{} (or give --database-url / DATABASE_URL)", e.0)))?,
147        };
148        let schema = pool.schema().await;
149        pool.close().await;
150        Ok(schema?.tables.into_iter().map(|t| (t.name, t.columns.into_iter().map(|c| c.name).collect())).collect())
151    })
152}
153
154#[cfg(not(any(feature = "model-sqlite", feature = "model-postgres", feature = "model-mysql")))]
155fn existing_columns(_url: Option<String>) -> Result<super::ExistingColumns, String> {
156    Err("--from-models reads the database: this rws was built without a database driver (cargo install rust-web-server --features model-sqlite,model-postgres,model-mysql)".into())
157}
158
159enum Parsed {
160    Help,
161    Run(Cli),
162}
163
164fn parse(args: &[String]) -> Result<Parsed, GenError> {
165    let mut positional: Vec<String> = Vec::new();
166    let mut dir = PathBuf::from(".");
167    let (mut dry_run, mut json) = (false, false);
168    let (mut db, mut methods, mut prefix, mut app_expr) = (None, None, None, None);
169    let (mut timestamps, mut soft_delete) = (false, false);
170    let (mut from_models, mut database_url) = (false, None);
171    let (mut image, mut port, mut replicas, mut max_replicas, mut out_dir) = (None, None, None, None, None);
172    let mut i = 0;
173    while i < args.len() {
174        let arg = args[i].as_str();
175        i += 1;
176        if arg == "-h" || arg == "--help" || (arg == "help" && positional.is_empty()) {
177            return Ok(Parsed::Help);
178        }
179        if !arg.starts_with("--") {
180            positional.push(arg.to_string());
181            continue;
182        }
183        let (flag, inline) = match arg.split_once('=') {
184            Some((f, v)) => (f, Some(v.to_string())),
185            None => (arg, None),
186        };
187        let mut value = |name: &str| -> Result<String, GenError> {
188            if let Some(v) = inline.clone() {
189                return Ok(v);
190            }
191            let v = args.get(i).cloned().ok_or_else(|| GenError::usage(format!("{name} needs a value")))?;
192            i += 1;
193            Ok(v)
194        };
195        match flag {
196            "--dry-run" => dry_run = true,
197            "--timestamps" => timestamps = true,
198            "--soft-delete" => soft_delete = true,
199            "--json" => json = true,
200            "--dir" => dir = PathBuf::from(value("--dir")?),
201            "--db" => {
202                let v = value("--db")?;
203                db = Some(Db::parse(&v).ok_or_else(|| GenError::usage(format!("--db {v}: use sqlite, postgres or mysql")))?);
204            }
205            "--methods" => methods = Some(value("--methods")?),
206            "--prefix" => prefix = Some(value("--prefix")?),
207            "--app-expr" => app_expr = Some(value("--app-expr")?),
208            "--from-models" => from_models = true,
209            "--image" => image = Some(value("--image")?),
210            "--port" => port = Some(value("--port")?),
211            "--replicas" => replicas = Some(value("--replicas")?),
212            "--max-replicas" => max_replicas = Some(value("--max-replicas")?),
213            "--out" => out_dir = Some(value("--out")?),
214            "--database-url" => database_url = Some(value("--database-url")?),
215            other => return Err(GenError::usage(format!("unknown option {other}"))),
216        }
217    }
218    let Some(kind) = positional.first().cloned() else {
219        return Err(GenError::usage("name a generator: model, route, migration, test or deploy"));
220    };
221    let deploy_flags = image.is_some() || port.is_some() || replicas.is_some() || max_replicas.is_some() || out_dir.is_some();
222    if kind == "deploy" {
223        if let Some(extra) = positional.get(1) {
224            return Err(GenError::usage(format!("rws gen deploy takes no name; `{extra}` is extra")));
225        }
226        let image = image.ok_or_else(|| GenError::usage("rws gen deploy: give the container image with --image"))?;
227        let mut spec = super::DeploySpec::new(&image);
228        let number = |flag: &str, v: &str| v.parse::<u32>().map_err(|_| GenError::usage(format!("{flag} {v}: a number")));
229        if let Some(p) = &port {
230            spec.port = u16::try_from(number("--port", p)?).map_err(|_| GenError::usage(format!("--port {p}: 1 to 65535")))?;
231        }
232        if let Some(r) = &replicas {
233            spec.replicas = number("--replicas", r)?;
234        }
235        if let Some(m) = &max_replicas {
236            spec.max_replicas = number("--max-replicas", m)?;
237        }
238        if let Some(o) = out_dir {
239            spec.dir = o;
240        }
241        return Ok(Parsed::Run(Cli { request: Request::Deploy(spec), dir, dry_run, json, from_models: None }));
242    }
243    if deploy_flags {
244        return Err(GenError::usage("--image, --port, --replicas, --max-replicas and --out apply to rws gen deploy only"));
245    }
246    let name = positional.get(1).cloned();
247    let rest = positional.get(2..).unwrap_or(&[]);
248    let only = |flag: &str, set: bool, kinds: &str| -> Result<(), GenError> {
249        if set {
250            Err(GenError::usage(format!("{flag} applies to rws gen {kinds} only")))
251        } else {
252            Ok(())
253        }
254    };
255    let need_name = |what: &str| name.clone().ok_or_else(|| GenError::usage(format!("rws gen {kind}: give the {what}")));
256    let request = match kind.as_str() {
257        "model" => {
258            only("--methods", methods.is_some(), "route")?;
259            only("--prefix", prefix.is_some(), "route")?;
260            only("--app-expr", app_expr.is_some(), "test")?;
261            Generate::Model { name: need_name("model's name (e.g. Post)")?, fields: parse_fields(rest)?, db, timestamps, soft_delete }
262        }
263        "route" => {
264            only("--db", db.is_some(), "model")?;
265            only("--timestamps", timestamps, "model")?;
266            only("--soft-delete", soft_delete, "model")?;
267            only("--app-expr", app_expr.is_some(), "test")?;
268            let actions = match &methods {
269                Some(m) => RouteAction::parse_list(m)?,
270                None => RouteAction::ALL.to_vec(),
271            };
272            Generate::Route { resource: need_name("resource (e.g. posts)")?, fields: parse_fields(rest)?, actions, prefix: prefix.unwrap_or_default() }
273        }
274        "migration" | "test" => {
275            only("--db", db.is_some() && !(kind == "migration" && from_models), "model (and migration --from-models)")?;
276            only("--timestamps", timestamps, "model")?;
277            only("--soft-delete", soft_delete, "model")?;
278            only("--methods", methods.is_some(), "route")?;
279            only("--prefix", prefix.is_some(), "route")?;
280            if let Some(extra) = rest.first() {
281                return Err(GenError::usage(format!("rws gen {kind} takes one name; `{extra}` is extra")));
282            }
283            if kind == "migration" {
284                only("--app-expr", app_expr.is_some(), "test")?;
285                Generate::Migration { name: need_name("migration's name (e.g. add_index_to_posts)")? }
286            } else {
287                Generate::Test { resource: need_name("resource (e.g. posts)")?, app_expr }
288            }
289        }
290        other => return Err(GenError::usage(format!("unknown generator `{other}`: use model, route, migration, test or deploy"))),
291    };
292    only("--from-models", from_models && kind != "migration", "migration")?;
293    only("--database-url", database_url.is_some() && !from_models, "migration --from-models")?;
294    let from_models = from_models.then_some((database_url, db));
295    Ok(Parsed::Run(Cli { request: Request::Generate(request), dir, dry_run, json, from_models }))
296}
297
298/// `rws gen …` asked for: the arguments after `gen`.
299pub fn requested(args: &[String]) -> Option<&[String]> {
300    (args.get(1).map(String::as_str) == Some("gen")).then(|| &args[2..])
301}