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