1use crate::register::{insert_chain_call_after_anchor, insert_use_after_last_use, RegisterOutcome};
21use std::fs;
22use std::io;
23use std::path::{Path, PathBuf};
24
25pub struct GenerateModuleArgs {
27 pub name: String,
31 pub project_root: Option<PathBuf>,
33 pub force: bool,
36 pub auto_register: bool,
40}
41
42#[derive(Debug)]
43pub enum GenerateError {
44 InvalidName(String),
45 InvalidSpec(String),
46 ProjectRootNotFound(PathBuf),
47 ModuleExists(PathBuf),
48 ModuleMissing(PathBuf),
49 FileExists(PathBuf),
50 Io { path: PathBuf, source: io::Error },
51}
52
53impl std::fmt::Display for GenerateError {
54 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
55 match self {
56 Self::InvalidName(n) => write!(
57 f,
58 "`{n}` is not a valid name. Use lowercase letters, digits, and underscores only (and start with a letter)."
59 ),
60 Self::InvalidSpec(s) => write!(
61 f,
62 "`{s}` is not a valid spec. Expected `<module>/<name>` (e.g. `users/email_sender`)."
63 ),
64 Self::ProjectRootNotFound(start) => write!(
65 f,
66 "could not find a kick-rs project from `{}` or any parent. Looking for `src/modules/mod.rs`.",
67 start.display()
68 ),
69 Self::ModuleExists(p) => write!(
70 f,
71 "module directory `{}` already exists. Re-run with --force to overwrite the files inside.",
72 p.display()
73 ),
74 Self::ModuleMissing(p) => write!(
75 f,
76 "parent module `{}` does not exist. Generate it first with `cargo kick g module`.",
77 p.display()
78 ),
79 Self::FileExists(p) => write!(
80 f,
81 "file `{}` already exists. Re-run with --force to overwrite.",
82 p.display()
83 ),
84 Self::Io { path, source } => write!(f, "I/O error at `{}`: {source}", path.display()),
85 }
86 }
87}
88
89impl std::error::Error for GenerateError {}
90
91pub fn validate_module_name(name: &str) -> Result<(), GenerateError> {
94 let bad = |reason: &str| -> GenerateError {
95 GenerateError::InvalidName(format!("{name} ({reason})"))
96 };
97 if name.is_empty() {
98 return Err(bad("empty"));
99 }
100 let mut chars = name.chars();
101 let first = chars.next().unwrap();
102 if !first.is_ascii_lowercase() {
103 return Err(bad("must start with a lowercase letter"));
104 }
105 for c in chars {
106 let ok = c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_';
107 if !ok {
108 return Err(bad(
109 "illegal character (hyphens not allowed in module names)",
110 ));
111 }
112 }
113 const RUST_KEYWORDS: &[&str] = &[
116 "as", "break", "const", "continue", "crate", "else", "enum", "extern", "false", "fn",
117 "for", "if", "impl", "in", "let", "loop", "match", "mod", "move", "mut", "pub", "ref",
118 "return", "self", "static", "struct", "super", "trait", "true", "type", "unsafe", "use",
119 "where", "while", "async", "await", "dyn",
120 ];
121 if RUST_KEYWORDS.contains(&name) {
122 return Err(bad("is a Rust keyword"));
123 }
124 Ok(())
125}
126
127pub fn find_project_root(start: &Path) -> Result<PathBuf, GenerateError> {
130 let mut cur = if start.is_absolute() {
131 start.to_path_buf()
132 } else {
133 std::env::current_dir()
134 .map_err(|e| GenerateError::Io {
135 path: start.to_path_buf(),
136 source: e,
137 })?
138 .join(start)
139 };
140 loop {
141 if cur.join("src/modules/mod.rs").is_file() {
142 return Ok(cur);
143 }
144 if !cur.pop() {
145 return Err(GenerateError::ProjectRootNotFound(start.to_path_buf()));
146 }
147 }
148}
149
150const MOD_TMPL: &str = include_str!("../templates/generate/module/mod.rs.tmpl");
152const HANDLERS_TMPL: &str = include_str!("../templates/generate/module/handlers.rs.tmpl");
153
154const SERVICE_TMPL: &str = include_str!("../templates/generate/service/file.rs.tmpl");
156
157const CONTRIBUTOR_TMPL: &str = include_str!("../templates/generate/contributor/file.rs.tmpl");
159
160pub fn to_pascal_case(snake: &str) -> String {
166 let mut out = String::with_capacity(snake.len());
167 let mut upper_next = true;
168 for c in snake.chars() {
169 if c == '_' {
170 upper_next = true;
171 continue;
172 }
173 if upper_next {
174 out.extend(c.to_uppercase());
175 upper_next = false;
176 } else {
177 out.push(c);
178 }
179 }
180 out
181}
182
183fn render(template: &str, name: &str) -> String {
184 template
185 .replace("{{module_name_snake}}", name)
189 .replace("{{module_name}}", name)
190}
191
192fn render_service(template: &str, snake: &str, pascal: &str) -> String {
193 template
194 .replace("{{service_snake}}", snake)
195 .replace("{{service_pascal}}", pascal)
196}
197
198fn render_contributor(template: &str, snake: &str, pascal: &str) -> String {
199 template
200 .replace("{{contributor_snake}}", snake)
201 .replace("{{contributor_pascal}}", pascal)
202}
203
204#[derive(Debug)]
207pub struct GenerateModuleResult {
208 pub module_dir: PathBuf,
209 pub register: RegisterOutcome,
210}
211
212pub fn generate_module(args: &GenerateModuleArgs) -> Result<GenerateModuleResult, GenerateError> {
214 validate_module_name(&args.name)?;
215
216 let root = match &args.project_root {
217 Some(p) => p.clone(),
218 None => find_project_root(Path::new("."))?,
219 };
220 if !root.join("src/modules/mod.rs").is_file() {
221 return Err(GenerateError::ProjectRootNotFound(root));
222 }
223
224 let module_dir = root.join("src/modules").join(&args.name);
225 if module_dir.exists() && !args.force {
226 return Err(GenerateError::ModuleExists(module_dir));
227 }
228 fs::create_dir_all(&module_dir).map_err(|e| GenerateError::Io {
229 path: module_dir.clone(),
230 source: e,
231 })?;
232
233 let mod_rs = module_dir.join("mod.rs");
234 fs::write(&mod_rs, render(MOD_TMPL, &args.name)).map_err(|e| GenerateError::Io {
235 path: mod_rs.clone(),
236 source: e,
237 })?;
238
239 let handlers_rs = module_dir.join("handlers.rs");
240 fs::write(&handlers_rs, render(HANDLERS_TMPL, &args.name)).map_err(|e| GenerateError::Io {
241 path: handlers_rs.clone(),
242 source: e,
243 })?;
244
245 let modules_mod = root.join("src/modules/mod.rs");
246 ensure_pub_mod_line(&modules_mod, &args.name)?;
247
248 let register = if args.auto_register {
249 try_register_module_in_main(&root, &args.name)?
250 } else {
251 RegisterOutcome::Skipped
252 };
253
254 Ok(GenerateModuleResult {
255 module_dir,
256 register,
257 })
258}
259
260fn try_register_module_in_main(root: &Path, name: &str) -> Result<RegisterOutcome, GenerateError> {
264 let main_rs = root.join("src/main.rs");
265 if !main_rs.is_file() {
266 return Ok(RegisterOutcome::TargetMissing);
267 }
268 let mut contents = fs::read_to_string(&main_rs).map_err(|e| GenerateError::Io {
269 path: main_rs.clone(),
270 source: e,
271 })?;
272
273 let signature = format!("modules::{name}::define()");
274 if contents.contains(&signature) {
275 return Ok(RegisterOutcome::AlreadyRegistered);
276 }
277
278 let call = format!(".module(modules::{name}::define())");
279 let inserted = insert_chain_call_after_anchor(&mut contents, ".module(modules::", &call)
280 || insert_chain_call_after_anchor(&mut contents, "bootstrap()", &call);
281 if !inserted {
282 return Ok(RegisterOutcome::AnchorNotFound);
283 }
284
285 fs::write(&main_rs, contents).map_err(|e| GenerateError::Io {
286 path: main_rs.clone(),
287 source: e,
288 })?;
289 Ok(RegisterOutcome::Inserted)
290}
291
292fn ensure_pub_mod_line(target: &Path, name: &str) -> Result<(), GenerateError> {
296 let mut contents = fs::read_to_string(target).map_err(|e| GenerateError::Io {
297 path: target.to_path_buf(),
298 source: e,
299 })?;
300 let decl = format!("pub mod {name};");
301 if contents.lines().any(|line| line.trim() == decl) {
302 return Ok(());
303 }
304 if !contents.ends_with('\n') {
305 contents.push('\n');
306 }
307 contents.push_str(&decl);
308 contents.push('\n');
309 fs::write(target, contents).map_err(|e| GenerateError::Io {
310 path: target.to_path_buf(),
311 source: e,
312 })
313}
314
315pub struct GenerateServiceArgs {
319 pub spec: String,
321 pub project_root: Option<PathBuf>,
323 pub force: bool,
325 pub auto_register: bool,
328}
329
330fn parse_kind_spec(spec: &str) -> Result<(&str, &str), GenerateError> {
334 let (module, name) = spec
335 .split_once('/')
336 .ok_or_else(|| GenerateError::InvalidSpec(spec.to_owned()))?;
337 if module.is_empty() || name.is_empty() {
338 return Err(GenerateError::InvalidSpec(spec.to_owned()));
339 }
340 if name.contains('/') {
341 return Err(GenerateError::InvalidSpec(spec.to_owned()));
342 }
343 validate_module_name(module)?;
344 validate_module_name(name)?;
345 Ok((module, name))
346}
347
348#[derive(Debug)]
350pub struct GenerateServiceResult {
351 pub file: PathBuf,
352 pub register: RegisterOutcome,
353}
354
355pub fn generate_service(
357 args: &GenerateServiceArgs,
358) -> Result<GenerateServiceResult, GenerateError> {
359 let (module, service_snake) = parse_kind_spec(&args.spec)?;
360 let service_pascal = to_pascal_case(service_snake);
361
362 let root = match &args.project_root {
363 Some(p) => p.clone(),
364 None => find_project_root(Path::new("."))?,
365 };
366 let module_mod_rs = root.join("src/modules").join(module).join("mod.rs");
367 if !module_mod_rs.is_file() {
368 return Err(GenerateError::ModuleMissing(
369 root.join("src/modules").join(module),
370 ));
371 }
372
373 let service_file = root
374 .join("src/modules")
375 .join(module)
376 .join(format!("{service_snake}.rs"));
377 if service_file.exists() && !args.force {
378 return Err(GenerateError::FileExists(service_file));
379 }
380
381 let rendered = render_service(SERVICE_TMPL, service_snake, &service_pascal);
382 fs::write(&service_file, rendered).map_err(|e| GenerateError::Io {
383 path: service_file.clone(),
384 source: e,
385 })?;
386
387 ensure_pub_mod_line(&module_mod_rs, service_snake)?;
388
389 let register = if args.auto_register {
390 try_register_in_define_builder(
391 &module_mod_rs,
392 service_snake,
393 &service_pascal,
394 DefineBuilderKind::Service,
395 )?
396 } else {
397 RegisterOutcome::Skipped
398 };
399
400 Ok(GenerateServiceResult {
401 file: service_file,
402 register,
403 })
404}
405
406#[derive(Clone, Copy)]
409enum DefineBuilderKind {
410 Service,
411 Contributor,
412}
413
414impl DefineBuilderKind {
415 fn anchor_substring(self) -> &'static str {
416 match self {
417 Self::Service => ".service::<",
418 Self::Contributor => ".contribute(",
419 }
420 }
421 fn call(self, pascal: &str) -> String {
422 match self {
423 Self::Service => format!(".service::<{pascal}>()"),
424 Self::Contributor => format!(".contribute({pascal})"),
425 }
426 }
427}
428
429fn try_register_in_define_builder(
432 module_mod_rs: &Path,
433 snake: &str,
434 pascal: &str,
435 kind: DefineBuilderKind,
436) -> Result<RegisterOutcome, GenerateError> {
437 let mut contents = fs::read_to_string(module_mod_rs).map_err(|e| GenerateError::Io {
438 path: module_mod_rs.to_path_buf(),
439 source: e,
440 })?;
441
442 let call = kind.call(pascal);
443 if contents.contains(&call) {
444 return Ok(RegisterOutcome::AlreadyRegistered);
445 }
446
447 let use_line = format!("use {snake}::{pascal};");
449 if !contents.lines().any(|l| l.trim() == use_line) {
450 insert_use_after_last_use(&mut contents, &use_line);
451 }
452
453 let inserted = insert_chain_call_after_anchor(&mut contents, kind.anchor_substring(), &call)
454 || insert_chain_call_after_anchor(&mut contents, "define_module(", &call);
455 if !inserted {
456 return Ok(RegisterOutcome::AnchorNotFound);
457 }
458
459 fs::write(module_mod_rs, contents).map_err(|e| GenerateError::Io {
460 path: module_mod_rs.to_path_buf(),
461 source: e,
462 })?;
463 Ok(RegisterOutcome::Inserted)
464}
465
466pub struct GenerateContributorArgs {
470 pub spec: String,
472 pub project_root: Option<PathBuf>,
474 pub force: bool,
476 pub auto_register: bool,
479}
480
481#[derive(Debug)]
483pub struct GenerateContributorResult {
484 pub file: PathBuf,
485 pub register: RegisterOutcome,
486}
487
488pub fn generate_contributor(
490 args: &GenerateContributorArgs,
491) -> Result<GenerateContributorResult, GenerateError> {
492 let (module, snake) = parse_kind_spec(&args.spec)?;
493 let pascal = to_pascal_case(snake);
494
495 let root = match &args.project_root {
496 Some(p) => p.clone(),
497 None => find_project_root(Path::new("."))?,
498 };
499 let module_mod_rs = root.join("src/modules").join(module).join("mod.rs");
500 if !module_mod_rs.is_file() {
501 return Err(GenerateError::ModuleMissing(
502 root.join("src/modules").join(module),
503 ));
504 }
505
506 let file = root
507 .join("src/modules")
508 .join(module)
509 .join(format!("{snake}.rs"));
510 if file.exists() && !args.force {
511 return Err(GenerateError::FileExists(file));
512 }
513
514 let rendered = render_contributor(CONTRIBUTOR_TMPL, snake, &pascal);
515 fs::write(&file, rendered).map_err(|e| GenerateError::Io {
516 path: file.clone(),
517 source: e,
518 })?;
519
520 ensure_pub_mod_line(&module_mod_rs, snake)?;
521
522 let register = if args.auto_register {
523 try_register_in_define_builder(
524 &module_mod_rs,
525 snake,
526 &pascal,
527 DefineBuilderKind::Contributor,
528 )?
529 } else {
530 RegisterOutcome::Skipped
531 };
532
533 Ok(GenerateContributorResult { file, register })
534}
535
536#[cfg(test)]
537mod tests {
538 use super::*;
539
540 fn make_skeleton(dir: &Path) {
543 fs::create_dir_all(dir.join("src/modules")).unwrap();
544 fs::write(dir.join("src/modules/mod.rs"), "pub mod hello;\n").unwrap();
545 fs::write(dir.join("Cargo.toml"), "[package]\nname = \"x\"\n").unwrap();
546 }
547
548 #[test]
549 fn validate_module_name_accepts_typical_names() {
550 assert!(validate_module_name("posts").is_ok());
551 assert!(validate_module_name("user_session").is_ok());
552 assert!(validate_module_name("v1").is_ok());
553 }
554
555 #[test]
556 fn validate_module_name_rejects_bad_names() {
557 assert!(validate_module_name("").is_err());
558 assert!(validate_module_name("Posts").is_err()); assert!(validate_module_name("has-hyphen").is_err()); assert!(validate_module_name("1leading").is_err()); assert!(validate_module_name("fn").is_err()); assert!(validate_module_name("type").is_err()); }
564
565 #[test]
566 fn find_project_root_walks_up_until_modules_anchor() {
567 let tmp = tempfile::tempdir().unwrap();
568 let root = tmp.path().join("proj");
569 make_skeleton(&root);
570
571 assert_eq!(find_project_root(&root).unwrap(), root);
573
574 let deep = root.join("src/modules/hello");
576 fs::create_dir_all(&deep).unwrap();
577 assert_eq!(find_project_root(&deep).unwrap(), root);
578 }
579
580 #[test]
581 fn find_project_root_errors_outside_a_project() {
582 let tmp = tempfile::tempdir().unwrap();
583 let err = find_project_root(tmp.path()).unwrap_err();
585 assert!(matches!(err, GenerateError::ProjectRootNotFound(_)));
586 }
587
588 #[test]
589 fn generate_module_writes_files_and_appends_modules_mod() {
590 let tmp = tempfile::tempdir().unwrap();
591 let root = tmp.path().join("proj");
592 make_skeleton(&root);
593
594 let args = GenerateModuleArgs {
595 name: "posts".into(),
596 project_root: Some(root.clone()),
597 force: false,
598 auto_register: false,
599 };
600 let res = generate_module(&args).unwrap();
601 assert_eq!(res.module_dir, root.join("src/modules/posts"));
602
603 let mod_rs = fs::read_to_string(res.module_dir.join("mod.rs")).unwrap();
604 assert!(mod_rs.contains(r#"define_module("posts")"#));
605 assert!(mod_rs.contains(r#".prefix("/posts")"#));
606
607 let handlers_rs = fs::read_to_string(res.module_dir.join("handlers.rs")).unwrap();
608 assert!(handlers_rs.contains("posts index"));
609
610 let modules_mod = fs::read_to_string(root.join("src/modules/mod.rs")).unwrap();
612 let count = modules_mod.matches("pub mod posts;").count();
613 assert_eq!(count, 1, "expected one append, got {count}: {modules_mod}");
614 }
615
616 #[test]
617 fn generate_module_appending_is_idempotent() {
618 let tmp = tempfile::tempdir().unwrap();
619 let root = tmp.path().join("proj");
620 make_skeleton(&root);
621
622 let args = GenerateModuleArgs {
623 name: "posts".into(),
624 project_root: Some(root.clone()),
625 force: false,
626 auto_register: false,
627 };
628 generate_module(&args).unwrap();
629
630 let args2 = GenerateModuleArgs {
632 force: true,
633 ..args
634 };
635 generate_module(&args2).unwrap();
636
637 let modules_mod = fs::read_to_string(root.join("src/modules/mod.rs")).unwrap();
638 assert_eq!(
639 modules_mod.matches("pub mod posts;").count(),
640 1,
641 "double-append on second generate: {modules_mod}"
642 );
643 }
644
645 #[test]
646 fn generate_module_refuses_existing_dir_without_force() {
647 let tmp = tempfile::tempdir().unwrap();
648 let root = tmp.path().join("proj");
649 make_skeleton(&root);
650 fs::create_dir_all(root.join("src/modules/posts")).unwrap();
651
652 let err = generate_module(&GenerateModuleArgs {
653 name: "posts".into(),
654 project_root: Some(root.clone()),
655 force: false,
656 auto_register: false,
657 })
658 .unwrap_err();
659 assert!(matches!(err, GenerateError::ModuleExists(_)), "got {err:?}");
660 }
661
662 #[test]
665 fn to_pascal_case_converts_snake() {
666 assert_eq!(to_pascal_case("email"), "Email");
667 assert_eq!(to_pascal_case("email_sender"), "EmailSender");
668 assert_eq!(to_pascal_case("user_repository"), "UserRepository");
669 assert_eq!(to_pascal_case("v1_handler"), "V1Handler");
670 assert_eq!(to_pascal_case(""), "");
672 }
673
674 #[test]
675 fn parse_kind_spec_splits_module_and_name() {
676 assert_eq!(parse_kind_spec("users/email").unwrap(), ("users", "email"));
677 assert_eq!(
678 parse_kind_spec("users/email_sender").unwrap(),
679 ("users", "email_sender")
680 );
681 }
682
683 #[test]
684 fn parse_kind_spec_rejects_bad_shapes() {
685 assert!(matches!(
687 parse_kind_spec("emailsender").unwrap_err(),
688 GenerateError::InvalidSpec(_)
689 ));
690 assert!(matches!(
692 parse_kind_spec("/email").unwrap_err(),
693 GenerateError::InvalidSpec(_)
694 ));
695 assert!(matches!(
696 parse_kind_spec("users/").unwrap_err(),
697 GenerateError::InvalidSpec(_)
698 ));
699 assert!(matches!(
701 parse_kind_spec("users/sub/email").unwrap_err(),
702 GenerateError::InvalidSpec(_)
703 ));
704 assert!(matches!(
706 parse_kind_spec("Users/email").unwrap_err(),
707 GenerateError::InvalidName(_)
708 ));
709 assert!(matches!(
710 parse_kind_spec("users/Email").unwrap_err(),
711 GenerateError::InvalidName(_)
712 ));
713 }
714
715 fn make_skeleton_with_module(dir: &Path, module: &str) {
716 make_skeleton(dir);
717 fs::create_dir_all(dir.join("src/modules").join(module)).unwrap();
718 fs::write(
719 dir.join("src/modules").join(module).join("mod.rs"),
720 format!("//! {module}\npub mod handlers;\n"),
721 )
722 .unwrap();
723 let decl = format!("pub mod {module};\n");
726 let mut top = fs::read_to_string(dir.join("src/modules/mod.rs")).unwrap();
727 top.push_str(&decl);
728 fs::write(dir.join("src/modules/mod.rs"), top).unwrap();
729 }
730
731 #[test]
732 fn generate_service_writes_file_and_appends_module_mod() {
733 let tmp = tempfile::tempdir().unwrap();
734 let root = tmp.path().join("proj");
735 make_skeleton_with_module(&root, "users");
736
737 let res = generate_service(&GenerateServiceArgs {
738 spec: "users/email_sender".into(),
739 project_root: Some(root.clone()),
740 force: false,
741 auto_register: false,
742 })
743 .unwrap();
744
745 assert_eq!(res.file, root.join("src/modules/users/email_sender.rs"));
746
747 let body = fs::read_to_string(&res.file).unwrap();
748 assert!(body.contains("pub struct EmailSender"));
749 assert!(body.contains(r#""email_sender ready""#));
750
751 let mod_rs = fs::read_to_string(root.join("src/modules/users/mod.rs")).unwrap();
752 assert_eq!(
753 mod_rs.matches("pub mod email_sender;").count(),
754 1,
755 "expected one append in module mod.rs: {mod_rs}"
756 );
757 }
758
759 #[test]
760 fn generate_service_refuses_when_parent_module_missing() {
761 let tmp = tempfile::tempdir().unwrap();
762 let root = tmp.path().join("proj");
763 make_skeleton(&root);
764 let err = generate_service(&GenerateServiceArgs {
767 spec: "users/email_sender".into(),
768 project_root: Some(root.clone()),
769 force: false,
770 auto_register: false,
771 })
772 .unwrap_err();
773 assert!(
774 matches!(err, GenerateError::ModuleMissing(_)),
775 "got {err:?}"
776 );
777 }
778
779 #[test]
780 fn generate_service_refuses_existing_file_without_force() {
781 let tmp = tempfile::tempdir().unwrap();
782 let root = tmp.path().join("proj");
783 make_skeleton_with_module(&root, "users");
784 fs::write(
785 root.join("src/modules/users/email_sender.rs"),
786 "// user wrote this",
787 )
788 .unwrap();
789
790 let err = generate_service(&GenerateServiceArgs {
791 spec: "users/email_sender".into(),
792 project_root: Some(root.clone()),
793 force: false,
794 auto_register: false,
795 })
796 .unwrap_err();
797 assert!(matches!(err, GenerateError::FileExists(_)), "got {err:?}");
798 }
799
800 #[test]
801 fn generate_service_force_overwrites_but_append_stays_idempotent() {
802 let tmp = tempfile::tempdir().unwrap();
803 let root = tmp.path().join("proj");
804 make_skeleton_with_module(&root, "users");
805
806 let args = GenerateServiceArgs {
807 spec: "users/email_sender".into(),
808 project_root: Some(root.clone()),
809 force: false,
810 auto_register: false,
811 };
812 generate_service(&args).unwrap();
813
814 let force_args = GenerateServiceArgs {
815 spec: "users/email_sender".into(),
816 project_root: Some(root.clone()),
817 force: true,
818 auto_register: false,
819 };
820 generate_service(&force_args).unwrap();
821
822 let mod_rs = fs::read_to_string(root.join("src/modules/users/mod.rs")).unwrap();
823 assert_eq!(
824 mod_rs.matches("pub mod email_sender;").count(),
825 1,
826 "double-append on second generate: {mod_rs}"
827 );
828 }
829
830 #[test]
833 fn generate_contributor_writes_file_and_appends_module_mod() {
834 let tmp = tempfile::tempdir().unwrap();
835 let root = tmp.path().join("proj");
836 make_skeleton_with_module(&root, "users");
837
838 let res = generate_contributor(&GenerateContributorArgs {
839 spec: "users/load_current_user".into(),
840 project_root: Some(root.clone()),
841 force: false,
842 auto_register: false,
843 })
844 .unwrap();
845
846 assert_eq!(
847 res.file,
848 root.join("src/modules/users/load_current_user.rs")
849 );
850
851 let body = fs::read_to_string(&res.file).unwrap();
852 assert!(body.contains("pub async fn LoadCurrentUser("));
855 assert!(body.contains("pub struct LoadCurrentUserOut"));
856 assert!(body.contains("#[contributor]"));
859
860 let mod_rs = fs::read_to_string(root.join("src/modules/users/mod.rs")).unwrap();
861 assert_eq!(
862 mod_rs.matches("pub mod load_current_user;").count(),
863 1,
864 "expected one append in module mod.rs: {mod_rs}"
865 );
866 }
867
868 #[test]
869 fn generate_contributor_refuses_when_parent_module_missing() {
870 let tmp = tempfile::tempdir().unwrap();
871 let root = tmp.path().join("proj");
872 make_skeleton(&root);
873
874 let err = generate_contributor(&GenerateContributorArgs {
875 spec: "users/load_current_user".into(),
876 project_root: Some(root.clone()),
877 force: false,
878 auto_register: false,
879 })
880 .unwrap_err();
881 assert!(
882 matches!(err, GenerateError::ModuleMissing(_)),
883 "got {err:?}"
884 );
885 }
886
887 #[test]
888 fn generate_contributor_refuses_existing_file_without_force() {
889 let tmp = tempfile::tempdir().unwrap();
890 let root = tmp.path().join("proj");
891 make_skeleton_with_module(&root, "users");
892 fs::write(
893 root.join("src/modules/users/load_current_user.rs"),
894 "// user wrote this",
895 )
896 .unwrap();
897
898 let err = generate_contributor(&GenerateContributorArgs {
899 spec: "users/load_current_user".into(),
900 project_root: Some(root.clone()),
901 force: false,
902 auto_register: false,
903 })
904 .unwrap_err();
905 assert!(matches!(err, GenerateError::FileExists(_)), "got {err:?}");
906 }
907
908 #[test]
909 fn generate_contributor_force_keeps_append_idempotent() {
910 let tmp = tempfile::tempdir().unwrap();
911 let root = tmp.path().join("proj");
912 make_skeleton_with_module(&root, "users");
913
914 generate_contributor(&GenerateContributorArgs {
915 spec: "users/load_current_user".into(),
916 project_root: Some(root.clone()),
917 force: false,
918 auto_register: false,
919 })
920 .unwrap();
921 generate_contributor(&GenerateContributorArgs {
922 spec: "users/load_current_user".into(),
923 project_root: Some(root.clone()),
924 force: true,
925 auto_register: false,
926 })
927 .unwrap();
928
929 let mod_rs = fs::read_to_string(root.join("src/modules/users/mod.rs")).unwrap();
930 assert_eq!(
931 mod_rs.matches("pub mod load_current_user;").count(),
932 1,
933 "double-append on second generate: {mod_rs}"
934 );
935 }
936
937 fn make_skeleton_with_main_and_define(dir: &Path, module: &str) {
943 make_skeleton_with_module(dir, module);
944 fs::write(
948 dir.join("src/modules").join(module).join("mod.rs"),
949 format!(
950 "//! `{module}` resource.\n\
951 pub mod handlers;\n\
952 \n\
953 use kick_rs::{{define_module, Module}};\n\
954 \n\
955 pub fn define() -> Module {{\n \
956 define_module(\"{module}\")\n \
957 .prefix(\"/{module}\")\n \
958 .build()\n\
959 }}\n",
960 ),
961 )
962 .unwrap();
963 fs::write(
964 dir.join("src/main.rs"),
965 "use kick_rs::{bootstrap, KickResult};\n\
966 mod modules;\n\
967 \n\
968 #[tokio::main]\n\
969 async fn main() -> KickResult<()> {\n \
970 bootstrap()\n \
971 .listen(\"0.0.0.0:3000\")\n \
972 .await\n\
973 }\n",
974 )
975 .unwrap();
976 }
977
978 #[test]
979 fn generate_module_auto_registers_in_main() {
980 let tmp = tempfile::tempdir().unwrap();
981 let root = tmp.path().join("proj");
982 make_skeleton_with_main_and_define(&root, "users");
983
984 let res = generate_module(&GenerateModuleArgs {
985 name: "posts".into(),
986 project_root: Some(root.clone()),
987 force: false,
988 auto_register: true,
989 })
990 .unwrap();
991 assert_eq!(res.register, RegisterOutcome::Inserted);
992
993 let main_rs = fs::read_to_string(root.join("src/main.rs")).unwrap();
994 assert!(
995 main_rs.contains(" .module(modules::posts::define())"),
996 "got: {main_rs}"
997 );
998 }
999
1000 #[test]
1001 fn generate_module_auto_register_is_idempotent() {
1002 let tmp = tempfile::tempdir().unwrap();
1003 let root = tmp.path().join("proj");
1004 make_skeleton_with_main_and_define(&root, "users");
1005
1006 let first = generate_module(&GenerateModuleArgs {
1008 name: "posts".into(),
1009 project_root: Some(root.clone()),
1010 force: false,
1011 auto_register: true,
1012 })
1013 .unwrap();
1014 assert_eq!(first.register, RegisterOutcome::Inserted);
1015
1016 let second = generate_module(&GenerateModuleArgs {
1019 name: "posts".into(),
1020 project_root: Some(root.clone()),
1021 force: true,
1022 auto_register: true,
1023 })
1024 .unwrap();
1025 assert_eq!(second.register, RegisterOutcome::AlreadyRegistered);
1026
1027 let main_rs = fs::read_to_string(root.join("src/main.rs")).unwrap();
1028 assert_eq!(
1029 main_rs.matches(".module(modules::posts::define())").count(),
1030 1
1031 );
1032 }
1033
1034 #[test]
1035 fn generate_service_auto_registers_use_and_call() {
1036 let tmp = tempfile::tempdir().unwrap();
1037 let root = tmp.path().join("proj");
1038 make_skeleton_with_main_and_define(&root, "users");
1039
1040 let res = generate_service(&GenerateServiceArgs {
1041 spec: "users/email_sender".into(),
1042 project_root: Some(root.clone()),
1043 force: false,
1044 auto_register: true,
1045 })
1046 .unwrap();
1047 assert_eq!(res.register, RegisterOutcome::Inserted);
1048
1049 let mod_rs = fs::read_to_string(root.join("src/modules/users/mod.rs")).unwrap();
1050 assert!(
1051 mod_rs.contains("use email_sender::EmailSender;"),
1052 "missing use line: {mod_rs}"
1053 );
1054 assert!(
1055 mod_rs.contains(".service::<EmailSender>()"),
1056 "missing service call: {mod_rs}"
1057 );
1058 }
1059
1060 #[test]
1061 fn generate_contributor_auto_registers_use_and_call() {
1062 let tmp = tempfile::tempdir().unwrap();
1063 let root = tmp.path().join("proj");
1064 make_skeleton_with_main_and_define(&root, "users");
1065
1066 let res = generate_contributor(&GenerateContributorArgs {
1067 spec: "users/load_current_user".into(),
1068 project_root: Some(root.clone()),
1069 force: false,
1070 auto_register: true,
1071 })
1072 .unwrap();
1073 assert_eq!(res.register, RegisterOutcome::Inserted);
1074
1075 let mod_rs = fs::read_to_string(root.join("src/modules/users/mod.rs")).unwrap();
1076 assert!(
1077 mod_rs.contains("use load_current_user::LoadCurrentUser;"),
1078 "missing use line: {mod_rs}"
1079 );
1080 assert!(
1081 mod_rs.contains(".contribute(LoadCurrentUser)"),
1082 "missing contribute call: {mod_rs}"
1083 );
1084 }
1085
1086 #[test]
1087 fn generate_module_falls_back_when_no_main_rs() {
1088 let tmp = tempfile::tempdir().unwrap();
1089 let root = tmp.path().join("proj");
1090 make_skeleton(&root); let res = generate_module(&GenerateModuleArgs {
1093 name: "posts".into(),
1094 project_root: Some(root.clone()),
1095 force: false,
1096 auto_register: true,
1097 })
1098 .unwrap();
1099 assert_eq!(res.register, RegisterOutcome::TargetMissing);
1101 assert!(root.join("src/modules/posts/mod.rs").is_file());
1103 }
1104}