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