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  test <resource> [--app-expr 'app(...)']  tests/<resource>.rs: TestClient against app()
28
29Field types: i64, i32, f64, bool, String, Option<T> (T one of those).
30Paths come from rws.app.toml (the conventional layout without one). --db
31defaults to the model-* feature in Cargo.toml, else sqlite.
32
33Nothing is overwritten: a file that exists with other content is a conflict,
34shown as a diff, and nothing is written. Running a generator twice changes
35nothing the second time. --dry-run prints the plan and the diffs without
36writing; --json prints it for agents.
37Exit codes: 0 written (or already as generated), 1 conflict or refused, 2 usage.
38";
39
40/// Run `rws gen <args>` (the arguments after `gen`), writing to `out` and
41/// `err`; answers the exit code ([`EXIT_OK`], [`EXIT_CONFLICT`],
42/// [`EXIT_USAGE`]).
43pub fn run(args: &[String], out: &mut dyn Write, err: &mut dyn Write) -> i32 {
44    let parsed = match parse(args) {
45        Ok(Parsed::Help) => {
46            let _ = write!(out, "{USAGE}");
47            return EXIT_OK;
48        }
49        Ok(Parsed::Run(cli)) => cli,
50        Err(e) => return fail(&e, args.iter().any(|a| a == "--json"), out, err),
51    };
52    let plan = match plan(&parsed.dir, &parsed.request) {
53        Ok(plan) => plan,
54        Err(e) => return fail(&e, parsed.json, out, err),
55    };
56    if plan.has_conflicts() || parsed.dry_run || plan.is_unchanged() {
57        if parsed.json {
58            let _ = writeln!(out, "{}", plan.to_json(false));
59        } else {
60            let _ = write!(out, "{}", plan.render(parsed.dry_run));
61            if plan.has_conflicts() {
62                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.");
63            } else if plan.is_unchanged() {
64                let _ = writeln!(out, "nothing to do: every file is already as generated");
65            }
66        }
67        return if plan.has_conflicts() { EXIT_CONFLICT } else { EXIT_OK };
68    }
69    match plan.apply(&parsed.dir) {
70        Ok(_) => {
71            if parsed.json {
72                let _ = writeln!(out, "{}", plan.to_json(true));
73            } else {
74                let _ = write!(out, "{}", plan.render(false));
75            }
76            EXIT_OK
77        }
78        Err(e) => fail(&e, parsed.json, out, err),
79    }
80}
81
82fn fail(e: &GenError, json: bool, out: &mut dyn Write, err: &mut dyn Write) -> i32 {
83    if json {
84        let _ = writeln!(out, "{}", e.to_json());
85    } else if e.usage {
86        let _ = writeln!(err, "rws gen: {}\n(rws gen --help lists the generators and their arguments)", e.message);
87    } else {
88        let _ = writeln!(err, "rws gen: {}", e.message);
89    }
90    if e.usage {
91        EXIT_USAGE
92    } else {
93        EXIT_CONFLICT
94    }
95}
96
97struct Cli {
98    request: Generate,
99    dir: PathBuf,
100    dry_run: bool,
101    json: bool,
102}
103
104enum Parsed {
105    Help,
106    Run(Cli),
107}
108
109fn parse(args: &[String]) -> Result<Parsed, GenError> {
110    let mut positional: Vec<String> = Vec::new();
111    let mut dir = PathBuf::from(".");
112    let (mut dry_run, mut json) = (false, false);
113    let (mut db, mut methods, mut prefix, mut app_expr) = (None, None, None, None);
114    let (mut timestamps, mut soft_delete) = (false, false);
115    let mut i = 0;
116    while i < args.len() {
117        let arg = args[i].as_str();
118        i += 1;
119        if arg == "-h" || arg == "--help" || (arg == "help" && positional.is_empty()) {
120            return Ok(Parsed::Help);
121        }
122        if !arg.starts_with("--") {
123            positional.push(arg.to_string());
124            continue;
125        }
126        let (flag, inline) = match arg.split_once('=') {
127            Some((f, v)) => (f, Some(v.to_string())),
128            None => (arg, None),
129        };
130        let mut value = |name: &str| -> Result<String, GenError> {
131            if let Some(v) = inline.clone() {
132                return Ok(v);
133            }
134            let v = args.get(i).cloned().ok_or_else(|| GenError::usage(format!("{name} needs a value")))?;
135            i += 1;
136            Ok(v)
137        };
138        match flag {
139            "--dry-run" => dry_run = true,
140            "--timestamps" => timestamps = true,
141            "--soft-delete" => soft_delete = true,
142            "--json" => json = true,
143            "--dir" => dir = PathBuf::from(value("--dir")?),
144            "--db" => {
145                let v = value("--db")?;
146                db = Some(Db::parse(&v).ok_or_else(|| GenError::usage(format!("--db {v}: use sqlite, postgres or mysql")))?);
147            }
148            "--methods" => methods = Some(value("--methods")?),
149            "--prefix" => prefix = Some(value("--prefix")?),
150            "--app-expr" => app_expr = Some(value("--app-expr")?),
151            other => return Err(GenError::usage(format!("unknown option {other}"))),
152        }
153    }
154    let Some(kind) = positional.first().cloned() else {
155        return Err(GenError::usage("name a generator: model, route, migration or test"));
156    };
157    let name = positional.get(1).cloned();
158    let rest = positional.get(2..).unwrap_or(&[]);
159    let only = |flag: &str, set: bool, kinds: &str| -> Result<(), GenError> {
160        if set {
161            Err(GenError::usage(format!("{flag} applies to rws gen {kinds} only")))
162        } else {
163            Ok(())
164        }
165    };
166    let need_name = |what: &str| name.clone().ok_or_else(|| GenError::usage(format!("rws gen {kind}: give the {what}")));
167    let request = match kind.as_str() {
168        "model" => {
169            only("--methods", methods.is_some(), "route")?;
170            only("--prefix", prefix.is_some(), "route")?;
171            only("--app-expr", app_expr.is_some(), "test")?;
172            Generate::Model { name: need_name("model's name (e.g. Post)")?, fields: parse_fields(rest)?, db, timestamps, soft_delete }
173        }
174        "route" => {
175            only("--db", db.is_some(), "model")?;
176            only("--timestamps", timestamps, "model")?;
177            only("--soft-delete", soft_delete, "model")?;
178            only("--app-expr", app_expr.is_some(), "test")?;
179            let actions = match &methods {
180                Some(m) => RouteAction::parse_list(m)?,
181                None => RouteAction::ALL.to_vec(),
182            };
183            Generate::Route { resource: need_name("resource (e.g. posts)")?, fields: parse_fields(rest)?, actions, prefix: prefix.unwrap_or_default() }
184        }
185        "migration" | "test" => {
186            only("--db", db.is_some(), "model")?;
187            only("--timestamps", timestamps, "model")?;
188            only("--soft-delete", soft_delete, "model")?;
189            only("--methods", methods.is_some(), "route")?;
190            only("--prefix", prefix.is_some(), "route")?;
191            if let Some(extra) = rest.first() {
192                return Err(GenError::usage(format!("rws gen {kind} takes one name; `{extra}` is extra")));
193            }
194            if kind == "migration" {
195                only("--app-expr", app_expr.is_some(), "test")?;
196                Generate::Migration { name: need_name("migration's name (e.g. add_index_to_posts)")? }
197            } else {
198                Generate::Test { resource: need_name("resource (e.g. posts)")?, app_expr }
199            }
200        }
201        other => return Err(GenError::usage(format!("unknown generator `{other}`: use model, route, migration or test"))),
202    };
203    Ok(Parsed::Run(Cli { request, dir, dry_run, json }))
204}
205
206/// `rws gen …` asked for: the arguments after `gen`.
207pub fn requested(args: &[String]) -> Option<&[String]> {
208    (args.get(1).map(String::as_str) == Some("gen")).then(|| &args[2..])
209}