1use 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#[derive(Clone, Copy, Debug, PartialEq, Eq)]
67pub enum Db {
68 Sqlite,
69 Postgres,
70 Mysql,
71}
72
73impl Db {
74 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 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 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#[derive(Clone, Copy, Debug, PartialEq, Eq)]
113pub enum RouteAction {
114 List,
116 Show,
118 Create,
120 Update,
122 Remove,
124}
125
126impl RouteAction {
127 pub const ALL: [RouteAction; 5] = [RouteAction::List, RouteAction::Show, RouteAction::Create, RouteAction::Update, RouteAction::Remove];
129
130 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 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 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#[derive(Clone, Debug, PartialEq, Eq)]
196pub enum Generate {
197 Model { name: String, fields: Vec<Field>, db: Option<Db> },
201 Route { resource: String, fields: Vec<Field>, actions: Vec<RouteAction>, prefix: String },
206 Migration { name: String },
208 Test { resource: String, app_expr: Option<String> },
213}
214
215impl Generate {
216 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 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#[derive(Clone, Debug, PartialEq, Eq)]
263pub struct GenError {
264 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 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#[derive(Clone, Copy, Debug, PartialEq, Eq)]
296pub enum ChangeKind {
297 Create,
299 Edit,
301 Unchanged,
303 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#[derive(Clone, Debug, PartialEq, Eq)]
320pub struct FileChange {
321 pub path: String,
323 pub kind: ChangeKind,
324 pub content: String,
327 pub original: Option<String>,
329 pub diff: String,
332}
333
334#[derive(Clone, Debug, PartialEq, Eq)]
336pub struct Plan {
337 pub command: String,
339 pub changes: Vec<FileChange>,
340 pub notes: Vec<String>,
343}
344
345impl Plan {
346 pub fn has_conflicts(&self) -> bool {
348 self.changes.iter().any(|c| c.kind == ChangeKind::Conflict)
349 }
350
351 pub fn is_unchanged(&self) -> bool {
353 self.changes.iter().all(|c| c.kind == ChangeKind::Unchanged)
354 }
355
356 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 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 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 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 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
453pub(crate) trait Files {
455 fn read(&self, path: &str) -> Option<String>;
456 fn list(&self, dir: &str) -> Vec<String>;
458 fn manifest(&self) -> Result<AppManifest, GenError>;
459}
460
461pub(crate) struct Disk(pub PathBuf);
463
464const 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
489pub 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
498struct Builder<'a> {
500 files: &'a dyn Files,
501 changes: Vec<FileChange>,
502 notes: Vec<String>,
503}
504
505impl<'a> Builder<'a> {
506 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 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
554fn 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
571fn 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
597fn 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
611fn 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
618fn 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
634fn 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
708fn 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
742fn 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 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 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#[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