Skip to main content

kick_rs_cli/
generate.rs

1//! `cargo kick g` — codegen into an existing kick-rs project.
2//!
3//! Currently ships:
4//!
5//! - `g module <name>` — emit `src/modules/<name>/{mod.rs,handlers.rs}`
6//!   and append `pub mod <name>;` to `src/modules/mod.rs`.
7//! - `g service <module>/<name>` — emit
8//!   `src/modules/<module>/<name>.rs` containing a `#[service]`-derived
9//!   stub, and append `pub mod <name>;` to the parent module's `mod.rs`.
10//! - `g contributor <module>/<name>` — emit
11//!   `src/modules/<module>/<name>.rs` containing a `#[contributor]`
12//!   async fn + Output struct, and append `pub mod <name>;` to the
13//!   parent module's `mod.rs`.
14//!
15//! Project root is auto-detected by walking up from `cwd` until we
16//! find a directory containing `src/modules/mod.rs`. That single
17//! anchor is what makes us "in a kick-rs project" for the purposes of
18//! this command.
19
20use 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
25/// Decoded form of the `g module` subcommand.
26pub struct GenerateModuleArgs {
27    /// Module name (e.g. `posts`). Must be a valid Rust identifier:
28    /// lowercase ASCII letters / digits / `_`, starting with a letter.
29    /// Hyphens are rejected — Rust modules use underscores.
30    pub name: String,
31    /// Override the project root. Defaults to walking up from `cwd`.
32    pub project_root: Option<PathBuf>,
33    /// Allow overwriting `mod.rs` / `handlers.rs` if the module
34    /// directory already exists.
35    pub force: bool,
36    /// Try to auto-insert `.module(modules::<name>::define())` into
37    /// `src/main.rs`. When the patterns aren't found, the caller falls
38    /// back to printing a manual hint.
39    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
91/// Module-name validation. Snake-case only — hyphens disallowed
92/// because Rust modules can't have them.
93pub 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    // A handful of keywords would shadow the language at the `pub mod
114    // <name>;` site. Reject the most likely collisions.
115    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
127/// Walk up from `start` until we find a directory containing
128/// `src/modules/mod.rs`. That's our project root.
129pub 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
150/// Module skeleton: 2 files (mod.rs + handlers.rs).
151const MOD_TMPL: &str = include_str!("../templates/generate/module/mod.rs.tmpl");
152const HANDLERS_TMPL: &str = include_str!("../templates/generate/module/handlers.rs.tmpl");
153
154/// Service skeleton: 1 file.
155const SERVICE_TMPL: &str = include_str!("../templates/generate/service/file.rs.tmpl");
156
157/// Contributor skeleton: 1 file.
158const CONTRIBUTOR_TMPL: &str = include_str!("../templates/generate/contributor/file.rs.tmpl");
159
160/// Convert a snake_case identifier to PascalCase. Used to derive the
161/// service struct name from its file name (`email_sender` → `EmailSender`).
162///
163/// Assumes the input has already been validated as a snake_case
164/// identifier (no leading digits, no hyphens, etc).
165pub 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        // module_name_snake is the same as module_name today (hyphens
186        // are rejected), but keep the placeholder so the templates
187        // are self-documenting about the intent.
188        .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/// Result of `generate_module` — the directory written + how the
205/// `main.rs` auto-register attempt fared.
206#[derive(Debug)]
207pub struct GenerateModuleResult {
208    pub module_dir: PathBuf,
209    pub register: RegisterOutcome,
210}
211
212/// Run the `g module <name>` flow.
213pub 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
260/// Try to insert `.module(modules::<name>::define())` into
261/// `src/main.rs` next to existing `.module(...)` calls (or right after
262/// `bootstrap()` if none).
263fn 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
292/// Idempotently append `pub mod <name>;` to `target` if it isn't
293/// already present (line-equality match, ignoring leading/trailing
294/// whitespace).
295fn 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
315// ─────────────────────────── g service ───────────────────────────────
316
317/// Decoded form of the `g service` subcommand.
318pub struct GenerateServiceArgs {
319    /// `<module>/<service_snake>` spec.
320    pub spec: String,
321    /// Override the project root.
322    pub project_root: Option<PathBuf>,
323    /// Overwrite the service file if it already exists.
324    pub force: bool,
325    /// Try to auto-insert `use <name>::<Pascal>;` + `.service::<Pascal>()`
326    /// into the parent module's `mod.rs`.
327    pub auto_register: bool,
328}
329
330/// Split `<module>/<name>` and validate each half. Shared by `g service`
331/// and `g contributor` — both expect the same shape (a module name and
332/// a snake_case item name inside it).
333fn 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/// Result of `generate_service` — the file written + auto-register outcome.
349#[derive(Debug)]
350pub struct GenerateServiceResult {
351    pub file: PathBuf,
352    pub register: RegisterOutcome,
353}
354
355/// Run the `g service <module>/<service_snake>` flow.
356pub 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/// Which builder method to insert. The format of the call differs
407/// between services (`.service::<X>()`) and contributors (`.contribute(X)`).
408#[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
429/// Insert a `use <name>::<Pascal>;` + the appropriate builder call
430/// into the parent module's `mod.rs`. Idempotent against re-runs.
431fn 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    // `use` line — add only if missing.
448    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
466// ────────────────────────── g contributor ────────────────────────────
467
468/// Decoded form of the `g contributor` subcommand.
469pub struct GenerateContributorArgs {
470    /// `<module>/<contributor_snake>` spec.
471    pub spec: String,
472    /// Override the project root.
473    pub project_root: Option<PathBuf>,
474    /// Overwrite the contributor file if it already exists.
475    pub force: bool,
476    /// Try to auto-insert `use <name>::<Pascal>;` + `.contribute(Pascal)`
477    /// into the parent module's `mod.rs`.
478    pub auto_register: bool,
479}
480
481/// Result of `generate_contributor`.
482#[derive(Debug)]
483pub struct GenerateContributorResult {
484    pub file: PathBuf,
485    pub register: RegisterOutcome,
486}
487
488/// Run the `g contributor <module>/<contributor_snake>` flow.
489pub 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    /// Build a minimal "project" inside `dir` so `find_project_root`
541    /// and the codegen can operate on it.
542    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()); // upper
559        assert!(validate_module_name("has-hyphen").is_err()); // hyphen
560        assert!(validate_module_name("1leading").is_err()); // leading digit
561        assert!(validate_module_name("fn").is_err()); // keyword
562        assert!(validate_module_name("type").is_err()); // keyword
563    }
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        // Find from project root itself
572        assert_eq!(find_project_root(&root).unwrap(), root);
573
574        // Find from nested directory inside the project
575        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        // tempdir is empty — no modules anchor anywhere
584        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        // `pub mod posts;` got appended exactly once.
611        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        // Second run with --force shouldn't double-up the re-export.
631        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    // ─────────────────── g service ───────────────────
663
664    #[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        // Empty + degenerate inputs preserved (validation lives upstream).
671        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        // No slash
686        assert!(matches!(
687            parse_kind_spec("emailsender").unwrap_err(),
688            GenerateError::InvalidSpec(_)
689        ));
690        // Empty halves
691        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        // Nested slashes — we only support one-level nesting today.
700        assert!(matches!(
701            parse_kind_spec("users/sub/email").unwrap_err(),
702            GenerateError::InvalidSpec(_)
703        ));
704        // Bad identifier on either side cascades to InvalidName.
705        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        // Append the module to the top-level mod.rs so it'd compile,
724        // though the codegen here doesn't actually need this.
725        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        // No `users` module — only the default hello one from make_skeleton.
765
766        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    // ────────────── g contributor ──────────────
831
832    #[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        // PascalCase derived from snake_case for both the contributor
853        // fn and its output struct.
854        assert!(body.contains("pub async fn LoadCurrentUser("));
855        assert!(body.contains("pub struct LoadCurrentUserOut"));
856        // The macro-driven sugar shows up — adopters get a working
857        // skeleton that compiles after `cargo kick g`.
858        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    // ────────────── auto-register paths ──────────────
938
939    /// Make a more realistic project that has both `src/main.rs` (with
940    /// a bootstrap chain) and a module's `mod.rs` (with a define()
941    /// builder) — covers both auto-register targets.
942    fn make_skeleton_with_main_and_define(dir: &Path, module: &str) {
943        make_skeleton_with_module(dir, module);
944        // Overwrite the module mod.rs with one that has a proper
945        // define() chain so .service::<...>() / .contribute(...) can be
946        // inserted into a real chain.
947        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        // First pass inserts.
1007        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        // Second pass with --force: should detect the existing
1017        // registration and skip the re-insert.
1018        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); // no src/main.rs
1091
1092        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        // No main.rs => caller gets a clear signal to print the manual hint.
1100        assert_eq!(res.register, RegisterOutcome::TargetMissing);
1101        // File emission and pub-mod-append still happened.
1102        assert!(root.join("src/modules/posts/mod.rs").is_file());
1103    }
1104}