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