From 76b2ce0ea804511afcbb4b1eda26895e7a897700 Mon Sep 17 00:00:00 2001 From: vernonthedev Date: Tue, 24 Mar 2026 14:21:35 +0300 Subject: [PATCH 01/11] fix: upgrade validator to 0.19 to resolve idna vulnerability --- Cargo.lock | 197 +++++++++++++++++++++-------------------------------- Cargo.toml | 2 +- 2 files changed, 79 insertions(+), 120 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index f6274c1..b9fb668 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -163,7 +163,7 @@ dependencies = [ "proc-macro2", "quote", "strum 0.27.2", - "syn 2.0.117", + "syn", "thiserror", ] @@ -228,7 +228,7 @@ checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] @@ -239,7 +239,7 @@ checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] @@ -503,7 +503,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] @@ -665,7 +665,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn 2.0.117", + "syn", ] [[package]] @@ -678,7 +678,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn 2.0.117", + "syn", ] [[package]] @@ -689,7 +689,7 @@ checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" dependencies = [ "darling_core 0.20.11", "quote", - "syn 2.0.117", + "syn", ] [[package]] @@ -700,7 +700,7 @@ checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" dependencies = [ "darling_core 0.23.0", "quote", - "syn 2.0.117", + "syn", ] [[package]] @@ -738,7 +738,7 @@ dependencies = [ "darling 0.20.11", "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] @@ -748,7 +748,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" dependencies = [ "derive_builder_core", - "syn 2.0.117", + "syn", ] [[package]] @@ -771,7 +771,7 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] @@ -957,7 +957,7 @@ checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] @@ -1120,7 +1120,7 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" [[package]] name = "hello-nestforge" -version = "1.8.0" +version = "1.9.0" dependencies = [ "anyhow", "axum 0.8.8", @@ -1131,7 +1131,7 @@ dependencies = [ [[package]] name = "hello-nestforge-graphql" -version = "1.8.0" +version = "1.9.0" dependencies = [ "anyhow", "async-graphql", @@ -1143,7 +1143,7 @@ dependencies = [ [[package]] name = "hello-nestforge-grpc" -version = "1.8.0" +version = "1.9.0" dependencies = [ "anyhow", "axum 0.8.8", @@ -1157,7 +1157,7 @@ dependencies = [ [[package]] name = "hello-nestforge-microservices" -version = "1.8.0" +version = "1.9.0" dependencies = [ "anyhow", "axum 0.8.8", @@ -1169,7 +1169,7 @@ dependencies = [ [[package]] name = "hello-nestforge-websockets" -version = "1.8.0" +version = "1.9.0" dependencies = [ "anyhow", "axum 0.8.8", @@ -1411,16 +1411,6 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" -[[package]] -name = "idna" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d20d6b07bfbc108882d88ed8e37d39636dcc260e15e30c45e6ba089610b917c" -dependencies = [ - "unicode-bidi", - "unicode-normalization", -] - [[package]] name = "idna" version = "1.1.0" @@ -1442,12 +1432,6 @@ dependencies = [ "icu_properties", ] -[[package]] -name = "if_chain" -version = "1.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd62e6b5e86ea8eeeb8db1de02880a6abc01a397b2ebb64b5d74ac255318f5cb" - [[package]] name = "indexmap" version = "1.9.3" @@ -1502,7 +1486,7 @@ dependencies = [ "indoc", "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] @@ -1696,7 +1680,7 @@ checksum = "db5b29714e950dbb20d5e6f74f9dcec4edbcc1067bb7f8ed198c097b8c1a818b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] @@ -1745,7 +1729,7 @@ dependencies = [ [[package]] name = "nestforge" -version = "1.8.0" +version = "1.9.0" dependencies = [ "anyhow", "async-graphql", @@ -1775,7 +1759,7 @@ dependencies = [ [[package]] name = "nestforge-cache" -version = "1.8.0" +version = "1.9.0" dependencies = [ "axum 0.8.8", "http", @@ -1787,7 +1771,7 @@ dependencies = [ [[package]] name = "nestforge-cli" -version = "1.8.0" +version = "1.9.0" dependencies = [ "anyhow", "clap", @@ -1804,7 +1788,7 @@ dependencies = [ [[package]] name = "nestforge-config" -version = "1.8.0" +version = "1.9.0" dependencies = [ "dotenvy", "thiserror", @@ -1812,7 +1796,7 @@ dependencies = [ [[package]] name = "nestforge-core" -version = "1.8.0" +version = "1.9.0" dependencies = [ "anyhow", "axum 0.8.8", @@ -1827,14 +1811,14 @@ dependencies = [ [[package]] name = "nestforge-data" -version = "1.8.0" +version = "1.9.0" dependencies = [ "thiserror", ] [[package]] name = "nestforge-db" -version = "1.8.0" +version = "1.9.0" dependencies = [ "sqlx", "thiserror", @@ -1843,7 +1827,7 @@ dependencies = [ [[package]] name = "nestforge-graphql" -version = "1.8.0" +version = "1.9.0" dependencies = [ "async-graphql", "async-graphql-axum", @@ -1853,7 +1837,7 @@ dependencies = [ [[package]] name = "nestforge-grpc" -version = "1.8.0" +version = "1.9.0" dependencies = [ "anyhow", "nestforge-core", @@ -1867,7 +1851,7 @@ dependencies = [ [[package]] name = "nestforge-http" -version = "1.8.0" +version = "1.9.0" dependencies = [ "anyhow", "axum 0.8.8", @@ -1878,16 +1862,16 @@ dependencies = [ [[package]] name = "nestforge-macros" -version = "1.8.0" +version = "1.9.0" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] name = "nestforge-microservices" -version = "1.8.0" +version = "1.9.0" dependencies = [ "anyhow", "nestforge-core", @@ -1897,14 +1881,14 @@ dependencies = [ [[package]] name = "nestforge-mongo" -version = "1.8.0" +version = "1.9.0" dependencies = [ "nestforge-data", ] [[package]] name = "nestforge-openapi" -version = "1.8.0" +version = "1.9.0" dependencies = [ "axum 0.8.8", "nestforge-core", @@ -1916,7 +1900,7 @@ dependencies = [ [[package]] name = "nestforge-orm" -version = "1.8.0" +version = "1.9.0" dependencies = [ "nestforge-db", "thiserror", @@ -1925,14 +1909,14 @@ dependencies = [ [[package]] name = "nestforge-redis" -version = "1.8.0" +version = "1.9.0" dependencies = [ "nestforge-data", ] [[package]] name = "nestforge-schedule" -version = "1.8.0" +version = "1.9.0" dependencies = [ "anyhow", "nestforge-core", @@ -1941,7 +1925,7 @@ dependencies = [ [[package]] name = "nestforge-testing" -version = "1.8.0" +version = "1.9.0" dependencies = [ "anyhow", "async-graphql", @@ -1958,7 +1942,7 @@ dependencies = [ [[package]] name = "nestforge-websockets" -version = "1.8.0" +version = "1.9.0" dependencies = [ "anyhow", "axum 0.8.8", @@ -2144,7 +2128,7 @@ dependencies = [ "pest_meta", "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] @@ -2174,7 +2158,7 @@ checksum = "d9b20ed30f105399776b9c883e68e536ef602a16ae6f596d2c473591d6ad64c6" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] @@ -2261,7 +2245,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" dependencies = [ "proc-macro2", - "syn 2.0.117", + "syn", ] [[package]] @@ -2274,27 +2258,25 @@ dependencies = [ ] [[package]] -name = "proc-macro-error" -version = "1.0.4" +name = "proc-macro-error-attr2" +version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" +checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5" dependencies = [ - "proc-macro-error-attr", "proc-macro2", "quote", - "syn 1.0.109", - "version_check", ] [[package]] -name = "proc-macro-error-attr" -version = "1.0.4" +name = "proc-macro-error2" +version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" +checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802" dependencies = [ + "proc-macro-error-attr2", "proc-macro2", "quote", - "version_check", + "syn", ] [[package]] @@ -2326,7 +2308,7 @@ dependencies = [ "itertools 0.14.0", "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] @@ -2572,7 +2554,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] @@ -2785,7 +2767,7 @@ dependencies = [ "quote", "sqlx-core", "sqlx-macros-core", - "syn 2.0.117", + "syn", ] [[package]] @@ -2807,7 +2789,7 @@ dependencies = [ "sqlx-core", "sqlx-mysql", "sqlx-postgres", - "syn 2.0.117", + "syn", "tokio", "url", ] @@ -2977,7 +2959,7 @@ dependencies = [ "proc-macro2", "quote", "rustversion", - "syn 2.0.117", + "syn", ] [[package]] @@ -2989,7 +2971,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] @@ -3019,17 +3001,6 @@ version = "3.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7401a30af6cb5818bb64852270bb722533397edcfc7344954a38f420819ece2" -[[package]] -name = "syn" -version = "1.0.109" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - [[package]] name = "syn" version = "2.0.117" @@ -3055,7 +3026,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] @@ -3108,7 +3079,7 @@ checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] @@ -3159,7 +3130,7 @@ checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] @@ -3327,7 +3298,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] @@ -3449,7 +3420,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" dependencies = [ "form_urlencoded", - "idna 1.1.0", + "idna", "percent-encoding", "serde", ] @@ -3474,12 +3445,12 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "validator" -version = "0.16.1" +version = "0.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b92f40481c04ff1f4f61f304d61793c7b56ff76ac1469f1beb199b1445b253bd" +checksum = "d0b4a29d8709210980a09379f27ee31549b73292c87ab9899beee1c0d3be6303" dependencies = [ - "idna 0.4.0", - "lazy_static", + "idna", + "once_cell", "regex", "serde", "serde_derive", @@ -3490,28 +3461,16 @@ dependencies = [ [[package]] name = "validator_derive" -version = "0.16.0" +version = "0.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc44ca3088bb3ba384d9aecf40c6a23a676ce23e09bdaca2073d99c207f864af" +checksum = "bac855a2ce6f843beb229757e6e570a42e837bcb15e5f449dd48d5747d41bf77" dependencies = [ - "if_chain", - "lazy_static", - "proc-macro-error", + "darling 0.20.11", + "once_cell", + "proc-macro-error2", "proc-macro2", "quote", - "regex", - "syn 1.0.109", - "validator_types", -] - -[[package]] -name = "validator_types" -version = "0.16.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "111abfe30072511849c5910134e8baf8dc05de4c0e5903d681cbd5c9c4d611e3" -dependencies = [ - "proc-macro2", - "syn 1.0.109", + "syn", ] [[package]] @@ -3597,7 +3556,7 @@ dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 2.0.117", + "syn", "wasm-bindgen-shared", ] @@ -3962,7 +3921,7 @@ dependencies = [ "heck", "indexmap 2.13.0", "prettyplease", - "syn 2.0.117", + "syn", "wasm-metadata", "wit-bindgen-core", "wit-component", @@ -3978,7 +3937,7 @@ dependencies = [ "prettyplease", "proc-macro2", "quote", - "syn 2.0.117", + "syn", "wit-bindgen-core", "wit-bindgen-rust", ] @@ -4045,7 +4004,7 @@ checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn", "synstructure", ] @@ -4066,7 +4025,7 @@ checksum = "4122cd3169e94605190e77839c9a40d40ed048d305bfdc146e7df40ab0f3e517" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] @@ -4086,7 +4045,7 @@ checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn", "synstructure", ] @@ -4126,7 +4085,7 @@ checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 512af94..5ac3199 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -45,7 +45,7 @@ serde_json = "1" http = "1" anyhow = "1" thiserror = "2" -validator = { version = "0.16", features = ["derive"] } +validator = { version = "0.19", features = ["derive"] } sha2 = "0.10" tower = "0.5" tower-http = { version = "0.6", features = ["trace", "cors"] } From d805599fe7cf7ac5f8f28881cfdac72dd6c32089 Mon Sep 17 00:00:00 2001 From: vernonthedev Date: Tue, 24 Mar 2026 14:42:38 +0300 Subject: [PATCH 02/11] fix(config): resolve multiple issues in config module implementation ## Bug Fixes ## Critical 1. CLI scaffolded app won't compile - Added nestforge-config dependency to scaffolded Cargo.toml, fixed template imports to use nestforge instead of nestforge_config, updated module templates to use load_config() factory pattern 2. Config wrapper unusable - Now wraps Arc with full getter forwarding and Deref impl 3. Process env precedence reversed - Changed to or_insert_with so runtime env takes precedence over .env file ## Major 4. Config loading panics - Added try_for_root_with_options() returning Result 5. Dotenv errors silently dropped - Malformed .env lines now propagate errors 6. Broken #[derive(Config)] macro - Removed broken macro that generated invalid code ## Updates - Updated all 5 examples to use new consistent config API - Added load_config() function to examples for DI registration - Removed unused FromEnv implementations from examples All workspace tests pass. --- crates/nestforge-cli/src/main.rs | 8642 ++++++++--------- crates/nestforge-config/src/lib.rs | 105 +- crates/nestforge-core/src/config.rs | 61 +- crates/nestforge-macros/src/lib.rs | 4088 ++++---- .../hello-nestforge-graphql/src/app_config.rs | 45 +- .../hello-nestforge-grpc/src/app_config.rs | 42 +- .../src/app_config.rs | 45 +- .../src/app_config.rs | 45 +- examples/hello-nestforge/src/app_config.rs | 47 +- examples/hello-nestforge/src/app_module.rs | 9 +- 10 files changed, 6563 insertions(+), 6566 deletions(-) diff --git a/crates/nestforge-cli/src/main.rs b/crates/nestforge-cli/src/main.rs index 21f30e8..5bf0434 100644 --- a/crates/nestforge-cli/src/main.rs +++ b/crates/nestforge-cli/src/main.rs @@ -1,4325 +1,4319 @@ -use anyhow::{bail, Context, Result}; -use clap::Parser; -use nestforge_db::{Db, DbConfig}; -use owo_colors::OwoColorize; -use std::{ - collections::{HashMap, HashSet}, - env, fs, - io::{self, IsTerminal, Write}, - path::{Path, PathBuf}, - process::Command, - time::{SystemTime, UNIX_EPOCH}, -}; - -mod cli; -mod diagnostics; -mod tui; -mod ui; - -use crate::cli::{ - AppTransport, Cli, Commands, DbArgs, DbCommand, DocsArgs, DocsFormatArg, GenerateArgs, - GeneratorKindArg, GeneratorLayout, NewArgs, -}; -use crate::diagnostics::{ - app_root_not_found, missing_app_module_declaration, module_file_not_found, - openapi_feature_missing, render_cli_error, -}; -use crate::tui::{ - render_docs_plaintext, run_docs_browser, run_generate_wizard, run_new_wizard, - should_fallback_to_prompt, -}; -use crate::ui::{ - interactive_enabled, print_brand_banner, print_note, print_success, prompt_generator_kind, - prompt_transport, start_spinner, -}; - -#[derive(Clone, Debug, Eq, PartialEq)] -struct GeneratorOptions { - target_module: Option, - layout: GeneratorLayout, - prompt_for_dto: bool, -} - -#[derive(Clone, Debug, Eq, PartialEq)] -struct DtoFieldSpec { - name: String, - ty: DtoFieldType, - required: bool, -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -enum DtoFieldType { - String, - Bool, - U32, - U64, - I64, - F64, -} - -impl DtoFieldType { - fn rust_type(self) -> &'static str { - match self { - Self::String => "String", - Self::Bool => "bool", - Self::U32 => "u32", - Self::U64 => "u64", - Self::I64 => "i64", - Self::F64 => "f64", - } - } - - fn prompt_label(self) -> &'static str { - match self { - Self::String => "String", - Self::Bool => "bool", - Self::U32 => "u32", - Self::U64 => "u64", - Self::I64 => "i64", - Self::F64 => "f64", - } - } - - fn choices() -> &'static [Self] { - &[ - Self::String, - Self::Bool, - Self::U32, - Self::U64, - Self::I64, - Self::F64, - ] - } -} - -fn main() -> miette::Result<()> { - let cli = Cli::parse(); - - if cli.command.is_none() { - print_brand_banner(); - crate::cli::print_help(&cli)?; - return Ok(()); - } - - run_cli(cli).map_err(render_cli_error) -} - -fn run_cli(cli: Cli) -> Result<()> { - match cli.command.expect("command checked above") { - Commands::New(args) => { - let (app_name, transport, enable_openapi) = resolve_new_args(args)?; - create_new_app(&app_name, transport, enable_openapi)?; - } - Commands::Docs(args) => run_docs_command(args)?, - Commands::Generate(args) => { - let (kind, name, options) = resolve_generate_args(args)?; - run_generate_command(kind, &name, options)?; - } - Commands::Db(args) => run_db_command_structured(args)?, - Commands::ExportDocs(args) => run_export_docs_command_with_options(ExportDocsOptions { - format: match args.format { - DocsFormatArg::Json => "json".to_string(), - DocsFormatArg::Yaml => "yaml".to_string(), - }, - output: args.output, - title: args.title, - version: args.version, - module_type: args.module_type, - })?, - Commands::Fmt => run_fmt_command()?, - } - - Ok(()) -} - -fn run_docs_command(args: DocsArgs) -> Result<()> { - let interactive = interactive_enabled(true); - let use_tui = interactive && !args.no_tui; - - if use_tui { - match run_docs_browser(args.topic.as_deref()) { - Ok(()) => return Ok(()), - Err(error) if should_fallback_to_prompt(&error) => { - print_note( - "Full-screen docs browser is unavailable in this terminal. Falling back to plain text.", - ); - } - Err(error) => return Err(error), - } - } - - println!("{}", render_docs_plaintext(args.topic.as_deref())); - Ok(()) -} - -fn resolve_new_args(args: NewArgs) -> Result<(String, AppTransport, bool)> { - let interactive = interactive_enabled(true); - let tui_enabled = interactive && !args.no_tui; - - if tui_enabled && (args.app_name.is_none() || args.transport.is_none()) { - match run_new_wizard() { - Ok((app_name, transport)) => return Ok((app_name, transport, args.openapi)), - Err(error) if should_fallback_to_prompt(&error) => { - print_note( - "Full-screen TUI is unavailable in this terminal. Falling back to prompt mode.", - ); - } - Err(error) => return Err(error), - } - } - - let app_name = match args.app_name { - Some(name) => name, - None if interactive => prompt_string("Application name (example: care-api)", false)?, - None => { - bail!("Missing app name. Use `nestforge new ` or run in interactive mode.") - } - }; - - let transport = match args.transport { - Some(transport) => transport.into(), - None if interactive => prompt_transport()?, - None => AppTransport::Http, - }; - - if args.openapi && !transport_supports_openapi(transport) { - bail!("OpenAPI scaffolding is currently supported for HTTP and GraphQL apps only."); - } - - Ok((app_name, transport, args.openapi)) -} - -fn resolve_generate_args( - args: GenerateArgs, -) -> Result<(GeneratorKindArg, String, GeneratorOptions)> { - let interactive = interactive_enabled(true); - let tui_enabled = interactive && !args.no_tui; - - if tui_enabled && (args.kind.is_none() || args.name.is_none()) { - match run_generate_wizard() { - Ok(result) => { - return Ok(( - result.kind, - result.name, - GeneratorOptions { - target_module: result.module.map(|value| normalize_resource_name(&value)), - layout: result.layout, - prompt_for_dto: !result.no_prompt, - }, - )); - } - Err(error) if should_fallback_to_prompt(&error) => { - print_note( - "Full-screen TUI is unavailable in this terminal. Falling back to prompt mode.", - ); - } - Err(error) => return Err(error), - } - } - - let kind = match args.kind { - Some(kind) => kind, - None if interactive => prompt_generator_kind()?, - None => bail!("Missing generator kind. Use `nestforge generate `."), - }; - - let name = match args.name { - Some(name) => name, - None if interactive => prompt_string("Resource or module name (example: users)", false)?, - None => bail!( - "Missing generator name. Use `nestforge generate {}` .", - kind.label() - ), - }; - - let target_module = if args.module.is_some() { - args.module.map(|value| normalize_resource_name(&value)) - } else if interactive - && matches!( - kind, - GeneratorKindArg::Resource | GeneratorKindArg::Controller | GeneratorKindArg::Service - ) - && prompt_yes_no("Generate inside a feature module?", false)? - { - let module_name = prompt_string("Target module name", false)?; - Some(normalize_resource_name(&module_name)) - } else { - None - }; - - Ok(( - kind, - name, - GeneratorOptions { - target_module, - layout: if args.flat { - GeneratorLayout::Flat - } else { - GeneratorLayout::Nested - }, - prompt_for_dto: !args.no_prompt, - }, - )) -} - -fn run_generate_command( - kind: GeneratorKindArg, - name: &str, - options: GeneratorOptions, -) -> Result<()> { - match kind { - GeneratorKindArg::Resource => generate_resource( - name, - options.target_module.as_deref(), - options.layout, - options.prompt_for_dto, - ), - GeneratorKindArg::Controller => { - generate_controller_only(name, options.target_module.as_deref(), options.layout) - } - GeneratorKindArg::Service => { - generate_service_only(name, options.target_module.as_deref(), options.layout) - } - GeneratorKindArg::Module => generate_module(name, options.layout), - GeneratorKindArg::Guard => generate_guard_only(name), - GeneratorKindArg::Decorator => generate_request_decorator_only(name), - GeneratorKindArg::Filter => generate_exception_filter_only(name), - GeneratorKindArg::Middleware => generate_middleware_only(name), - GeneratorKindArg::Interceptor => generate_interceptor_only(name), - GeneratorKindArg::Serializer => generate_serializer_only(name), - GeneratorKindArg::Graphql => generate_graphql_resolver_only(name), - GeneratorKindArg::Grpc => generate_grpc_service_only(name), - GeneratorKindArg::Gateway => generate_websocket_gateway_only(name), - GeneratorKindArg::Microservice => generate_microservice_patterns_only(name), - } -} - -fn run_db_command_structured(args: DbArgs) -> Result<()> { - let app_root = detect_app_root()?; - match args.action { - DbCommand::Init => db_init(&app_root), - DbCommand::Generate { name } => db_generate(&app_root, &name), - DbCommand::Migrate => db_migrate(&app_root), - DbCommand::Status => db_status(&app_root), - } -} - -/* ------------------------------ - NEW APP SCAFFOLD ------------------------------- */ - -fn create_new_app(app_name: &str, transport: AppTransport, enable_openapi: bool) -> Result<()> { - let app_dir = env::current_dir()?.join(app_name); - - if app_dir.exists() { - bail!("App `{}` already exists at {}", app_name, app_dir.display()); - } - - let spinner = start_spinner(format!( - "Scaffolding {} app {}", - transport.label(), - app_name.bold() - )); - - /* Cargo.toml */ - write_file( - &app_dir.join("Cargo.toml"), - &template_app_cargo_toml( - app_name, - resolve_nestforge_dependency_line(transport, enable_openapi), - transport, - ), - )?; - - /* main.rs */ - write_file( - &app_dir.join("src/main.rs"), - &template_main_rs(app_name, transport, enable_openapi), - )?; - write_file( - &app_dir.join("src/lib.rs"), - &template_app_lib_rs(transport), - )?; - - /* app_module.rs */ - write_file( - &app_dir.join("src/app_module.rs"), - &template_app_module_rs(transport), - )?; - - write_file( - &app_dir.join("src/app_config.rs"), - &template_app_config_rs(transport), - )?; - - scaffold_transport_files(&app_dir, transport)?; - - write_file( - &app_dir.join(".env.example"), - &template_env_file(app_name, transport), - )?; - write_file( - &app_dir.join(".env"), - &template_env_file(app_name, transport), - )?; - - spinner.finish_and_clear(); - print_success(format!( - "Created NestForge {} app at {}", - transport.label(), - app_dir.display() - )); - print_note(format!("Next: cd {}", app_dir.display())); - print_note("Then run: cargo run"); - - if enable_openapi { - print_note("OpenAPI docs will be available at /api/v1/docs for supported HTTP routes."); - } - - if matches!(transport, AppTransport::Http) { - print_note("Then generate your first resource: nestforge generate resource users"); - } - - Ok(()) -} - -fn scaffold_transport_files(app_dir: &Path, transport: AppTransport) -> Result<()> { - match transport { - AppTransport::Http => { - fs::create_dir_all(app_dir.join("src/guards"))?; - fs::create_dir_all(app_dir.join("src/interceptors"))?; - - write_file( - &app_dir.join("src/app_service.rs"), - &template_app_service_rs(), - )?; - write_file( - &app_dir.join("src/app_controller.rs"), - &template_app_controller_rs(), - )?; - write_file( - &app_dir.join("src/health_controller.rs"), - &template_health_controller_rs(), - )?; - write_file( - &app_dir.join("src/guards/mod.rs"), - &template_guards_mod_rs(), - )?; - write_file( - &app_dir.join("src/filters/mod.rs"), - &template_filters_mod_rs(), - )?; - write_file( - &app_dir.join("src/interceptors/mod.rs"), - &template_interceptors_mod_rs(), - )?; - } - AppTransport::Graphql => { - fs::create_dir_all(app_dir.join("src/graphql"))?; - write_file(&app_dir.join("src/graphql/mod.rs"), "pub mod schema;\n")?; - write_file( - &app_dir.join("src/graphql/schema.rs"), - &template_graphql_schema_rs(), - )?; - } - AppTransport::Grpc => { - fs::create_dir_all(app_dir.join("src/grpc"))?; - fs::create_dir_all(app_dir.join("proto"))?; - write_file(&app_dir.join("build.rs"), &template_grpc_build_rs())?; - write_file(&app_dir.join("proto/greeter.proto"), &template_grpc_proto())?; - write_file(&app_dir.join("src/grpc/mod.rs"), &template_grpc_mod_rs())?; - write_file( - &app_dir.join("src/grpc/service.rs"), - &template_grpc_service_rs(), - )?; - } - AppTransport::Microservices => { - fs::create_dir_all(app_dir.join("src/microservices"))?; - write_file( - &app_dir.join("src/microservices/mod.rs"), - &template_microservices_app_mod_rs(), - )?; - write_file( - &app_dir.join("src/microservices/app_patterns.rs"), - &template_microservices_app_patterns_rs(), - )?; - } - AppTransport::Websockets => { - fs::create_dir_all(app_dir.join("src/ws"))?; - write_file(&app_dir.join("src/ws/mod.rs"), &template_ws_mod_rs())?; - write_file( - &app_dir.join("src/ws/events_gateway.rs"), - &template_ws_gateway_rs(), - )?; - } - } - - Ok(()) -} - -/* ------------------------------ - DB COMMANDS ------------------------------- */ - -fn run_fmt_command() -> Result<()> { - let target_dir = detect_app_root().or_else(|_| env::current_dir())?; - let status = Command::new("cargo") - .arg("fmt") - .current_dir(&target_dir) - .status() - .with_context(|| format!("Failed to run cargo fmt in {}", target_dir.display()))?; - - if !status.success() { - bail!("cargo fmt failed in {}", target_dir.display()); - } - - println!("Formatted Rust sources in {}", target_dir.display()); - Ok(()) -} - -fn run_export_docs_command_with_options(options: ExportDocsOptions) -> Result<()> { - let app_root = detect_app_root().or_else(|_| env::current_dir())?; - run_export_docs_command_with_options_at(app_root, options) -} - -fn run_export_docs_command_with_options_at( - app_root: PathBuf, - options: ExportDocsOptions, -) -> Result<()> { - let output = options.output.unwrap_or_else(|| { - let file_name = match options.format.as_str() { - "yaml" => "openapi.yaml", - _ => "openapi.json", - }; - app_root.join("docs").join(file_name) - }); - - export_openapi_docs( - &app_root, - &options.title, - &options.version, - &options.module_type, - &output, - ) -} - -#[derive(Clone, Debug, Eq, PartialEq)] -struct ExportDocsOptions { - format: String, - output: Option, - title: String, - version: String, - module_type: String, -} - -#[cfg_attr(not(test), allow(dead_code))] -fn parse_export_docs_options(args: &[String]) -> Result { - let mut format = "json".to_string(); - let mut output = None; - let mut title = "NestForge API".to_string(); - let mut version = "0.1.0".to_string(); - let mut module_type = "AppModule".to_string(); - let mut index = 0usize; - - while index < args.len() { - match args[index].as_str() { - "--format" => { - let Some(value) = args.get(index + 1) else { - bail!("Use: nestforge export-docs [--format json|yaml] [--output ] [--title ] [--version <version>] [--module-type <type>]"); - }; - let normalized = value.to_ascii_lowercase(); - if normalized != "json" && normalized != "yaml" { - bail!("Unsupported docs format `{value}`. Use `json` or `yaml`."); - } - format = normalized; - index += 2; - } - "--output" => { - let Some(value) = args.get(index + 1) else { - bail!("Use: nestforge export-docs [--format json|yaml] [--output <path>] [--title <title>] [--version <version>] [--module-type <type>]"); - }; - output = Some(PathBuf::from(value)); - index += 2; - } - "--title" => { - let Some(value) = args.get(index + 1) else { - bail!("Use: nestforge export-docs [--format json|yaml] [--output <path>] [--title <title>] [--version <version>] [--module-type <type>]"); - }; - title = value.clone(); - index += 2; - } - "--version" => { - let Some(value) = args.get(index + 1) else { - bail!("Use: nestforge export-docs [--format json|yaml] [--output <path>] [--title <title>] [--version <version>] [--module-type <type>]"); - }; - version = value.clone(); - index += 2; - } - "--module-type" => { - let Some(value) = args.get(index + 1) else { - bail!("Use: nestforge export-docs [--format json|yaml] [--output <path>] [--title <title>] [--version <version>] [--module-type <type>]"); - }; - module_type = value.clone(); - index += 2; - } - _ => bail!("Use: nestforge export-docs [--format json|yaml] [--output <path>] [--title <title>] [--version <version>] [--module-type <type>]"), - } - } - - Ok(ExportDocsOptions { - format, - output, - title, - version, - module_type, - }) -} - -fn export_openapi_docs( - app_root: &Path, - title: &str, - version: &str, - module_type: &str, - output: &Path, -) -> Result<()> { - let spinner = start_spinner(format!( - "Exporting OpenAPI {} to {}", - title.bold(), - output.display() - )); - let main_rs = app_root.join("src/main.rs"); - let temp_bin = app_root.join("src/bin/nestforge_export_docs.rs"); - let top_level_mods = collect_top_level_modules(&main_rs)?; - let module_lines = top_level_mods - .iter() - .map(|module_name| { - let module_path = resolve_top_level_module_path(app_root, module_name)?; - let relative = relative_path_from(&temp_bin, &module_path)?; - Ok(format!("#[path = \"{}\"] mod {};", relative, module_name)) - }) - .collect::<Result<Vec<_>>>()? - .join("\n"); - let output_path = output - .canonicalize() - .unwrap_or_else(|_| output.to_path_buf()); - let output_path_string = output_path.to_string_lossy().replace('\\', "\\\\"); - - let script = format!( - r#"{module_lines} - -use anyhow::Context; - -fn main() -> anyhow::Result<()> {{ - let doc = nestforge::openapi_doc_for_module::<app_module::{module_type}>("{title}", "{version}") - .context("Failed to collect OpenAPI metadata. Ensure the app depends on nestforge with the `openapi` feature enabled.")?; - let path = std::path::PathBuf::from(r"{output_path_string}"); - if let Some(parent) = path.parent() {{ - std::fs::create_dir_all(parent)?; - }} - let body = if path.extension().and_then(|ext| ext.to_str()) == Some("yaml") {{ - doc.to_openapi_yaml() - }} else {{ - nestforge::serde_json::to_string_pretty(&doc.to_openapi_json())? - }}; - std::fs::write(&path, body)?; - println!("Exported OpenAPI spec to {{}}", path.display()); - Ok(()) -}} -"# - ); - - write_file(&temp_bin, &script)?; - let status = Command::new("cargo") - .args([ - "run", - "--offline", - "--quiet", - "--bin", - "nestforge_export_docs", - ]) - .current_dir(app_root) - .status() - .with_context(|| format!("Failed to run cargo export in {}", app_root.display()))?; - - let cleanup_result = fs::remove_file(&temp_bin); - if let Err(error) = cleanup_result { - spinner.println(format!( - "{} failed to remove {}: {error}", - "warning".bright_yellow(), - temp_bin.display() - )); - } - - if !status.success() { - spinner.finish_and_clear(); - return Err(openapi_feature_missing()); - } - - spinner.finish_and_clear(); - print_success(format!("Exported OpenAPI spec to {}", output.display())); - Ok(()) -} - -fn db_init(app_root: &Path) -> Result<()> { - fs::create_dir_all(migrations_dir(app_root))?; - fs::create_dir_all(nestforge_dir(app_root))?; - - let applied = applied_migrations_file(app_root); - if !applied.exists() { - write_file(&applied, "")?; - } - - let env_example = app_root.join(".env.example"); - if !env_example.exists() { - write_file( - &env_example, - "# Set your local database connection string before running DB commands.\nDATABASE_URL=postgres://<user>:<password>@localhost/<database>\n", - )?; - } - - let env_file = app_root.join(".env"); - if !env_file.exists() { - write_file( - &env_file, - "# Set your local database connection string before running the app.\nDATABASE_URL=postgres://<user>:<password>@localhost/<database>\n", - )?; - } - - println!("Initialized DB migration setup in {}", app_root.display()); - Ok(()) -} - -fn db_generate(app_root: &Path, name: &str) -> Result<()> { - db_init(app_root)?; - - let slug = to_snake_case(name); - let stamp = current_unix_timestamp()?; - let file_name = format!("{stamp}_{slug}.sql"); - let file_path = migrations_dir(app_root).join(&file_name); - - if file_path.exists() { - bail!("Migration already exists: {}", file_path.display()); - } - - let template = format!( - "-- Migration: {name}\n-- Generated by nestforge db generate\n\n-- Write SQL statements below.\n-- Example:\n-- CREATE TABLE users (\n-- id BIGSERIAL PRIMARY KEY,\n-- email TEXT NOT NULL UNIQUE\n-- );\n" - ); - write_file(&file_path, &template)?; - - println!("Created migration {}", file_name); - Ok(()) -} - -fn db_migrate(app_root: &Path) -> Result<()> { - db_init(app_root)?; - - let migrations = list_migration_files(app_root)?; - let applied = read_applied_migrations(app_root)?; - let applied_names: HashSet<String> = applied.keys().cloned().collect(); - let pending: Vec<PathBuf> = migrations - .into_iter() - .filter(|path| { - path.file_name() - .and_then(|n| n.to_str()) - .map(|name| !applied_names.contains(name)) - .unwrap_or(false) - }) - .collect(); - - if pending.is_empty() { - println!("No pending migrations."); - return Ok(()); - } - - let database_url = resolve_database_url(app_root)?; - let rt = tokio::runtime::Runtime::new().context("Failed to initialize tokio runtime")?; - let db = rt - .block_on(Db::connect(DbConfig::new(database_url))) - .context("Failed to connect using DATABASE_URL (value redacted)")?; - - for migration in pending { - let file_name = migration - .file_name() - .and_then(|n| n.to_str()) - .context("Invalid migration filename")? - .to_string(); - let sql = fs::read_to_string(&migration) - .with_context(|| format!("Failed to read migration {}", migration.display()))?; - - if !contains_sql_content(&sql) { - println!("Skipping empty migration {}", file_name); - let hash = compute_content_hash(&sql); - append_applied_migration(app_root, &file_name, &hash)?; - continue; - } - - rt.block_on(async { - let mut tx = db - .begin() - .await - .with_context(|| format!("Migration {} failed to start transaction", file_name))?; - - tx.execute_script(&sql).await.with_context(|| { - format!("Migration {} failed while executing SQL script", file_name) - })?; - - tx.commit() - .await - .with_context(|| format!("Migration {} failed to commit transaction", file_name)) - })?; - - let hash = compute_content_hash(&sql); - append_applied_migration(app_root, &file_name, &hash)?; - println!("Applied {}", file_name); - } - - println!("Migration run complete."); - Ok(()) -} - -fn db_status(app_root: &Path) -> Result<()> { - db_init(app_root)?; - - let migrations = list_migration_files(app_root)?; - let applied = read_applied_migrations(app_root)?; - - if migrations.is_empty() { - println!("No migration files found."); - return Ok(()); - } - - let mut applied_count = 0usize; - let mut pending_count = 0usize; - - let mut drift_count = 0usize; - for migration in migrations { - let file_name = migration - .file_name() - .and_then(|n| n.to_str()) - .context("Invalid migration filename")?; - if let Some(stored_hash) = applied.get(file_name) { - let content = fs::read_to_string(&migration) - .with_context(|| format!("Failed to read migration {}", migration.display()))?; - let current_hash = compute_content_hash(&content); - if stored_hash.is_empty() || *stored_hash == current_hash { - applied_count += 1; - println!("[applied] {file_name}"); - } else { - drift_count += 1; - println!("[drift] {file_name} (applied hash differs from current file)"); - } - } else { - pending_count += 1; - println!("[pending] {file_name}"); - } - } - - println!(); - println!("Applied: {applied_count}"); - println!("Pending: {pending_count}"); - println!("Drift: {drift_count}"); - Ok(()) -} - -/* ------------------------------ - GENERATORS ------------------------------- */ - -fn generate_resource( - name: &str, - target_module: Option<&str>, - layout: GeneratorLayout, - prompt_for_dto: bool, -) -> Result<()> { - let app_root = detect_app_root()?; - let resource = normalize_resource_name(name); - let singular = singular_name(&resource); - let pascal_plural = to_pascal_case(&resource); - let pascal_singular = to_pascal_case(&singular); - let dto_fields = collect_dto_fields(&pascal_singular, prompt_for_dto)?; - let spinner = start_spinner(format!("Generating resource {}", resource.bold())); - - let target_root = generator_target_root(&app_root, target_module)?; - let imports = resource_import_paths(target_module, layout, &resource, &singular); - - generate_dto_files( - &target_root, - layout, - &resource, - &singular, - &pascal_singular, - &dto_fields, - )?; - generate_service_file( - &target_root, - layout, - &resource, - &singular, - &pascal_plural, - &pascal_singular, - &imports, - )?; - generate_controller_file( - &target_root, - layout, - &resource, - &singular, - &pascal_plural, - &pascal_singular, - &imports, - )?; - - patch_dto_mod(&target_root, layout, &singular, &pascal_singular)?; - patch_services_mod(&target_root, layout, &resource, &pascal_plural)?; - patch_controllers_mod(&target_root, layout, &resource, &pascal_plural)?; - - if let Some(module_name) = target_module { - patch_feature_module(&app_root, module_name, layout, &pascal_plural, true, true)?; - } else { - if layout == GeneratorLayout::Flat { - patch_main_mod_decl(&app_root, &format!("{}_controller", resource))?; - patch_main_mod_decl(&app_root, &format!("{}_service", resource))?; - patch_main_mod_decl(&app_root, &format!("{}_dto", singular))?; - patch_main_mod_decl(&app_root, &format!("create_{}_dto", singular))?; - patch_main_mod_decl(&app_root, &format!("update_{}_dto", singular))?; - } else { - patch_main_mod_decl(&app_root, "controllers")?; - patch_main_mod_decl(&app_root, "services")?; - patch_main_mod_decl(&app_root, "dto")?; - } - patch_app_module(&app_root, layout, &resource, &pascal_plural)?; - } - - spinner.finish_and_clear(); - print_success(format!("Generated resource `{}`", resource)); - Ok(()) -} - -fn generate_controller_only( - name: &str, - target_module: Option<&str>, - layout: GeneratorLayout, -) -> Result<()> { - let app_root = detect_app_root()?; - let resource = normalize_resource_name(name); - let singular = singular_name(&resource); - let pascal_plural = to_pascal_case(&resource); - let pascal_singular = to_pascal_case(&singular); - let target_root = generator_target_root(&app_root, target_module)?; - let imports = resource_import_paths(target_module, layout, &resource, &singular); - - generate_controller_file( - &target_root, - layout, - &resource, - &singular, - &pascal_plural, - &pascal_singular, - &imports, - )?; - patch_controllers_mod(&target_root, layout, &resource, &pascal_plural)?; - if let Some(module_name) = target_module { - patch_feature_module(&app_root, module_name, layout, &pascal_plural, true, false)?; - } else { - if layout == GeneratorLayout::Flat { - patch_main_mod_decl(&app_root, &format!("{}_controller", resource))?; - } else { - patch_main_mod_decl(&app_root, "controllers")?; - } - patch_app_module_controllers_only(&app_root, layout, &resource, &pascal_plural)?; - } - - println!("Generated controller `{}`", resource); - Ok(()) -} - -fn generate_service_only( - name: &str, - target_module: Option<&str>, - layout: GeneratorLayout, -) -> Result<()> { - let app_root = detect_app_root()?; - let resource = normalize_resource_name(name); - let singular = singular_name(&resource); - let pascal_plural = to_pascal_case(&resource); - let pascal_singular = to_pascal_case(&singular); - let target_root = generator_target_root(&app_root, target_module)?; - let imports = resource_import_paths(target_module, layout, &resource, &singular); - - generate_service_file( - &target_root, - layout, - &resource, - &singular, - &pascal_plural, - &pascal_singular, - &imports, - )?; - patch_services_mod(&target_root, layout, &resource, &pascal_plural)?; - if let Some(module_name) = target_module { - patch_feature_module(&app_root, module_name, layout, &pascal_plural, false, true)?; - } else { - if layout == GeneratorLayout::Flat { - patch_main_mod_decl(&app_root, &format!("{}_service", resource))?; - } else { - patch_main_mod_decl(&app_root, "services")?; - } - patch_app_module_providers_only(&app_root, layout, &resource, &pascal_plural)?; - } - - println!("Generated service `{}`", resource); - Ok(()) -} - -fn generate_module(name: &str, layout: GeneratorLayout) -> Result<()> { - let app_root = detect_app_root()?; - let module_name = normalize_resource_name(name); - let pascal_module = format!("{}Module", to_pascal_case(&module_name)); - let module_dir = app_root.join("src").join(&module_name); - let module_file = module_dir.join("mod.rs"); - - if module_file.exists() { - bail!("Module folder already exists: {}", module_dir.display()); - } - - write_file( - &module_dir.join("mod.rs"), - &template_feature_mod_rs(&module_name, &pascal_module, layout), - )?; - if layout == GeneratorLayout::Nested { - write_file( - &module_dir.join("controllers/mod.rs"), - &template_feature_controllers_mod_rs(&module_name, &pascal_module), - )?; - write_file( - &module_dir.join("services/mod.rs"), - &template_feature_services_mod_rs(&module_name, &pascal_module), - )?; - write_file( - &module_dir.join("dto/mod.rs"), - &template_feature_dto_mod_rs(), - )?; - } - - patch_main_mod_decl(&app_root, &module_name)?; - patch_root_app_module_import(&app_root, &module_name, &pascal_module)?; - patch_root_app_module_imports_list(&app_root, &pascal_module)?; - - println!("Generated module `{}`", module_name); - Ok(()) -} - -fn generate_guard_only(name: &str) -> Result<()> { - let app_root = detect_app_root()?; - let guard_name = normalize_resource_name(name); - let pascal_guard = format!("{}Guard", to_pascal_case(&guard_name)); - let guard_file = app_root - .join("src/guards") - .join(format!("{}_guard.rs", guard_name)); - - if guard_file.exists() { - println!("Guard already exists: {}", guard_file.display()); - return Ok(()); - } - - fs::create_dir_all(app_root.join("src/guards"))?; - write_file(&guard_file, &template_guard_rs(&pascal_guard))?; - patch_guards_mod(&app_root, &guard_name, &pascal_guard)?; - patch_main_mod_decl(&app_root, "guards")?; - - println!("Generated guard `{}`", guard_name); - Ok(()) -} - -fn generate_request_decorator_only(name: &str) -> Result<()> { - let app_root = detect_app_root()?; - let decorator_name = normalize_resource_name(name); - let pascal_decorator = to_pascal_case(&decorator_name); - let decorator_file = app_root - .join("src/decorators") - .join(format!("{}_decorator.rs", decorator_name)); - - if decorator_file.exists() { - println!( - "Request decorator already exists: {}", - decorator_file.display() - ); - return Ok(()); - } - - fs::create_dir_all(app_root.join("src/decorators"))?; - write_file( - &decorator_file, - &template_request_decorator_rs(&pascal_decorator), - )?; - patch_decorators_mod(&app_root, &decorator_name, &pascal_decorator)?; - patch_main_mod_decl(&app_root, "decorators")?; - - println!("Generated request decorator `{}`", decorator_name); - Ok(()) -} - -fn generate_exception_filter_only(name: &str) -> Result<()> { - let app_root = detect_app_root()?; - let filter_name = normalize_resource_name(name); - let pascal_filter = format!("{}Filter", to_pascal_case(&filter_name)); - let filter_file = app_root - .join("src/filters") - .join(format!("{}_filter.rs", filter_name)); - - if filter_file.exists() { - println!("Exception filter already exists: {}", filter_file.display()); - return Ok(()); - } - - fs::create_dir_all(app_root.join("src/filters"))?; - write_file(&filter_file, &template_exception_filter_rs(&pascal_filter))?; - patch_filters_mod(&app_root, &filter_name, &pascal_filter)?; - patch_main_mod_decl(&app_root, "filters")?; - - println!("Generated exception filter `{}`", filter_name); - Ok(()) -} - -fn generate_middleware_only(name: &str) -> Result<()> { - let app_root = detect_app_root()?; - let middleware_name = normalize_resource_name(name); - let pascal_middleware = format!("{}Middleware", to_pascal_case(&middleware_name)); - let middleware_file = app_root - .join("src/middleware") - .join(format!("{}_middleware.rs", middleware_name)); - - if middleware_file.exists() { - println!("Middleware already exists: {}", middleware_file.display()); - return Ok(()); - } - - fs::create_dir_all(app_root.join("src/middleware"))?; - write_file( - &middleware_file, - &template_middleware_rs(&pascal_middleware), - )?; - patch_middleware_mod(&app_root, &middleware_name, &pascal_middleware)?; - patch_main_mod_decl(&app_root, "middleware")?; - - println!("Generated middleware `{}`", middleware_name); - Ok(()) -} - -fn generate_interceptor_only(name: &str) -> Result<()> { - let app_root = detect_app_root()?; - let interceptor_name = normalize_resource_name(name); - let pascal_interceptor = format!("{}Interceptor", to_pascal_case(&interceptor_name)); - let interceptor_file = app_root - .join("src/interceptors") - .join(format!("{}_interceptor.rs", interceptor_name)); - - if interceptor_file.exists() { - println!("Interceptor already exists: {}", interceptor_file.display()); - return Ok(()); - } - - fs::create_dir_all(app_root.join("src/interceptors"))?; - write_file( - &interceptor_file, - &template_interceptor_rs(&pascal_interceptor), - )?; - patch_interceptors_mod(&app_root, &interceptor_name, &pascal_interceptor)?; - patch_main_mod_decl(&app_root, "interceptors")?; - - println!("Generated interceptor `{}`", interceptor_name); - Ok(()) -} - -fn generate_serializer_only(name: &str) -> Result<()> { - let app_root = detect_app_root()?; - let serializer_name = normalize_resource_name(name); - let pascal_serializer = format!("{}Serializer", to_pascal_case(&serializer_name)); - let serializer_file = app_root - .join("src/serializers") - .join(format!("{}_serializer.rs", serializer_name)); - - fs::create_dir_all(app_root.join("src/serializers"))?; - - if !serializer_file.exists() { - write_file( - &serializer_file, - &template_serializer_rs(&serializer_name, &pascal_serializer), - )?; - } else { - println!("Serializer already exists: {}", serializer_file.display()); - } - - patch_serializers_mod(&app_root, &serializer_name, &pascal_serializer)?; - patch_main_mod_decl(&app_root, "serializers")?; - - println!("Generated serializer `{}`", serializer_name); - println!("Next: implement `ResponseSerializer<T>` for your domain type"); - Ok(()) -} - -fn generate_websocket_gateway_only(name: &str) -> Result<()> { - let app_root = detect_app_root()?; - let gateway_name = normalize_resource_name(name); - let pascal_gateway = format!("{}Gateway", to_pascal_case(&gateway_name)); - let gateway_file = app_root - .join("src/ws") - .join(format!("{}_gateway.rs", gateway_name)); - - fs::create_dir_all(app_root.join("src/ws"))?; - ensure_ws_mod(&app_root)?; - - if !gateway_file.exists() { - write_file( - &gateway_file, - &template_named_ws_gateway_rs(&pascal_gateway), - )?; - } else { - println!( - "WebSocket gateway already exists: {}", - gateway_file.display() - ); - } - - patch_ws_mod(&app_root, &gateway_name, &pascal_gateway)?; - patch_main_mod_decl(&app_root, "ws")?; - - println!("Generated WebSocket gateway `{}`", gateway_name); - Ok(()) -} - -fn generate_graphql_resolver_only(name: &str) -> Result<()> { - let app_root = detect_app_root()?; - let resolver_name = normalize_resource_name(name); - let pascal_name = to_pascal_case(&resolver_name); - let graphql_dir = app_root.join("src/graphql"); - let resolver_file = graphql_dir.join(format!("{}_resolver.rs", resolver_name)); - - fs::create_dir_all(&graphql_dir)?; - ensure_graphql_mod(&app_root)?; - - if !resolver_file.exists() { - write_file( - &resolver_file, - &template_graphql_resolver_rs(&resolver_name, &pascal_name), - )?; - } else { - println!( - "GraphQL resolver already exists: {}", - resolver_file.display() - ); - } - - patch_graphql_mod(&app_root, &resolver_name, &pascal_name)?; - - println!("Generated GraphQL resolver `{}`", resolver_name); - println!("Next: wire `{pascal_name}Resolver` into src/graphql/schema.rs"); - Ok(()) -} - -fn generate_grpc_service_only(name: &str) -> Result<()> { - let app_root = detect_app_root()?; - let service_name = normalize_resource_name(name); - let pascal_name = to_pascal_case(&service_name); - let proto_path = app_root.join("proto").join(format!("{service_name}.proto")); - let service_path = app_root - .join("src/grpc") - .join(format!("{}_service.rs", service_name)); - - fs::create_dir_all(app_root.join("proto"))?; - fs::create_dir_all(app_root.join("src/grpc"))?; - ensure_grpc_build_rs(&app_root)?; - ensure_grpc_mod(&app_root)?; - - if !proto_path.exists() { - write_file( - &proto_path, - &template_named_grpc_proto(&service_name, &pascal_name), - )?; - } - - if !service_path.exists() { - write_file( - &service_path, - &template_named_grpc_service_rs(&service_name, &pascal_name), - )?; - } else { - println!("gRPC service already exists: {}", service_path.display()); - } - - patch_grpc_build_rs(&app_root, &service_name)?; - patch_grpc_mod_rs(&app_root, &service_name, &pascal_name)?; - - println!("Generated gRPC service `{}`", service_name); - println!( - "Next: mount `{pascal_name}ServiceServer::new({pascal_name}GrpcService::new(ctx))` in src/main.rs" - ); - Ok(()) -} - -fn generate_microservice_patterns_only(name: &str) -> Result<()> { - let app_root = detect_app_root()?; - let pattern_name = normalize_resource_name(name); - let pascal_name = to_pascal_case(&pattern_name); - let patterns_dir = app_root.join("src/microservices"); - let patterns_file = patterns_dir.join(format!("{}_patterns.rs", pattern_name)); - - fs::create_dir_all(&patterns_dir)?; - ensure_microservices_mod(&app_root)?; - - if !patterns_file.exists() { - write_file( - &patterns_file, - &template_microservice_patterns_rs(&pattern_name, &pascal_name), - )?; - } else { - println!( - "Microservice patterns already exist: {}", - patterns_file.display() - ); - } - - patch_microservices_mod(&app_root, &pattern_name, &pascal_name)?; - patch_main_mod_decl(&app_root, "microservices")?; - - println!("Generated microservice patterns `{}`", pattern_name); - println!( - "Next: register `{pascal_name}Patterns::registry()` with your transport adapter or module provider" - ); - println!("Note: enable the `microservices` feature on `nestforge` in Cargo.toml if needed"); - Ok(()) -} - -/* ------------------------------ - FILE GENERATION ------------------------------- */ - -fn generate_dto_files( - target_root: &Path, - layout: GeneratorLayout, - resource: &str, - singular: &str, - pascal_singular: &str, - fields: &[DtoFieldSpec], -) -> Result<()> { - let dto_dir = dto_dir(target_root, layout); - - let entity_file = dto_dir.join(format!("{}_dto.rs", singular)); - let create_file = dto_dir.join(format!("create_{}_dto.rs", singular)); - let update_file = dto_dir.join(format!("update_{}_dto.rs", singular)); - - if !entity_file.exists() { - write_file( - &entity_file, - &template_entity_dto_rs(pascal_singular, fields), - )?; - } - if !create_file.exists() { - write_file( - &create_file, - &template_create_dto_rs(pascal_singular, fields), - )?; - } - if !update_file.exists() { - write_file( - &update_file, - &template_update_dto_rs(pascal_singular, fields), - )?; - } - - let _ = resource; // kept for future template customization - Ok(()) -} - -fn collect_dto_fields(resource_name: &str, prompt_for_dto: bool) -> Result<Vec<DtoFieldSpec>> { - if !prompt_for_dto || !io::stdin().is_terminal() || !io::stdout().is_terminal() { - return Ok(default_dto_fields()); - } - - println!(); - println!("Configure DTO fields for {resource_name}:"); - if !prompt_yes_no("Customize generated DTO fields?", false)? { - return Ok(default_dto_fields()); - } - - let mut fields = Vec::new(); - loop { - let name = prompt_string("Field name (leave empty to finish)", true)?; - if name.is_empty() { - break; - } - - let normalized = normalize_resource_name(&name); - if normalized.is_empty() { - println!("Field name cannot be empty."); - continue; - } - - if fields - .iter() - .any(|field: &DtoFieldSpec| field.name == normalized) - { - println!("Field `{normalized}` already exists."); - continue; - } - - let ty = prompt_field_type()?; - let required = prompt_yes_no("Required in Create DTO?", ty != DtoFieldType::Bool)?; - fields.push(DtoFieldSpec { - name: normalized, - ty, - required, - }); - } - - if fields.is_empty() { - Ok(default_dto_fields()) - } else { - Ok(fields) - } -} - -fn default_dto_fields() -> Vec<DtoFieldSpec> { - vec![DtoFieldSpec { - name: "name".to_string(), - ty: DtoFieldType::String, - required: true, - }] -} - -fn prompt_string(prompt: &str, allow_empty: bool) -> Result<String> { - loop { - print!("{prompt}: "); - io::stdout().flush()?; - let mut input = String::new(); - io::stdin().read_line(&mut input)?; - let value = input.trim().to_string(); - if allow_empty || !value.is_empty() { - return Ok(value); - } - } -} - -fn prompt_yes_no(prompt: &str, default: bool) -> Result<bool> { - let suffix = if default { "[Y/n]" } else { "[y/N]" }; - loop { - print!("{prompt} {suffix}: "); - io::stdout().flush()?; - let mut input = String::new(); - io::stdin().read_line(&mut input)?; - let normalized = input.trim().to_ascii_lowercase(); - if normalized.is_empty() { - return Ok(default); - } - match normalized.as_str() { - "y" | "yes" => return Ok(true), - "n" | "no" => return Ok(false), - _ => println!("Enter `y` or `n`."), - } - } -} - -fn prompt_field_type() -> Result<DtoFieldType> { - println!("Select a field type:"); - for (index, ty) in DtoFieldType::choices().iter().enumerate() { - println!(" {}. {}", index + 1, ty.prompt_label()); - } - - loop { - let value = prompt_string("Type number", false)?; - let Ok(choice) = value.parse::<usize>() else { - println!("Enter a number from the list."); - continue; - }; - - if let Some(ty) = DtoFieldType::choices() - .get(choice.saturating_sub(1)) - .copied() - { - return Ok(ty); - } - - println!("Enter a number from the list."); - } -} - -fn generate_service_file( - target_root: &Path, - layout: GeneratorLayout, - resource: &str, - singular: &str, - pascal_plural: &str, - pascal_singular: &str, - imports: &ResourceImportPaths, -) -> Result<()> { - let service_path = services_dir(target_root, layout).join(format!("{}_service.rs", resource)); - if service_path.exists() { - println!("Service already exists: {}", service_path.display()); - return Ok(()); - } - - write_file( - &service_path, - &template_resource_service_rs(resource, singular, pascal_plural, pascal_singular, imports), - )?; - Ok(()) -} - -fn generate_controller_file( - target_root: &Path, - layout: GeneratorLayout, - resource: &str, - singular: &str, - pascal_plural: &str, - pascal_singular: &str, - imports: &ResourceImportPaths, -) -> Result<()> { - let controller_path = - controllers_dir(target_root, layout).join(format!("{}_controller.rs", resource)); - - if controller_path.exists() { - println!("Controller already exists: {}", controller_path.display()); - return Ok(()); - } - - write_file( - &controller_path, - &template_resource_controller_rs( - resource, - singular, - pascal_plural, - pascal_singular, - imports, - ), - )?; - Ok(()) -} - -/* ------------------------------ - PATCHERS ------------------------------- */ - -fn patch_dto_mod( - target_root: &Path, - layout: GeneratorLayout, - singular: &str, - pascal_singular: &str, -) -> Result<()> { - if layout == GeneratorLayout::Flat { - let Some(path) = target_mod_file(target_root) else { - return Ok(()); - }; - let mut content = fs::read_to_string(&path)?; - - for line in [ - format!("pub mod {}_dto;", singular), - format!("pub mod create_{}_dto;", singular), - format!("pub mod update_{}_dto;", singular), - ] { - if !content.contains(&line) { - content = content.replacen( - "/* nestforge:feature_modules */", - &format!("/* nestforge:feature_modules */\n{line}"), - 1, - ); - } - } - - for line in [ - format!("pub use {}_dto::{}Dto;", singular, pascal_singular), - format!( - "pub use create_{}_dto::Create{}Dto;", - singular, pascal_singular - ), - format!( - "pub use update_{}_dto::Update{}Dto;", - singular, pascal_singular - ), - ] { - if !content.contains(&line) { - content = content.replacen( - "/* nestforge:feature_reexports */", - &format!("/* nestforge:feature_reexports */\n{line}"), - 1, - ); - } - } - - fs::write(path, content)?; - return Ok(()); - } - - let path = target_root.join("dto/mod.rs"); - let mut content = if path.exists() { - fs::read_to_string(&path)? - } else { - template_dto_mod_rs() - }; - - let mod_lines = [ - format!("pub mod {}_dto;", singular), - format!("pub mod create_{}_dto;", singular), - format!("pub mod update_{}_dto;", singular), - ]; - - let use_lines = [ - format!("pub use {}_dto::{}Dto;", singular, pascal_singular), - format!( - "pub use create_{}_dto::Create{}Dto;", - singular, pascal_singular - ), - format!( - "pub use update_{}_dto::Update{}Dto;", - singular, pascal_singular - ), - ]; - - for line in mod_lines { - if !content.contains(&line) { - content.push_str(&format!("\n{}", line)); - } - } - - for line in use_lines { - if !content.contains(&line) { - content.push_str(&format!("\n{}", line)); - } - } - - fs::write(path, content)?; - Ok(()) -} - -fn patch_services_mod( - target_root: &Path, - layout: GeneratorLayout, - resource: &str, - pascal_plural: &str, -) -> Result<()> { - if layout == GeneratorLayout::Flat { - let Some(path) = target_mod_file(target_root) else { - return Ok(()); - }; - let mut content = fs::read_to_string(&path)?; - - for line in [ - format!("pub mod {}_service;", resource), - format!("pub use {}_service::{}Service;", resource, pascal_plural), - ] { - let marker = if line.starts_with("pub mod ") { - "/* nestforge:feature_modules */" - } else { - "/* nestforge:feature_reexports */" - }; - if !content.contains(&line) { - content = content.replacen(marker, &format!("{marker}\n{line}"), 1); - } - } - - fs::write(path, content)?; - return Ok(()); - } - - let path = target_root.join("services/mod.rs"); - let mut content = if path.exists() { - fs::read_to_string(&path)? - } else { - template_services_mod_rs() - }; - - let mod_line = format!("pub mod {}_service;", resource); - let use_line = format!("pub use {}_service::{}Service;", resource, pascal_plural); - - if !content.contains(&mod_line) { - content.push_str(&format!("\n{}", mod_line)); - } - if !content.contains(&use_line) { - content.push_str(&format!("\n{}", use_line)); - } - - fs::write(path, content)?; - Ok(()) -} - -fn patch_controllers_mod( - target_root: &Path, - layout: GeneratorLayout, - resource: &str, - pascal_plural: &str, -) -> Result<()> { - if layout == GeneratorLayout::Flat { - let Some(path) = target_mod_file(target_root) else { - return Ok(()); - }; - let mut content = fs::read_to_string(&path)?; - - for line in [ - format!("pub mod {}_controller;", resource), - format!( - "pub use {}_controller::{}Controller;", - resource, pascal_plural - ), - ] { - let marker = if line.starts_with("pub mod ") { - "/* nestforge:feature_modules */" - } else { - "/* nestforge:feature_reexports */" - }; - if !content.contains(&line) { - content = content.replacen(marker, &format!("{marker}\n{line}"), 1); - } - } - - fs::write(path, content)?; - return Ok(()); - } - - let path = target_root.join("controllers/mod.rs"); - let mut content = if path.exists() { - fs::read_to_string(&path)? - } else { - template_controllers_mod_rs() - }; - - let mod_line = format!("pub mod {}_controller;", resource); - let use_line = format!( - "pub use {}_controller::{}Controller;", - resource, pascal_plural - ); - - if !content.contains(&mod_line) { - content.push_str(&format!("\n{}", mod_line)); - } - if !content.contains(&use_line) { - content.push_str(&format!("\n{}", use_line)); - } - - fs::write(path, content)?; - Ok(()) -} - -fn patch_guards_mod(app_root: &Path, guard_name: &str, pascal_guard: &str) -> Result<()> { - let path = app_root.join("src/guards/mod.rs"); - let mut content = if path.exists() { - fs::read_to_string(&path)? - } else { - template_guards_mod_rs() - }; - - let mod_line = format!("pub mod {}_guard;", guard_name); - let use_line = format!("pub use {}_guard::{};", guard_name, pascal_guard); - - if !content.contains(&mod_line) { - content.push_str(&format!("\n{}", mod_line)); - } - if !content.contains(&use_line) { - content.push_str(&format!("\n{}", use_line)); - } - - fs::write(path, content)?; - Ok(()) -} - -fn patch_decorators_mod( - app_root: &Path, - decorator_name: &str, - pascal_decorator: &str, -) -> Result<()> { - let path = app_root.join("src/decorators/mod.rs"); - let mut content = if path.exists() { - fs::read_to_string(&path)? - } else { - template_decorators_mod_rs() - }; - - let mod_line = format!("pub mod {}_decorator;", decorator_name); - let use_line = format!( - "pub use {}_decorator::{};", - decorator_name, pascal_decorator - ); - - if !content.contains(&mod_line) { - content.push_str(&format!("\n{}", mod_line)); - } - if !content.contains(&use_line) { - content.push_str(&format!("\n{}", use_line)); - } - - fs::write(path, content)?; - Ok(()) -} - -fn patch_interceptors_mod( - app_root: &Path, - interceptor_name: &str, - pascal_interceptor: &str, -) -> Result<()> { - let path = app_root.join("src/interceptors/mod.rs"); - let mut content = if path.exists() { - fs::read_to_string(&path)? - } else { - template_interceptors_mod_rs() - }; - - let mod_line = format!("pub mod {}_interceptor;", interceptor_name); - let use_line = format!( - "pub use {}_interceptor::{};", - interceptor_name, pascal_interceptor - ); - - if !content.contains(&mod_line) { - content.push_str(&format!("\n{}", mod_line)); - } - if !content.contains(&use_line) { - content.push_str(&format!("\n{}", use_line)); - } - - fs::write(path, content)?; - Ok(()) -} - -fn patch_filters_mod(app_root: &Path, filter_name: &str, pascal_filter: &str) -> Result<()> { - let path = app_root.join("src/filters/mod.rs"); - let content = if path.exists() { - fs::read_to_string(&path)? - } else { - template_filters_mod_rs() - }; - let mod_line = format!("pub mod {}_filter;", filter_name); - let use_line = format!("pub use {}_filter::{};", filter_name, pascal_filter); - let mut next = content; - - if !next.contains(&mod_line) { - next.push_str(&format!("\n{}", mod_line)); - } - if !next.contains(&use_line) { - next.push_str(&format!("\n{}", use_line)); - } - - fs::write(path, next)?; - Ok(()) -} - -fn patch_middleware_mod( - app_root: &Path, - middleware_name: &str, - pascal_middleware: &str, -) -> Result<()> { - let path = app_root.join("src/middleware/mod.rs"); - let content = if path.exists() { - fs::read_to_string(&path)? - } else { - template_middleware_mod_rs() - }; - let mod_line = format!("pub mod {}_middleware;", middleware_name); - let use_line = format!( - "pub use {}_middleware::{};", - middleware_name, pascal_middleware - ); - let mut next = content; - - if !next.contains(&mod_line) { - next.push_str(&format!("\n{}", mod_line)); - } - if !next.contains(&use_line) { - next.push_str(&format!("\n{}", use_line)); - } - - fs::write(path, next)?; - Ok(()) -} - -fn patch_serializers_mod( - app_root: &Path, - serializer_name: &str, - pascal_serializer: &str, -) -> Result<()> { - let path = app_root.join("src/serializers/mod.rs"); - let content = if path.exists() { - fs::read_to_string(&path)? - } else { - template_serializers_mod_rs() - }; - let mod_line = format!("pub mod {}_serializer;", serializer_name); - let use_line = format!( - "pub use {}_serializer::{};", - serializer_name, pascal_serializer - ); - let mut next = content; - - if !next.contains(&mod_line) { - next.push_str(&format!("\n{}", mod_line)); - } - if !next.contains(&use_line) { - next.push_str(&format!("\n{}", use_line)); - } - - fs::write(path, next)?; - Ok(()) -} - -fn ensure_graphql_mod(app_root: &Path) -> Result<()> { - let path = app_root.join("src/graphql/mod.rs"); - if !path.exists() { - write_file(&path, "pub mod schema;\n")?; - } - Ok(()) -} - -fn ensure_ws_mod(app_root: &Path) -> Result<()> { - let path = app_root.join("src/ws/mod.rs"); - if !path.exists() { - write_file(&path, &template_ws_mod_rs())?; - } - Ok(()) -} - -fn ensure_microservices_mod(app_root: &Path) -> Result<()> { - let path = app_root.join("src/microservices/mod.rs"); - if !path.exists() { - write_file(&path, &template_microservices_mod_rs())?; - } - Ok(()) -} - -fn patch_graphql_mod(app_root: &Path, resolver_name: &str, pascal_name: &str) -> Result<()> { - let path = app_root.join("src/graphql/mod.rs"); - let mut content = if path.exists() { - fs::read_to_string(&path)? - } else { - "pub mod schema;\n".to_string() - }; - - let mod_line = format!("pub mod {}_resolver;", resolver_name); - let use_line = format!( - "pub use {}_resolver::{}Resolver;", - resolver_name, pascal_name - ); - - if !content.contains(&mod_line) { - content.push_str(&format!("\n{}", mod_line)); - } - if !content.contains(&use_line) { - content.push_str(&format!("\n{}", use_line)); - } - - fs::write(path, content)?; - Ok(()) -} - -fn patch_ws_mod(app_root: &Path, gateway_name: &str, pascal_name: &str) -> Result<()> { - let path = app_root.join("src/ws/mod.rs"); - let mut content = if path.exists() { - fs::read_to_string(&path)? - } else { - template_ws_mod_rs() - }; - - let mod_line = format!("mod {}_gateway;", gateway_name); - let use_line = format!("pub use {}_gateway::{};", gateway_name, pascal_name); - - if !content.contains(&mod_line) { - content.push_str(&format!("\n{}", mod_line)); - } - if !content.contains(&use_line) { - content.push_str(&format!("\n{}", use_line)); - } - - fs::write(path, content)?; - Ok(()) -} - -fn patch_microservices_mod(app_root: &Path, pattern_name: &str, pascal_name: &str) -> Result<()> { - let path = app_root.join("src/microservices/mod.rs"); - let mut content = if path.exists() { - fs::read_to_string(&path)? - } else { - template_microservices_mod_rs() - }; - - let mod_line = format!("pub mod {}_patterns;", pattern_name); - let use_line = format!( - "pub use {}_patterns::{}Patterns;", - pattern_name, pascal_name - ); - - if !content.contains(&mod_line) { - content.push_str(&format!("\n{}", mod_line)); - } - if !content.contains(&use_line) { - content.push_str(&format!("\n{}", use_line)); - } - - fs::write(path, content)?; - Ok(()) -} - -fn ensure_grpc_build_rs(app_root: &Path) -> Result<()> { - let path = app_root.join("build.rs"); - if !path.exists() { - write_file(&path, &template_grpc_build_rs())?; - } - Ok(()) -} - -fn ensure_grpc_mod(app_root: &Path) -> Result<()> { - let path = app_root.join("src/grpc/mod.rs"); - if !path.exists() { - write_file(&path, &template_grpc_mod_rs())?; - } - Ok(()) -} - -fn patch_grpc_build_rs(app_root: &Path, service_name: &str) -> Result<()> { - let path = app_root.join("build.rs"); - let mut content = fs::read_to_string(&path)?; - let proto_entry = format!("\"proto/{service_name}.proto\""); - let rerun_line = - format!(" println!(\"cargo:rerun-if-changed=proto/{service_name}.proto\");\n"); - - if !content.contains(&proto_entry) { - let target = ".compile_protos(&[\"proto/greeter.proto\"], &[\"proto\"])?;"; - if content.contains(target) { - content = content.replace( - target, - &format!( - ".compile_protos(&[\"proto/greeter.proto\", {proto_entry}], &[\"proto\"])?;" - ), - ); - } else if let Some(start) = content.find(".compile_protos(&[") { - if let Some(end_rel) = content[start..].find("], &[\"proto\"])?;") { - let insert_at = start + end_rel; - content.insert_str(insert_at, &format!(", {proto_entry}")); - } - } - } - - if !content.contains(&format!( - "cargo:rerun-if-changed=proto/{service_name}.proto" - )) { - if let Some(insert_at) = content.rfind(" Ok(())") { - content.insert_str(insert_at, &rerun_line); - } - } - - fs::write(path, content)?; - Ok(()) -} - -fn patch_grpc_mod_rs(app_root: &Path, service_name: &str, pascal_name: &str) -> Result<()> { - let path = app_root.join("src/grpc/mod.rs"); - let mut content = fs::read_to_string(&path)?; - let proto_module = format!( - " pub mod {service_name} {{\n nestforge::tonic::include_proto!(\"{service_name}\");\n }}\n" - ); - let service_mod_line = format!("pub mod {}_service;", service_name); - let service_use_line = format!( - "pub use {}_service::{}GrpcService;", - service_name, pascal_name - ); - - if content.contains("nestforge::tonic::include_proto!(\"hello\");") { - content = content.replace( - "pub mod proto {\n nestforge::tonic::include_proto!(\"hello\");\n}\n\npub mod service;\n", - "pub mod proto {\n pub mod hello {\n nestforge::tonic::include_proto!(\"hello\");\n }\n}\n\npub mod service;\n", - ); - } - - if !content.contains(&format!("pub mod {service_name} {{")) { - if let Some(insert_at) = content.find("}\n\npub mod service;") { - content.insert_str(insert_at, &proto_module); - } else if let Some(insert_at) = content.find("}\n") { - content.insert_str(insert_at, &proto_module); - } - } - - if !content.contains(&service_mod_line) { - content.push_str(&format!("\n{}", service_mod_line)); - } - if !content.contains(&service_use_line) { - content.push_str(&format!("\n{}", service_use_line)); - } - - fs::write(path, content)?; - Ok(()) -} - -fn patch_app_module( - app_root: &Path, - layout: GeneratorLayout, - resource: &str, - pascal_plural: &str, -) -> Result<()> { - patch_app_module_controllers_only(app_root, layout, resource, pascal_plural)?; - patch_app_module_providers_only(app_root, layout, resource, pascal_plural)?; - - let _ = resource; - Ok(()) -} - -fn patch_feature_module( - app_root: &Path, - module_name: &str, - layout: GeneratorLayout, - pascal_plural: &str, - include_controller: bool, - include_service: bool, -) -> Result<()> { - if layout == GeneratorLayout::Flat { - return patch_feature_module_flat( - app_root, - module_name, - pascal_plural, - include_controller, - include_service, - ); - } - - let path = app_root.join("src").join(module_name).join("mod.rs"); - let mut content = fs::read_to_string(&path)?; - - let controllers_marker = "/* nestforge:feature_controllers */"; - let providers_marker = "/* nestforge:feature_providers */"; - let exports_marker = "/* nestforge:feature_exports */"; - - let controller_entry = format!("controllers::{}Controller,", pascal_plural); - let provider_entry = format!("services::{}Service,", pascal_plural); - let export_entry = format!("services::{}Service,", pascal_plural); - - let controller_block = format!("{}\n {}", controllers_marker, controller_entry); - let provider_block = format!("{}\n {}", providers_marker, provider_entry); - let export_block = format!("{}\n {}", exports_marker, export_entry); - - if !content.contains(&controller_block) && content.contains(controllers_marker) { - content = content.replace( - controllers_marker, - &format!("{}\n {}", controllers_marker, controller_entry), - ); - } - if !content.contains(&provider_block) && content.contains(providers_marker) { - content = content.replace( - providers_marker, - &format!("{}\n {}", providers_marker, provider_entry), - ); - } - if !content.contains(&export_block) && content.contains(exports_marker) { - content = content.replace( - exports_marker, - &format!("{}\n {}", exports_marker, export_entry), - ); - } - - fs::write(path, content)?; - Ok(()) -} - -fn patch_feature_module_flat( - app_root: &Path, - module_name: &str, - pascal_plural: &str, - include_controller: bool, - include_service: bool, -) -> Result<()> { - let path = app_root.join("src").join(module_name).join("mod.rs"); - let mut content = fs::read_to_string(&path)?; - - if include_controller { - let controller_entry = format!("{pascal_plural}Controller,"); - let controller_block = - format!("/* nestforge:feature_controllers */\n {controller_entry}"); - if !content.contains(&controller_block) { - content = content.replacen( - "/* nestforge:feature_controllers */", - &format!("/* nestforge:feature_controllers */\n {controller_entry}"), - 1, - ); - } - } - - if include_service { - let provider_entry = format!("{pascal_plural}Service,"); - let provider_block = format!("/* nestforge:feature_providers */\n {provider_entry}"); - if !content.contains(&provider_block) { - content = content.replacen( - "/* nestforge:feature_providers */", - &format!("/* nestforge:feature_providers */\n {provider_entry}"), - 1, - ); - } - - let export_entry = format!("{pascal_plural}Service,"); - let export_block = format!("/* nestforge:feature_exports */\n {export_entry}"); - if !content.contains(&export_block) { - content = content.replacen( - "/* nestforge:feature_exports */", - &format!("/* nestforge:feature_exports */\n {export_entry}"), - 1, - ); - } - } - - fs::write(path, content)?; - Ok(()) -} - -fn patch_app_module_controllers_only( - app_root: &Path, - layout: GeneratorLayout, - resource: &str, - pascal_plural: &str, -) -> Result<()> { - let path = app_root.join("src/app_module.rs"); - let mut content = fs::read_to_string(&path)?; - - let marker = "/* nestforge:controllers */"; - let entry = format!("{}Controller,", pascal_plural); - - if content.contains(&entry) { - return Ok(()); - } - - content = content.replace(marker, &format!("{}\n {}", marker, entry)); - let import_line = match layout { - GeneratorLayout::Flat => { - format!( - "use crate::{}_controller::{}Controller;\n", - resource, pascal_plural - ) - } - GeneratorLayout::Nested => { - format!("use crate::controllers::{}Controller;\n", pascal_plural) - } - }; - if !content.contains(&import_line) { - content = format!("{import_line}{content}"); - } - fs::write(path, content)?; - Ok(()) -} - -fn patch_app_module_providers_only( - app_root: &Path, - layout: GeneratorLayout, - resource: &str, - pascal_plural: &str, -) -> Result<()> { - let path = app_root.join("src/app_module.rs"); - let mut content = fs::read_to_string(&path)?; - - let marker = "/* nestforge:providers */"; - let entry = format!("{}Service,", pascal_plural); - - if content.contains(&entry) { - return Ok(()); - } - - content = content.replace(marker, &format!("{}\n {}", marker, entry)); - let import_line = match layout { - GeneratorLayout::Flat => { - format!( - "use crate::{}_service::{}Service;\n", - resource, pascal_plural - ) - } - GeneratorLayout::Nested => { - format!("use crate::services::{}Service;\n", pascal_plural) - } - }; - if !content.contains(&import_line) { - content = format!("{import_line}{content}"); - } - fs::write(path, content)?; - Ok(()) -} - -/* ------------------------------ - TEMPLATE HELPERS ------------------------------- */ - -#[cfg_attr(not(test), allow(dead_code))] -fn parse_new_transport_arg(args: &[String]) -> Result<AppTransport> { - if args.is_empty() { - return Ok(AppTransport::Http); - } - - if args.len() == 2 && args[0] == "--transport" { - return AppTransport::parse(&args[1]); - } - - bail!("Invalid new app options. Use: nestforge new <app-name> --transport <http|graphql|grpc|microservices|websockets>") -} - -fn transport_supports_openapi(transport: AppTransport) -> bool { - matches!(transport, AppTransport::Http | AppTransport::Graphql) -} - -fn resolve_nestforge_dependency_line(transport: AppTransport, enable_openapi: bool) -> String { - let framework_version = env!("CARGO_PKG_VERSION"); - let mut features = match transport { - AppTransport::Http => vec!["\"config\""], - AppTransport::Graphql => vec!["\"config\"", "\"graphql\""], - AppTransport::Grpc => vec!["\"config\"", "\"grpc\""], - AppTransport::Microservices => vec!["\"config\"", "\"microservices\"", "\"testing\""], - AppTransport::Websockets => vec!["\"config\"", "\"websockets\""], - }; - if enable_openapi && transport_supports_openapi(transport) { - features.push("\"openapi\""); - } - let features = features.join(", "); - let local_path = Path::new(env!("CARGO_MANIFEST_DIR")) - .parent() - .map(|p| p.join("nestforge")); - - if let Some(path) = local_path { - if path.exists() { - let normalized = path.to_string_lossy().replace('\\', "/"); - return format!( - "nestforge = {{ path = \"{}\", features = [{}] }}", - normalized, features - ); - } - } - - format!( - "nestforge = {{ version = \"{}\", features = [{}] }}", - framework_version, features - ) -} - -fn template_app_cargo_toml( - app_name: &str, - nestforge_dep: String, - transport: AppTransport, -) -> String { - let package_extra = if matches!(transport, AppTransport::Grpc) { - "build = \"build.rs\"\n" - } else { - "" - }; - - let dependency_lines = match transport { - AppTransport::Http => { - "axum = \"0.8\"\ntokio = { version = \"1\", features = [\"full\"] }\nserde = { version = \"1\", features = [\"derive\"] }\nanyhow = \"1\"\n" - } - AppTransport::Graphql => { - "axum = \"0.8\"\nasync-graphql = \"7\"\ntokio = { version = \"1\", features = [\"full\"] }\nanyhow = \"1\"\n" - } - AppTransport::Grpc => { - "axum = \"0.8\"\ntokio = { version = \"1\", features = [\"full\"] }\nanyhow = \"1\"\ntonic = { version = \"0.12\", features = [\"transport\"] }\nprost = \"0.13\"\n" - } - AppTransport::Microservices => { - "axum = \"0.8\"\ntokio = { version = \"1\", features = [\"full\"] }\nanyhow = \"1\"\nserde = { version = \"1\", features = [\"derive\"] }\nserde_json = \"1\"\n" - } - AppTransport::Websockets => { - "axum = \"0.8\"\ntokio = { version = \"1\", features = [\"full\"] }\nanyhow = \"1\"\n" - } - }; - - let build_dependencies = if matches!(transport, AppTransport::Grpc) { - "\n[build-dependencies]\ntonic-build = \"0.12\"\nprotoc-bin-vendored = \"3\"\n" - } else { - "" - }; - - format!( - r#"[package] -name = "{app_name}" -version = "0.1.0" -edition = "2021" -{package_extra} - -[workspace] - -[dependencies] -{nestforge_dep} -{dependency_lines}{build_dependencies} -"#, - nestforge_dep = nestforge_dep, - package_extra = package_extra, - dependency_lines = dependency_lines, - build_dependencies = build_dependencies, - ) -} - -fn template_main_rs(app_name: &str, transport: AppTransport, enable_openapi: bool) -> String { - let crate_name = to_snake_case(app_name); - - match transport { - AppTransport::Http => { - let openapi_setup = if enable_openapi { - format!( - " .with_openapi_docs(\"{} API\", \"1.0.0\")?\n", - to_pascal_case(app_name).replace('_', " ") - ) - } else { - String::new() - }; - - format!( - r#"use {crate_name}::AppModule; -use nestforge::prelude::*; - -const PORT: u16 = 3000; - -async fn bootstrap() -> anyhow::Result<()> {{ - NestForgeFactory::<AppModule>::create()? - .with_global_prefix("api"){openapi_setup} - .with_version("v1") - .listen(PORT) - .await -}} - -#[tokio::main] -async fn main() -> anyhow::Result<()> {{ - bootstrap().await -}} -"#, - crate_name = crate_name - ) +use anyhow::{bail, Context, Result}; +use clap::Parser; +use nestforge_db::{Db, DbConfig}; +use owo_colors::OwoColorize; +use std::{ + collections::{HashMap, HashSet}, + env, fs, + io::{self, IsTerminal, Write}, + path::{Path, PathBuf}, + process::Command, + time::{SystemTime, UNIX_EPOCH}, +}; + +mod cli; +mod diagnostics; +mod tui; +mod ui; + +use crate::cli::{ + AppTransport, Cli, Commands, DbArgs, DbCommand, DocsArgs, DocsFormatArg, GenerateArgs, + GeneratorKindArg, GeneratorLayout, NewArgs, +}; +use crate::diagnostics::{ + app_root_not_found, missing_app_module_declaration, module_file_not_found, + openapi_feature_missing, render_cli_error, +}; +use crate::tui::{ + render_docs_plaintext, run_docs_browser, run_generate_wizard, run_new_wizard, + should_fallback_to_prompt, +}; +use crate::ui::{ + interactive_enabled, print_brand_banner, print_note, print_success, prompt_generator_kind, + prompt_transport, start_spinner, +}; + +#[derive(Clone, Debug, Eq, PartialEq)] +struct GeneratorOptions { + target_module: Option<String>, + layout: GeneratorLayout, + prompt_for_dto: bool, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +struct DtoFieldSpec { + name: String, + ty: DtoFieldType, + required: bool, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum DtoFieldType { + String, + Bool, + U32, + U64, + I64, + F64, +} + +impl DtoFieldType { + fn rust_type(self) -> &'static str { + match self { + Self::String => "String", + Self::Bool => "bool", + Self::U32 => "u32", + Self::U64 => "u64", + Self::I64 => "i64", + Self::F64 => "f64", + } + } + + fn prompt_label(self) -> &'static str { + match self { + Self::String => "String", + Self::Bool => "bool", + Self::U32 => "u32", + Self::U64 => "u64", + Self::I64 => "i64", + Self::F64 => "f64", + } + } + + fn choices() -> &'static [Self] { + &[ + Self::String, + Self::Bool, + Self::U32, + Self::U64, + Self::I64, + Self::F64, + ] + } +} + +fn main() -> miette::Result<()> { + let cli = Cli::parse(); + + if cli.command.is_none() { + print_brand_banner(); + crate::cli::print_help(&cli)?; + return Ok(()); + } + + run_cli(cli).map_err(render_cli_error) +} + +fn run_cli(cli: Cli) -> Result<()> { + match cli.command.expect("command checked above") { + Commands::New(args) => { + let (app_name, transport, enable_openapi) = resolve_new_args(args)?; + create_new_app(&app_name, transport, enable_openapi)?; + } + Commands::Docs(args) => run_docs_command(args)?, + Commands::Generate(args) => { + let (kind, name, options) = resolve_generate_args(args)?; + run_generate_command(kind, &name, options)?; + } + Commands::Db(args) => run_db_command_structured(args)?, + Commands::ExportDocs(args) => run_export_docs_command_with_options(ExportDocsOptions { + format: match args.format { + DocsFormatArg::Json => "json".to_string(), + DocsFormatArg::Yaml => "yaml".to_string(), + }, + output: args.output, + title: args.title, + version: args.version, + module_type: args.module_type, + })?, + Commands::Fmt => run_fmt_command()?, + } + + Ok(()) +} + +fn run_docs_command(args: DocsArgs) -> Result<()> { + let interactive = interactive_enabled(true); + let use_tui = interactive && !args.no_tui; + + if use_tui { + match run_docs_browser(args.topic.as_deref()) { + Ok(()) => return Ok(()), + Err(error) if should_fallback_to_prompt(&error) => { + print_note( + "Full-screen docs browser is unavailable in this terminal. Falling back to plain text.", + ); + } + Err(error) => return Err(error), + } + } + + println!("{}", render_docs_plaintext(args.topic.as_deref())); + Ok(()) +} + +fn resolve_new_args(args: NewArgs) -> Result<(String, AppTransport, bool)> { + let interactive = interactive_enabled(true); + let tui_enabled = interactive && !args.no_tui; + + if tui_enabled && (args.app_name.is_none() || args.transport.is_none()) { + match run_new_wizard() { + Ok((app_name, transport)) => return Ok((app_name, transport, args.openapi)), + Err(error) if should_fallback_to_prompt(&error) => { + print_note( + "Full-screen TUI is unavailable in this terminal. Falling back to prompt mode.", + ); + } + Err(error) => return Err(error), + } + } + + let app_name = match args.app_name { + Some(name) => name, + None if interactive => prompt_string("Application name (example: care-api)", false)?, + None => { + bail!("Missing app name. Use `nestforge new <app-name>` or run in interactive mode.") + } + }; + + let transport = match args.transport { + Some(transport) => transport.into(), + None if interactive => prompt_transport()?, + None => AppTransport::Http, + }; + + if args.openapi && !transport_supports_openapi(transport) { + bail!("OpenAPI scaffolding is currently supported for HTTP and GraphQL apps only."); + } + + Ok((app_name, transport, args.openapi)) +} + +fn resolve_generate_args( + args: GenerateArgs, +) -> Result<(GeneratorKindArg, String, GeneratorOptions)> { + let interactive = interactive_enabled(true); + let tui_enabled = interactive && !args.no_tui; + + if tui_enabled && (args.kind.is_none() || args.name.is_none()) { + match run_generate_wizard() { + Ok(result) => { + return Ok(( + result.kind, + result.name, + GeneratorOptions { + target_module: result.module.map(|value| normalize_resource_name(&value)), + layout: result.layout, + prompt_for_dto: !result.no_prompt, + }, + )); + } + Err(error) if should_fallback_to_prompt(&error) => { + print_note( + "Full-screen TUI is unavailable in this terminal. Falling back to prompt mode.", + ); + } + Err(error) => return Err(error), + } + } + + let kind = match args.kind { + Some(kind) => kind, + None if interactive => prompt_generator_kind()?, + None => bail!("Missing generator kind. Use `nestforge generate <kind> <name>`."), + }; + + let name = match args.name { + Some(name) => name, + None if interactive => prompt_string("Resource or module name (example: users)", false)?, + None => bail!( + "Missing generator name. Use `nestforge generate {}` <name>.", + kind.label() + ), + }; + + let target_module = if args.module.is_some() { + args.module.map(|value| normalize_resource_name(&value)) + } else if interactive + && matches!( + kind, + GeneratorKindArg::Resource | GeneratorKindArg::Controller | GeneratorKindArg::Service + ) + && prompt_yes_no("Generate inside a feature module?", false)? + { + let module_name = prompt_string("Target module name", false)?; + Some(normalize_resource_name(&module_name)) + } else { + None + }; + + Ok(( + kind, + name, + GeneratorOptions { + target_module, + layout: if args.flat { + GeneratorLayout::Flat + } else { + GeneratorLayout::Nested + }, + prompt_for_dto: !args.no_prompt, + }, + )) +} + +fn run_generate_command( + kind: GeneratorKindArg, + name: &str, + options: GeneratorOptions, +) -> Result<()> { + match kind { + GeneratorKindArg::Resource => generate_resource( + name, + options.target_module.as_deref(), + options.layout, + options.prompt_for_dto, + ), + GeneratorKindArg::Controller => { + generate_controller_only(name, options.target_module.as_deref(), options.layout) + } + GeneratorKindArg::Service => { + generate_service_only(name, options.target_module.as_deref(), options.layout) + } + GeneratorKindArg::Module => generate_module(name, options.layout), + GeneratorKindArg::Guard => generate_guard_only(name), + GeneratorKindArg::Decorator => generate_request_decorator_only(name), + GeneratorKindArg::Filter => generate_exception_filter_only(name), + GeneratorKindArg::Middleware => generate_middleware_only(name), + GeneratorKindArg::Interceptor => generate_interceptor_only(name), + GeneratorKindArg::Serializer => generate_serializer_only(name), + GeneratorKindArg::Graphql => generate_graphql_resolver_only(name), + GeneratorKindArg::Grpc => generate_grpc_service_only(name), + GeneratorKindArg::Gateway => generate_websocket_gateway_only(name), + GeneratorKindArg::Microservice => generate_microservice_patterns_only(name), + } +} + +fn run_db_command_structured(args: DbArgs) -> Result<()> { + let app_root = detect_app_root()?; + match args.action { + DbCommand::Init => db_init(&app_root), + DbCommand::Generate { name } => db_generate(&app_root, &name), + DbCommand::Migrate => db_migrate(&app_root), + DbCommand::Status => db_status(&app_root), + } +} + +/* ------------------------------ + NEW APP SCAFFOLD +------------------------------ */ + +fn create_new_app(app_name: &str, transport: AppTransport, enable_openapi: bool) -> Result<()> { + let app_dir = env::current_dir()?.join(app_name); + + if app_dir.exists() { + bail!("App `{}` already exists at {}", app_name, app_dir.display()); + } + + let spinner = start_spinner(format!( + "Scaffolding {} app {}", + transport.label(), + app_name.bold() + )); + + /* Cargo.toml */ + write_file( + &app_dir.join("Cargo.toml"), + &template_app_cargo_toml( + app_name, + resolve_nestforge_dependency_line(transport, enable_openapi), + transport, + ), + )?; + + /* main.rs */ + write_file( + &app_dir.join("src/main.rs"), + &template_main_rs(app_name, transport, enable_openapi), + )?; + write_file( + &app_dir.join("src/lib.rs"), + &template_app_lib_rs(transport), + )?; + + /* app_module.rs */ + write_file( + &app_dir.join("src/app_module.rs"), + &template_app_module_rs(transport), + )?; + + write_file( + &app_dir.join("src/app_config.rs"), + &template_app_config_rs(transport), + )?; + + scaffold_transport_files(&app_dir, transport)?; + + write_file( + &app_dir.join(".env.example"), + &template_env_file(app_name, transport), + )?; + write_file( + &app_dir.join(".env"), + &template_env_file(app_name, transport), + )?; + + spinner.finish_and_clear(); + print_success(format!( + "Created NestForge {} app at {}", + transport.label(), + app_dir.display() + )); + print_note(format!("Next: cd {}", app_dir.display())); + print_note("Then run: cargo run"); + + if enable_openapi { + print_note("OpenAPI docs will be available at /api/v1/docs for supported HTTP routes."); + } + + if matches!(transport, AppTransport::Http) { + print_note("Then generate your first resource: nestforge generate resource users"); + } + + Ok(()) +} + +fn scaffold_transport_files(app_dir: &Path, transport: AppTransport) -> Result<()> { + match transport { + AppTransport::Http => { + fs::create_dir_all(app_dir.join("src/guards"))?; + fs::create_dir_all(app_dir.join("src/interceptors"))?; + + write_file( + &app_dir.join("src/app_service.rs"), + &template_app_service_rs(), + )?; + write_file( + &app_dir.join("src/app_controller.rs"), + &template_app_controller_rs(), + )?; + write_file( + &app_dir.join("src/health_controller.rs"), + &template_health_controller_rs(), + )?; + write_file( + &app_dir.join("src/guards/mod.rs"), + &template_guards_mod_rs(), + )?; + write_file( + &app_dir.join("src/filters/mod.rs"), + &template_filters_mod_rs(), + )?; + write_file( + &app_dir.join("src/interceptors/mod.rs"), + &template_interceptors_mod_rs(), + )?; + } + AppTransport::Graphql => { + fs::create_dir_all(app_dir.join("src/graphql"))?; + write_file(&app_dir.join("src/graphql/mod.rs"), "pub mod schema;\n")?; + write_file( + &app_dir.join("src/graphql/schema.rs"), + &template_graphql_schema_rs(), + )?; + } + AppTransport::Grpc => { + fs::create_dir_all(app_dir.join("src/grpc"))?; + fs::create_dir_all(app_dir.join("proto"))?; + write_file(&app_dir.join("build.rs"), &template_grpc_build_rs())?; + write_file(&app_dir.join("proto/greeter.proto"), &template_grpc_proto())?; + write_file(&app_dir.join("src/grpc/mod.rs"), &template_grpc_mod_rs())?; + write_file( + &app_dir.join("src/grpc/service.rs"), + &template_grpc_service_rs(), + )?; + } + AppTransport::Microservices => { + fs::create_dir_all(app_dir.join("src/microservices"))?; + write_file( + &app_dir.join("src/microservices/mod.rs"), + &template_microservices_app_mod_rs(), + )?; + write_file( + &app_dir.join("src/microservices/app_patterns.rs"), + &template_microservices_app_patterns_rs(), + )?; + } + AppTransport::Websockets => { + fs::create_dir_all(app_dir.join("src/ws"))?; + write_file(&app_dir.join("src/ws/mod.rs"), &template_ws_mod_rs())?; + write_file( + &app_dir.join("src/ws/events_gateway.rs"), + &template_ws_gateway_rs(), + )?; + } + } + + Ok(()) +} + +/* ------------------------------ + DB COMMANDS +------------------------------ */ + +fn run_fmt_command() -> Result<()> { + let target_dir = detect_app_root().or_else(|_| env::current_dir())?; + let status = Command::new("cargo") + .arg("fmt") + .current_dir(&target_dir) + .status() + .with_context(|| format!("Failed to run cargo fmt in {}", target_dir.display()))?; + + if !status.success() { + bail!("cargo fmt failed in {}", target_dir.display()); + } + + println!("Formatted Rust sources in {}", target_dir.display()); + Ok(()) +} + +fn run_export_docs_command_with_options(options: ExportDocsOptions) -> Result<()> { + let app_root = detect_app_root().or_else(|_| env::current_dir())?; + run_export_docs_command_with_options_at(app_root, options) +} + +fn run_export_docs_command_with_options_at( + app_root: PathBuf, + options: ExportDocsOptions, +) -> Result<()> { + let output = options.output.unwrap_or_else(|| { + let file_name = match options.format.as_str() { + "yaml" => "openapi.yaml", + _ => "openapi.json", + }; + app_root.join("docs").join(file_name) + }); + + export_openapi_docs( + &app_root, + &options.title, + &options.version, + &options.module_type, + &output, + ) +} + +#[derive(Clone, Debug, Eq, PartialEq)] +struct ExportDocsOptions { + format: String, + output: Option<PathBuf>, + title: String, + version: String, + module_type: String, +} + +#[cfg_attr(not(test), allow(dead_code))] +fn parse_export_docs_options(args: &[String]) -> Result<ExportDocsOptions> { + let mut format = "json".to_string(); + let mut output = None; + let mut title = "NestForge API".to_string(); + let mut version = "0.1.0".to_string(); + let mut module_type = "AppModule".to_string(); + let mut index = 0usize; + + while index < args.len() { + match args[index].as_str() { + "--format" => { + let Some(value) = args.get(index + 1) else { + bail!("Use: nestforge export-docs [--format json|yaml] [--output <path>] [--title <title>] [--version <version>] [--module-type <type>]"); + }; + let normalized = value.to_ascii_lowercase(); + if normalized != "json" && normalized != "yaml" { + bail!("Unsupported docs format `{value}`. Use `json` or `yaml`."); + } + format = normalized; + index += 2; + } + "--output" => { + let Some(value) = args.get(index + 1) else { + bail!("Use: nestforge export-docs [--format json|yaml] [--output <path>] [--title <title>] [--version <version>] [--module-type <type>]"); + }; + output = Some(PathBuf::from(value)); + index += 2; + } + "--title" => { + let Some(value) = args.get(index + 1) else { + bail!("Use: nestforge export-docs [--format json|yaml] [--output <path>] [--title <title>] [--version <version>] [--module-type <type>]"); + }; + title = value.clone(); + index += 2; + } + "--version" => { + let Some(value) = args.get(index + 1) else { + bail!("Use: nestforge export-docs [--format json|yaml] [--output <path>] [--title <title>] [--version <version>] [--module-type <type>]"); + }; + version = value.clone(); + index += 2; + } + "--module-type" => { + let Some(value) = args.get(index + 1) else { + bail!("Use: nestforge export-docs [--format json|yaml] [--output <path>] [--title <title>] [--version <version>] [--module-type <type>]"); + }; + module_type = value.clone(); + index += 2; + } + _ => bail!("Use: nestforge export-docs [--format json|yaml] [--output <path>] [--title <title>] [--version <version>] [--module-type <type>]"), + } + } + + Ok(ExportDocsOptions { + format, + output, + title, + version, + module_type, + }) +} + +fn export_openapi_docs( + app_root: &Path, + title: &str, + version: &str, + module_type: &str, + output: &Path, +) -> Result<()> { + let spinner = start_spinner(format!( + "Exporting OpenAPI {} to {}", + title.bold(), + output.display() + )); + let main_rs = app_root.join("src/main.rs"); + let temp_bin = app_root.join("src/bin/nestforge_export_docs.rs"); + let top_level_mods = collect_top_level_modules(&main_rs)?; + let module_lines = top_level_mods + .iter() + .map(|module_name| { + let module_path = resolve_top_level_module_path(app_root, module_name)?; + let relative = relative_path_from(&temp_bin, &module_path)?; + Ok(format!("#[path = \"{}\"] mod {};", relative, module_name)) + }) + .collect::<Result<Vec<_>>>()? + .join("\n"); + let output_path = output + .canonicalize() + .unwrap_or_else(|_| output.to_path_buf()); + let output_path_string = output_path.to_string_lossy().replace('\\', "\\\\"); + + let script = format!( + r#"{module_lines} + +use anyhow::Context; + +fn main() -> anyhow::Result<()> {{ + let doc = nestforge::openapi_doc_for_module::<app_module::{module_type}>("{title}", "{version}") + .context("Failed to collect OpenAPI metadata. Ensure the app depends on nestforge with the `openapi` feature enabled.")?; + let path = std::path::PathBuf::from(r"{output_path_string}"); + if let Some(parent) = path.parent() {{ + std::fs::create_dir_all(parent)?; + }} + let body = if path.extension().and_then(|ext| ext.to_str()) == Some("yaml") {{ + doc.to_openapi_yaml() + }} else {{ + nestforge::serde_json::to_string_pretty(&doc.to_openapi_json())? + }}; + std::fs::write(&path, body)?; + println!("Exported OpenAPI spec to {{}}", path.display()); + Ok(()) +}} +"# + ); + + write_file(&temp_bin, &script)?; + let status = Command::new("cargo") + .args([ + "run", + "--offline", + "--quiet", + "--bin", + "nestforge_export_docs", + ]) + .current_dir(app_root) + .status() + .with_context(|| format!("Failed to run cargo export in {}", app_root.display()))?; + + let cleanup_result = fs::remove_file(&temp_bin); + if let Err(error) = cleanup_result { + spinner.println(format!( + "{} failed to remove {}: {error}", + "warning".bright_yellow(), + temp_bin.display() + )); + } + + if !status.success() { + spinner.finish_and_clear(); + return Err(openapi_feature_missing()); + } + + spinner.finish_and_clear(); + print_success(format!("Exported OpenAPI spec to {}", output.display())); + Ok(()) +} + +fn db_init(app_root: &Path) -> Result<()> { + fs::create_dir_all(migrations_dir(app_root))?; + fs::create_dir_all(nestforge_dir(app_root))?; + + let applied = applied_migrations_file(app_root); + if !applied.exists() { + write_file(&applied, "")?; + } + + let env_example = app_root.join(".env.example"); + if !env_example.exists() { + write_file( + &env_example, + "# Set your local database connection string before running DB commands.\nDATABASE_URL=postgres://<user>:<password>@localhost/<database>\n", + )?; + } + + let env_file = app_root.join(".env"); + if !env_file.exists() { + write_file( + &env_file, + "# Set your local database connection string before running the app.\nDATABASE_URL=postgres://<user>:<password>@localhost/<database>\n", + )?; + } + + println!("Initialized DB migration setup in {}", app_root.display()); + Ok(()) +} + +fn db_generate(app_root: &Path, name: &str) -> Result<()> { + db_init(app_root)?; + + let slug = to_snake_case(name); + let stamp = current_unix_timestamp()?; + let file_name = format!("{stamp}_{slug}.sql"); + let file_path = migrations_dir(app_root).join(&file_name); + + if file_path.exists() { + bail!("Migration already exists: {}", file_path.display()); + } + + let template = format!( + "-- Migration: {name}\n-- Generated by nestforge db generate\n\n-- Write SQL statements below.\n-- Example:\n-- CREATE TABLE users (\n-- id BIGSERIAL PRIMARY KEY,\n-- email TEXT NOT NULL UNIQUE\n-- );\n" + ); + write_file(&file_path, &template)?; + + println!("Created migration {}", file_name); + Ok(()) +} + +fn db_migrate(app_root: &Path) -> Result<()> { + db_init(app_root)?; + + let migrations = list_migration_files(app_root)?; + let applied = read_applied_migrations(app_root)?; + let applied_names: HashSet<String> = applied.keys().cloned().collect(); + let pending: Vec<PathBuf> = migrations + .into_iter() + .filter(|path| { + path.file_name() + .and_then(|n| n.to_str()) + .map(|name| !applied_names.contains(name)) + .unwrap_or(false) + }) + .collect(); + + if pending.is_empty() { + println!("No pending migrations."); + return Ok(()); + } + + let database_url = resolve_database_url(app_root)?; + let rt = tokio::runtime::Runtime::new().context("Failed to initialize tokio runtime")?; + let db = rt + .block_on(Db::connect(DbConfig::new(database_url))) + .context("Failed to connect using DATABASE_URL (value redacted)")?; + + for migration in pending { + let file_name = migration + .file_name() + .and_then(|n| n.to_str()) + .context("Invalid migration filename")? + .to_string(); + let sql = fs::read_to_string(&migration) + .with_context(|| format!("Failed to read migration {}", migration.display()))?; + + if !contains_sql_content(&sql) { + println!("Skipping empty migration {}", file_name); + let hash = compute_content_hash(&sql); + append_applied_migration(app_root, &file_name, &hash)?; + continue; + } + + rt.block_on(async { + let mut tx = db + .begin() + .await + .with_context(|| format!("Migration {} failed to start transaction", file_name))?; + + tx.execute_script(&sql).await.with_context(|| { + format!("Migration {} failed while executing SQL script", file_name) + })?; + + tx.commit() + .await + .with_context(|| format!("Migration {} failed to commit transaction", file_name)) + })?; + + let hash = compute_content_hash(&sql); + append_applied_migration(app_root, &file_name, &hash)?; + println!("Applied {}", file_name); + } + + println!("Migration run complete."); + Ok(()) +} + +fn db_status(app_root: &Path) -> Result<()> { + db_init(app_root)?; + + let migrations = list_migration_files(app_root)?; + let applied = read_applied_migrations(app_root)?; + + if migrations.is_empty() { + println!("No migration files found."); + return Ok(()); + } + + let mut applied_count = 0usize; + let mut pending_count = 0usize; + + let mut drift_count = 0usize; + for migration in migrations { + let file_name = migration + .file_name() + .and_then(|n| n.to_str()) + .context("Invalid migration filename")?; + if let Some(stored_hash) = applied.get(file_name) { + let content = fs::read_to_string(&migration) + .with_context(|| format!("Failed to read migration {}", migration.display()))?; + let current_hash = compute_content_hash(&content); + if stored_hash.is_empty() || *stored_hash == current_hash { + applied_count += 1; + println!("[applied] {file_name}"); + } else { + drift_count += 1; + println!("[drift] {file_name} (applied hash differs from current file)"); + } + } else { + pending_count += 1; + println!("[pending] {file_name}"); + } + } + + println!(); + println!("Applied: {applied_count}"); + println!("Pending: {pending_count}"); + println!("Drift: {drift_count}"); + Ok(()) +} + +/* ------------------------------ + GENERATORS +------------------------------ */ + +fn generate_resource( + name: &str, + target_module: Option<&str>, + layout: GeneratorLayout, + prompt_for_dto: bool, +) -> Result<()> { + let app_root = detect_app_root()?; + let resource = normalize_resource_name(name); + let singular = singular_name(&resource); + let pascal_plural = to_pascal_case(&resource); + let pascal_singular = to_pascal_case(&singular); + let dto_fields = collect_dto_fields(&pascal_singular, prompt_for_dto)?; + let spinner = start_spinner(format!("Generating resource {}", resource.bold())); + + let target_root = generator_target_root(&app_root, target_module)?; + let imports = resource_import_paths(target_module, layout, &resource, &singular); + + generate_dto_files( + &target_root, + layout, + &resource, + &singular, + &pascal_singular, + &dto_fields, + )?; + generate_service_file( + &target_root, + layout, + &resource, + &singular, + &pascal_plural, + &pascal_singular, + &imports, + )?; + generate_controller_file( + &target_root, + layout, + &resource, + &singular, + &pascal_plural, + &pascal_singular, + &imports, + )?; + + patch_dto_mod(&target_root, layout, &singular, &pascal_singular)?; + patch_services_mod(&target_root, layout, &resource, &pascal_plural)?; + patch_controllers_mod(&target_root, layout, &resource, &pascal_plural)?; + + if let Some(module_name) = target_module { + patch_feature_module(&app_root, module_name, layout, &pascal_plural, true, true)?; + } else { + if layout == GeneratorLayout::Flat { + patch_main_mod_decl(&app_root, &format!("{}_controller", resource))?; + patch_main_mod_decl(&app_root, &format!("{}_service", resource))?; + patch_main_mod_decl(&app_root, &format!("{}_dto", singular))?; + patch_main_mod_decl(&app_root, &format!("create_{}_dto", singular))?; + patch_main_mod_decl(&app_root, &format!("update_{}_dto", singular))?; + } else { + patch_main_mod_decl(&app_root, "controllers")?; + patch_main_mod_decl(&app_root, "services")?; + patch_main_mod_decl(&app_root, "dto")?; + } + patch_app_module(&app_root, layout, &resource, &pascal_plural)?; + } + + spinner.finish_and_clear(); + print_success(format!("Generated resource `{}`", resource)); + Ok(()) +} + +fn generate_controller_only( + name: &str, + target_module: Option<&str>, + layout: GeneratorLayout, +) -> Result<()> { + let app_root = detect_app_root()?; + let resource = normalize_resource_name(name); + let singular = singular_name(&resource); + let pascal_plural = to_pascal_case(&resource); + let pascal_singular = to_pascal_case(&singular); + let target_root = generator_target_root(&app_root, target_module)?; + let imports = resource_import_paths(target_module, layout, &resource, &singular); + + generate_controller_file( + &target_root, + layout, + &resource, + &singular, + &pascal_plural, + &pascal_singular, + &imports, + )?; + patch_controllers_mod(&target_root, layout, &resource, &pascal_plural)?; + if let Some(module_name) = target_module { + patch_feature_module(&app_root, module_name, layout, &pascal_plural, true, false)?; + } else { + if layout == GeneratorLayout::Flat { + patch_main_mod_decl(&app_root, &format!("{}_controller", resource))?; + } else { + patch_main_mod_decl(&app_root, "controllers")?; + } + patch_app_module_controllers_only(&app_root, layout, &resource, &pascal_plural)?; + } + + println!("Generated controller `{}`", resource); + Ok(()) +} + +fn generate_service_only( + name: &str, + target_module: Option<&str>, + layout: GeneratorLayout, +) -> Result<()> { + let app_root = detect_app_root()?; + let resource = normalize_resource_name(name); + let singular = singular_name(&resource); + let pascal_plural = to_pascal_case(&resource); + let pascal_singular = to_pascal_case(&singular); + let target_root = generator_target_root(&app_root, target_module)?; + let imports = resource_import_paths(target_module, layout, &resource, &singular); + + generate_service_file( + &target_root, + layout, + &resource, + &singular, + &pascal_plural, + &pascal_singular, + &imports, + )?; + patch_services_mod(&target_root, layout, &resource, &pascal_plural)?; + if let Some(module_name) = target_module { + patch_feature_module(&app_root, module_name, layout, &pascal_plural, false, true)?; + } else { + if layout == GeneratorLayout::Flat { + patch_main_mod_decl(&app_root, &format!("{}_service", resource))?; + } else { + patch_main_mod_decl(&app_root, "services")?; + } + patch_app_module_providers_only(&app_root, layout, &resource, &pascal_plural)?; + } + + println!("Generated service `{}`", resource); + Ok(()) +} + +fn generate_module(name: &str, layout: GeneratorLayout) -> Result<()> { + let app_root = detect_app_root()?; + let module_name = normalize_resource_name(name); + let pascal_module = format!("{}Module", to_pascal_case(&module_name)); + let module_dir = app_root.join("src").join(&module_name); + let module_file = module_dir.join("mod.rs"); + + if module_file.exists() { + bail!("Module folder already exists: {}", module_dir.display()); + } + + write_file( + &module_dir.join("mod.rs"), + &template_feature_mod_rs(&module_name, &pascal_module, layout), + )?; + if layout == GeneratorLayout::Nested { + write_file( + &module_dir.join("controllers/mod.rs"), + &template_feature_controllers_mod_rs(&module_name, &pascal_module), + )?; + write_file( + &module_dir.join("services/mod.rs"), + &template_feature_services_mod_rs(&module_name, &pascal_module), + )?; + write_file( + &module_dir.join("dto/mod.rs"), + &template_feature_dto_mod_rs(), + )?; + } + + patch_main_mod_decl(&app_root, &module_name)?; + patch_root_app_module_import(&app_root, &module_name, &pascal_module)?; + patch_root_app_module_imports_list(&app_root, &pascal_module)?; + + println!("Generated module `{}`", module_name); + Ok(()) +} + +fn generate_guard_only(name: &str) -> Result<()> { + let app_root = detect_app_root()?; + let guard_name = normalize_resource_name(name); + let pascal_guard = format!("{}Guard", to_pascal_case(&guard_name)); + let guard_file = app_root + .join("src/guards") + .join(format!("{}_guard.rs", guard_name)); + + if guard_file.exists() { + println!("Guard already exists: {}", guard_file.display()); + return Ok(()); + } + + fs::create_dir_all(app_root.join("src/guards"))?; + write_file(&guard_file, &template_guard_rs(&pascal_guard))?; + patch_guards_mod(&app_root, &guard_name, &pascal_guard)?; + patch_main_mod_decl(&app_root, "guards")?; + + println!("Generated guard `{}`", guard_name); + Ok(()) +} + +fn generate_request_decorator_only(name: &str) -> Result<()> { + let app_root = detect_app_root()?; + let decorator_name = normalize_resource_name(name); + let pascal_decorator = to_pascal_case(&decorator_name); + let decorator_file = app_root + .join("src/decorators") + .join(format!("{}_decorator.rs", decorator_name)); + + if decorator_file.exists() { + println!( + "Request decorator already exists: {}", + decorator_file.display() + ); + return Ok(()); + } + + fs::create_dir_all(app_root.join("src/decorators"))?; + write_file( + &decorator_file, + &template_request_decorator_rs(&pascal_decorator), + )?; + patch_decorators_mod(&app_root, &decorator_name, &pascal_decorator)?; + patch_main_mod_decl(&app_root, "decorators")?; + + println!("Generated request decorator `{}`", decorator_name); + Ok(()) +} + +fn generate_exception_filter_only(name: &str) -> Result<()> { + let app_root = detect_app_root()?; + let filter_name = normalize_resource_name(name); + let pascal_filter = format!("{}Filter", to_pascal_case(&filter_name)); + let filter_file = app_root + .join("src/filters") + .join(format!("{}_filter.rs", filter_name)); + + if filter_file.exists() { + println!("Exception filter already exists: {}", filter_file.display()); + return Ok(()); + } + + fs::create_dir_all(app_root.join("src/filters"))?; + write_file(&filter_file, &template_exception_filter_rs(&pascal_filter))?; + patch_filters_mod(&app_root, &filter_name, &pascal_filter)?; + patch_main_mod_decl(&app_root, "filters")?; + + println!("Generated exception filter `{}`", filter_name); + Ok(()) +} + +fn generate_middleware_only(name: &str) -> Result<()> { + let app_root = detect_app_root()?; + let middleware_name = normalize_resource_name(name); + let pascal_middleware = format!("{}Middleware", to_pascal_case(&middleware_name)); + let middleware_file = app_root + .join("src/middleware") + .join(format!("{}_middleware.rs", middleware_name)); + + if middleware_file.exists() { + println!("Middleware already exists: {}", middleware_file.display()); + return Ok(()); + } + + fs::create_dir_all(app_root.join("src/middleware"))?; + write_file( + &middleware_file, + &template_middleware_rs(&pascal_middleware), + )?; + patch_middleware_mod(&app_root, &middleware_name, &pascal_middleware)?; + patch_main_mod_decl(&app_root, "middleware")?; + + println!("Generated middleware `{}`", middleware_name); + Ok(()) +} + +fn generate_interceptor_only(name: &str) -> Result<()> { + let app_root = detect_app_root()?; + let interceptor_name = normalize_resource_name(name); + let pascal_interceptor = format!("{}Interceptor", to_pascal_case(&interceptor_name)); + let interceptor_file = app_root + .join("src/interceptors") + .join(format!("{}_interceptor.rs", interceptor_name)); + + if interceptor_file.exists() { + println!("Interceptor already exists: {}", interceptor_file.display()); + return Ok(()); + } + + fs::create_dir_all(app_root.join("src/interceptors"))?; + write_file( + &interceptor_file, + &template_interceptor_rs(&pascal_interceptor), + )?; + patch_interceptors_mod(&app_root, &interceptor_name, &pascal_interceptor)?; + patch_main_mod_decl(&app_root, "interceptors")?; + + println!("Generated interceptor `{}`", interceptor_name); + Ok(()) +} + +fn generate_serializer_only(name: &str) -> Result<()> { + let app_root = detect_app_root()?; + let serializer_name = normalize_resource_name(name); + let pascal_serializer = format!("{}Serializer", to_pascal_case(&serializer_name)); + let serializer_file = app_root + .join("src/serializers") + .join(format!("{}_serializer.rs", serializer_name)); + + fs::create_dir_all(app_root.join("src/serializers"))?; + + if !serializer_file.exists() { + write_file( + &serializer_file, + &template_serializer_rs(&serializer_name, &pascal_serializer), + )?; + } else { + println!("Serializer already exists: {}", serializer_file.display()); + } + + patch_serializers_mod(&app_root, &serializer_name, &pascal_serializer)?; + patch_main_mod_decl(&app_root, "serializers")?; + + println!("Generated serializer `{}`", serializer_name); + println!("Next: implement `ResponseSerializer<T>` for your domain type"); + Ok(()) +} + +fn generate_websocket_gateway_only(name: &str) -> Result<()> { + let app_root = detect_app_root()?; + let gateway_name = normalize_resource_name(name); + let pascal_gateway = format!("{}Gateway", to_pascal_case(&gateway_name)); + let gateway_file = app_root + .join("src/ws") + .join(format!("{}_gateway.rs", gateway_name)); + + fs::create_dir_all(app_root.join("src/ws"))?; + ensure_ws_mod(&app_root)?; + + if !gateway_file.exists() { + write_file( + &gateway_file, + &template_named_ws_gateway_rs(&pascal_gateway), + )?; + } else { + println!( + "WebSocket gateway already exists: {}", + gateway_file.display() + ); + } + + patch_ws_mod(&app_root, &gateway_name, &pascal_gateway)?; + patch_main_mod_decl(&app_root, "ws")?; + + println!("Generated WebSocket gateway `{}`", gateway_name); + Ok(()) +} + +fn generate_graphql_resolver_only(name: &str) -> Result<()> { + let app_root = detect_app_root()?; + let resolver_name = normalize_resource_name(name); + let pascal_name = to_pascal_case(&resolver_name); + let graphql_dir = app_root.join("src/graphql"); + let resolver_file = graphql_dir.join(format!("{}_resolver.rs", resolver_name)); + + fs::create_dir_all(&graphql_dir)?; + ensure_graphql_mod(&app_root)?; + + if !resolver_file.exists() { + write_file( + &resolver_file, + &template_graphql_resolver_rs(&resolver_name, &pascal_name), + )?; + } else { + println!( + "GraphQL resolver already exists: {}", + resolver_file.display() + ); + } + + patch_graphql_mod(&app_root, &resolver_name, &pascal_name)?; + + println!("Generated GraphQL resolver `{}`", resolver_name); + println!("Next: wire `{pascal_name}Resolver` into src/graphql/schema.rs"); + Ok(()) +} + +fn generate_grpc_service_only(name: &str) -> Result<()> { + let app_root = detect_app_root()?; + let service_name = normalize_resource_name(name); + let pascal_name = to_pascal_case(&service_name); + let proto_path = app_root.join("proto").join(format!("{service_name}.proto")); + let service_path = app_root + .join("src/grpc") + .join(format!("{}_service.rs", service_name)); + + fs::create_dir_all(app_root.join("proto"))?; + fs::create_dir_all(app_root.join("src/grpc"))?; + ensure_grpc_build_rs(&app_root)?; + ensure_grpc_mod(&app_root)?; + + if !proto_path.exists() { + write_file( + &proto_path, + &template_named_grpc_proto(&service_name, &pascal_name), + )?; + } + + if !service_path.exists() { + write_file( + &service_path, + &template_named_grpc_service_rs(&service_name, &pascal_name), + )?; + } else { + println!("gRPC service already exists: {}", service_path.display()); + } + + patch_grpc_build_rs(&app_root, &service_name)?; + patch_grpc_mod_rs(&app_root, &service_name, &pascal_name)?; + + println!("Generated gRPC service `{}`", service_name); + println!( + "Next: mount `{pascal_name}ServiceServer::new({pascal_name}GrpcService::new(ctx))` in src/main.rs" + ); + Ok(()) +} + +fn generate_microservice_patterns_only(name: &str) -> Result<()> { + let app_root = detect_app_root()?; + let pattern_name = normalize_resource_name(name); + let pascal_name = to_pascal_case(&pattern_name); + let patterns_dir = app_root.join("src/microservices"); + let patterns_file = patterns_dir.join(format!("{}_patterns.rs", pattern_name)); + + fs::create_dir_all(&patterns_dir)?; + ensure_microservices_mod(&app_root)?; + + if !patterns_file.exists() { + write_file( + &patterns_file, + &template_microservice_patterns_rs(&pattern_name, &pascal_name), + )?; + } else { + println!( + "Microservice patterns already exist: {}", + patterns_file.display() + ); + } + + patch_microservices_mod(&app_root, &pattern_name, &pascal_name)?; + patch_main_mod_decl(&app_root, "microservices")?; + + println!("Generated microservice patterns `{}`", pattern_name); + println!( + "Next: register `{pascal_name}Patterns::registry()` with your transport adapter or module provider" + ); + println!("Note: enable the `microservices` feature on `nestforge` in Cargo.toml if needed"); + Ok(()) +} + +/* ------------------------------ + FILE GENERATION +------------------------------ */ + +fn generate_dto_files( + target_root: &Path, + layout: GeneratorLayout, + resource: &str, + singular: &str, + pascal_singular: &str, + fields: &[DtoFieldSpec], +) -> Result<()> { + let dto_dir = dto_dir(target_root, layout); + + let entity_file = dto_dir.join(format!("{}_dto.rs", singular)); + let create_file = dto_dir.join(format!("create_{}_dto.rs", singular)); + let update_file = dto_dir.join(format!("update_{}_dto.rs", singular)); + + if !entity_file.exists() { + write_file( + &entity_file, + &template_entity_dto_rs(pascal_singular, fields), + )?; + } + if !create_file.exists() { + write_file( + &create_file, + &template_create_dto_rs(pascal_singular, fields), + )?; + } + if !update_file.exists() { + write_file( + &update_file, + &template_update_dto_rs(pascal_singular, fields), + )?; + } + + let _ = resource; // kept for future template customization + Ok(()) +} + +fn collect_dto_fields(resource_name: &str, prompt_for_dto: bool) -> Result<Vec<DtoFieldSpec>> { + if !prompt_for_dto || !io::stdin().is_terminal() || !io::stdout().is_terminal() { + return Ok(default_dto_fields()); + } + + println!(); + println!("Configure DTO fields for {resource_name}:"); + if !prompt_yes_no("Customize generated DTO fields?", false)? { + return Ok(default_dto_fields()); + } + + let mut fields = Vec::new(); + loop { + let name = prompt_string("Field name (leave empty to finish)", true)?; + if name.is_empty() { + break; + } + + let normalized = normalize_resource_name(&name); + if normalized.is_empty() { + println!("Field name cannot be empty."); + continue; + } + + if fields + .iter() + .any(|field: &DtoFieldSpec| field.name == normalized) + { + println!("Field `{normalized}` already exists."); + continue; + } + + let ty = prompt_field_type()?; + let required = prompt_yes_no("Required in Create DTO?", ty != DtoFieldType::Bool)?; + fields.push(DtoFieldSpec { + name: normalized, + ty, + required, + }); + } + + if fields.is_empty() { + Ok(default_dto_fields()) + } else { + Ok(fields) + } +} + +fn default_dto_fields() -> Vec<DtoFieldSpec> { + vec![DtoFieldSpec { + name: "name".to_string(), + ty: DtoFieldType::String, + required: true, + }] +} + +fn prompt_string(prompt: &str, allow_empty: bool) -> Result<String> { + loop { + print!("{prompt}: "); + io::stdout().flush()?; + let mut input = String::new(); + io::stdin().read_line(&mut input)?; + let value = input.trim().to_string(); + if allow_empty || !value.is_empty() { + return Ok(value); + } + } +} + +fn prompt_yes_no(prompt: &str, default: bool) -> Result<bool> { + let suffix = if default { "[Y/n]" } else { "[y/N]" }; + loop { + print!("{prompt} {suffix}: "); + io::stdout().flush()?; + let mut input = String::new(); + io::stdin().read_line(&mut input)?; + let normalized = input.trim().to_ascii_lowercase(); + if normalized.is_empty() { + return Ok(default); + } + match normalized.as_str() { + "y" | "yes" => return Ok(true), + "n" | "no" => return Ok(false), + _ => println!("Enter `y` or `n`."), + } + } +} + +fn prompt_field_type() -> Result<DtoFieldType> { + println!("Select a field type:"); + for (index, ty) in DtoFieldType::choices().iter().enumerate() { + println!(" {}. {}", index + 1, ty.prompt_label()); + } + + loop { + let value = prompt_string("Type number", false)?; + let Ok(choice) = value.parse::<usize>() else { + println!("Enter a number from the list."); + continue; + }; + + if let Some(ty) = DtoFieldType::choices() + .get(choice.saturating_sub(1)) + .copied() + { + return Ok(ty); + } + + println!("Enter a number from the list."); + } +} + +fn generate_service_file( + target_root: &Path, + layout: GeneratorLayout, + resource: &str, + singular: &str, + pascal_plural: &str, + pascal_singular: &str, + imports: &ResourceImportPaths, +) -> Result<()> { + let service_path = services_dir(target_root, layout).join(format!("{}_service.rs", resource)); + if service_path.exists() { + println!("Service already exists: {}", service_path.display()); + return Ok(()); + } + + write_file( + &service_path, + &template_resource_service_rs(resource, singular, pascal_plural, pascal_singular, imports), + )?; + Ok(()) +} + +fn generate_controller_file( + target_root: &Path, + layout: GeneratorLayout, + resource: &str, + singular: &str, + pascal_plural: &str, + pascal_singular: &str, + imports: &ResourceImportPaths, +) -> Result<()> { + let controller_path = + controllers_dir(target_root, layout).join(format!("{}_controller.rs", resource)); + + if controller_path.exists() { + println!("Controller already exists: {}", controller_path.display()); + return Ok(()); + } + + write_file( + &controller_path, + &template_resource_controller_rs( + resource, + singular, + pascal_plural, + pascal_singular, + imports, + ), + )?; + Ok(()) +} + +/* ------------------------------ + PATCHERS +------------------------------ */ + +fn patch_dto_mod( + target_root: &Path, + layout: GeneratorLayout, + singular: &str, + pascal_singular: &str, +) -> Result<()> { + if layout == GeneratorLayout::Flat { + let Some(path) = target_mod_file(target_root) else { + return Ok(()); + }; + let mut content = fs::read_to_string(&path)?; + + for line in [ + format!("pub mod {}_dto;", singular), + format!("pub mod create_{}_dto;", singular), + format!("pub mod update_{}_dto;", singular), + ] { + if !content.contains(&line) { + content = content.replacen( + "/* nestforge:feature_modules */", + &format!("/* nestforge:feature_modules */\n{line}"), + 1, + ); + } + } + + for line in [ + format!("pub use {}_dto::{}Dto;", singular, pascal_singular), + format!( + "pub use create_{}_dto::Create{}Dto;", + singular, pascal_singular + ), + format!( + "pub use update_{}_dto::Update{}Dto;", + singular, pascal_singular + ), + ] { + if !content.contains(&line) { + content = content.replacen( + "/* nestforge:feature_reexports */", + &format!("/* nestforge:feature_reexports */\n{line}"), + 1, + ); + } + } + + fs::write(path, content)?; + return Ok(()); + } + + let path = target_root.join("dto/mod.rs"); + let mut content = if path.exists() { + fs::read_to_string(&path)? + } else { + template_dto_mod_rs() + }; + + let mod_lines = [ + format!("pub mod {}_dto;", singular), + format!("pub mod create_{}_dto;", singular), + format!("pub mod update_{}_dto;", singular), + ]; + + let use_lines = [ + format!("pub use {}_dto::{}Dto;", singular, pascal_singular), + format!( + "pub use create_{}_dto::Create{}Dto;", + singular, pascal_singular + ), + format!( + "pub use update_{}_dto::Update{}Dto;", + singular, pascal_singular + ), + ]; + + for line in mod_lines { + if !content.contains(&line) { + content.push_str(&format!("\n{}", line)); + } + } + + for line in use_lines { + if !content.contains(&line) { + content.push_str(&format!("\n{}", line)); + } + } + + fs::write(path, content)?; + Ok(()) +} + +fn patch_services_mod( + target_root: &Path, + layout: GeneratorLayout, + resource: &str, + pascal_plural: &str, +) -> Result<()> { + if layout == GeneratorLayout::Flat { + let Some(path) = target_mod_file(target_root) else { + return Ok(()); + }; + let mut content = fs::read_to_string(&path)?; + + for line in [ + format!("pub mod {}_service;", resource), + format!("pub use {}_service::{}Service;", resource, pascal_plural), + ] { + let marker = if line.starts_with("pub mod ") { + "/* nestforge:feature_modules */" + } else { + "/* nestforge:feature_reexports */" + }; + if !content.contains(&line) { + content = content.replacen(marker, &format!("{marker}\n{line}"), 1); + } + } + + fs::write(path, content)?; + return Ok(()); + } + + let path = target_root.join("services/mod.rs"); + let mut content = if path.exists() { + fs::read_to_string(&path)? + } else { + template_services_mod_rs() + }; + + let mod_line = format!("pub mod {}_service;", resource); + let use_line = format!("pub use {}_service::{}Service;", resource, pascal_plural); + + if !content.contains(&mod_line) { + content.push_str(&format!("\n{}", mod_line)); + } + if !content.contains(&use_line) { + content.push_str(&format!("\n{}", use_line)); + } + + fs::write(path, content)?; + Ok(()) +} + +fn patch_controllers_mod( + target_root: &Path, + layout: GeneratorLayout, + resource: &str, + pascal_plural: &str, +) -> Result<()> { + if layout == GeneratorLayout::Flat { + let Some(path) = target_mod_file(target_root) else { + return Ok(()); + }; + let mut content = fs::read_to_string(&path)?; + + for line in [ + format!("pub mod {}_controller;", resource), + format!( + "pub use {}_controller::{}Controller;", + resource, pascal_plural + ), + ] { + let marker = if line.starts_with("pub mod ") { + "/* nestforge:feature_modules */" + } else { + "/* nestforge:feature_reexports */" + }; + if !content.contains(&line) { + content = content.replacen(marker, &format!("{marker}\n{line}"), 1); + } + } + + fs::write(path, content)?; + return Ok(()); + } + + let path = target_root.join("controllers/mod.rs"); + let mut content = if path.exists() { + fs::read_to_string(&path)? + } else { + template_controllers_mod_rs() + }; + + let mod_line = format!("pub mod {}_controller;", resource); + let use_line = format!( + "pub use {}_controller::{}Controller;", + resource, pascal_plural + ); + + if !content.contains(&mod_line) { + content.push_str(&format!("\n{}", mod_line)); + } + if !content.contains(&use_line) { + content.push_str(&format!("\n{}", use_line)); + } + + fs::write(path, content)?; + Ok(()) +} + +fn patch_guards_mod(app_root: &Path, guard_name: &str, pascal_guard: &str) -> Result<()> { + let path = app_root.join("src/guards/mod.rs"); + let mut content = if path.exists() { + fs::read_to_string(&path)? + } else { + template_guards_mod_rs() + }; + + let mod_line = format!("pub mod {}_guard;", guard_name); + let use_line = format!("pub use {}_guard::{};", guard_name, pascal_guard); + + if !content.contains(&mod_line) { + content.push_str(&format!("\n{}", mod_line)); + } + if !content.contains(&use_line) { + content.push_str(&format!("\n{}", use_line)); + } + + fs::write(path, content)?; + Ok(()) +} + +fn patch_decorators_mod( + app_root: &Path, + decorator_name: &str, + pascal_decorator: &str, +) -> Result<()> { + let path = app_root.join("src/decorators/mod.rs"); + let mut content = if path.exists() { + fs::read_to_string(&path)? + } else { + template_decorators_mod_rs() + }; + + let mod_line = format!("pub mod {}_decorator;", decorator_name); + let use_line = format!( + "pub use {}_decorator::{};", + decorator_name, pascal_decorator + ); + + if !content.contains(&mod_line) { + content.push_str(&format!("\n{}", mod_line)); + } + if !content.contains(&use_line) { + content.push_str(&format!("\n{}", use_line)); + } + + fs::write(path, content)?; + Ok(()) +} + +fn patch_interceptors_mod( + app_root: &Path, + interceptor_name: &str, + pascal_interceptor: &str, +) -> Result<()> { + let path = app_root.join("src/interceptors/mod.rs"); + let mut content = if path.exists() { + fs::read_to_string(&path)? + } else { + template_interceptors_mod_rs() + }; + + let mod_line = format!("pub mod {}_interceptor;", interceptor_name); + let use_line = format!( + "pub use {}_interceptor::{};", + interceptor_name, pascal_interceptor + ); + + if !content.contains(&mod_line) { + content.push_str(&format!("\n{}", mod_line)); + } + if !content.contains(&use_line) { + content.push_str(&format!("\n{}", use_line)); + } + + fs::write(path, content)?; + Ok(()) +} + +fn patch_filters_mod(app_root: &Path, filter_name: &str, pascal_filter: &str) -> Result<()> { + let path = app_root.join("src/filters/mod.rs"); + let content = if path.exists() { + fs::read_to_string(&path)? + } else { + template_filters_mod_rs() + }; + let mod_line = format!("pub mod {}_filter;", filter_name); + let use_line = format!("pub use {}_filter::{};", filter_name, pascal_filter); + let mut next = content; + + if !next.contains(&mod_line) { + next.push_str(&format!("\n{}", mod_line)); + } + if !next.contains(&use_line) { + next.push_str(&format!("\n{}", use_line)); + } + + fs::write(path, next)?; + Ok(()) +} + +fn patch_middleware_mod( + app_root: &Path, + middleware_name: &str, + pascal_middleware: &str, +) -> Result<()> { + let path = app_root.join("src/middleware/mod.rs"); + let content = if path.exists() { + fs::read_to_string(&path)? + } else { + template_middleware_mod_rs() + }; + let mod_line = format!("pub mod {}_middleware;", middleware_name); + let use_line = format!( + "pub use {}_middleware::{};", + middleware_name, pascal_middleware + ); + let mut next = content; + + if !next.contains(&mod_line) { + next.push_str(&format!("\n{}", mod_line)); + } + if !next.contains(&use_line) { + next.push_str(&format!("\n{}", use_line)); + } + + fs::write(path, next)?; + Ok(()) +} + +fn patch_serializers_mod( + app_root: &Path, + serializer_name: &str, + pascal_serializer: &str, +) -> Result<()> { + let path = app_root.join("src/serializers/mod.rs"); + let content = if path.exists() { + fs::read_to_string(&path)? + } else { + template_serializers_mod_rs() + }; + let mod_line = format!("pub mod {}_serializer;", serializer_name); + let use_line = format!( + "pub use {}_serializer::{};", + serializer_name, pascal_serializer + ); + let mut next = content; + + if !next.contains(&mod_line) { + next.push_str(&format!("\n{}", mod_line)); + } + if !next.contains(&use_line) { + next.push_str(&format!("\n{}", use_line)); + } + + fs::write(path, next)?; + Ok(()) +} + +fn ensure_graphql_mod(app_root: &Path) -> Result<()> { + let path = app_root.join("src/graphql/mod.rs"); + if !path.exists() { + write_file(&path, "pub mod schema;\n")?; + } + Ok(()) +} + +fn ensure_ws_mod(app_root: &Path) -> Result<()> { + let path = app_root.join("src/ws/mod.rs"); + if !path.exists() { + write_file(&path, &template_ws_mod_rs())?; + } + Ok(()) +} + +fn ensure_microservices_mod(app_root: &Path) -> Result<()> { + let path = app_root.join("src/microservices/mod.rs"); + if !path.exists() { + write_file(&path, &template_microservices_mod_rs())?; + } + Ok(()) +} + +fn patch_graphql_mod(app_root: &Path, resolver_name: &str, pascal_name: &str) -> Result<()> { + let path = app_root.join("src/graphql/mod.rs"); + let mut content = if path.exists() { + fs::read_to_string(&path)? + } else { + "pub mod schema;\n".to_string() + }; + + let mod_line = format!("pub mod {}_resolver;", resolver_name); + let use_line = format!( + "pub use {}_resolver::{}Resolver;", + resolver_name, pascal_name + ); + + if !content.contains(&mod_line) { + content.push_str(&format!("\n{}", mod_line)); + } + if !content.contains(&use_line) { + content.push_str(&format!("\n{}", use_line)); + } + + fs::write(path, content)?; + Ok(()) +} + +fn patch_ws_mod(app_root: &Path, gateway_name: &str, pascal_name: &str) -> Result<()> { + let path = app_root.join("src/ws/mod.rs"); + let mut content = if path.exists() { + fs::read_to_string(&path)? + } else { + template_ws_mod_rs() + }; + + let mod_line = format!("mod {}_gateway;", gateway_name); + let use_line = format!("pub use {}_gateway::{};", gateway_name, pascal_name); + + if !content.contains(&mod_line) { + content.push_str(&format!("\n{}", mod_line)); + } + if !content.contains(&use_line) { + content.push_str(&format!("\n{}", use_line)); + } + + fs::write(path, content)?; + Ok(()) +} + +fn patch_microservices_mod(app_root: &Path, pattern_name: &str, pascal_name: &str) -> Result<()> { + let path = app_root.join("src/microservices/mod.rs"); + let mut content = if path.exists() { + fs::read_to_string(&path)? + } else { + template_microservices_mod_rs() + }; + + let mod_line = format!("pub mod {}_patterns;", pattern_name); + let use_line = format!( + "pub use {}_patterns::{}Patterns;", + pattern_name, pascal_name + ); + + if !content.contains(&mod_line) { + content.push_str(&format!("\n{}", mod_line)); + } + if !content.contains(&use_line) { + content.push_str(&format!("\n{}", use_line)); + } + + fs::write(path, content)?; + Ok(()) +} + +fn ensure_grpc_build_rs(app_root: &Path) -> Result<()> { + let path = app_root.join("build.rs"); + if !path.exists() { + write_file(&path, &template_grpc_build_rs())?; + } + Ok(()) +} + +fn ensure_grpc_mod(app_root: &Path) -> Result<()> { + let path = app_root.join("src/grpc/mod.rs"); + if !path.exists() { + write_file(&path, &template_grpc_mod_rs())?; + } + Ok(()) +} + +fn patch_grpc_build_rs(app_root: &Path, service_name: &str) -> Result<()> { + let path = app_root.join("build.rs"); + let mut content = fs::read_to_string(&path)?; + let proto_entry = format!("\"proto/{service_name}.proto\""); + let rerun_line = + format!(" println!(\"cargo:rerun-if-changed=proto/{service_name}.proto\");\n"); + + if !content.contains(&proto_entry) { + let target = ".compile_protos(&[\"proto/greeter.proto\"], &[\"proto\"])?;"; + if content.contains(target) { + content = content.replace( + target, + &format!( + ".compile_protos(&[\"proto/greeter.proto\", {proto_entry}], &[\"proto\"])?;" + ), + ); + } else if let Some(start) = content.find(".compile_protos(&[") { + if let Some(end_rel) = content[start..].find("], &[\"proto\"])?;") { + let insert_at = start + end_rel; + content.insert_str(insert_at, &format!(", {proto_entry}")); + } + } + } + + if !content.contains(&format!( + "cargo:rerun-if-changed=proto/{service_name}.proto" + )) { + if let Some(insert_at) = content.rfind(" Ok(())") { + content.insert_str(insert_at, &rerun_line); + } + } + + fs::write(path, content)?; + Ok(()) +} + +fn patch_grpc_mod_rs(app_root: &Path, service_name: &str, pascal_name: &str) -> Result<()> { + let path = app_root.join("src/grpc/mod.rs"); + let mut content = fs::read_to_string(&path)?; + let proto_module = format!( + " pub mod {service_name} {{\n nestforge::tonic::include_proto!(\"{service_name}\");\n }}\n" + ); + let service_mod_line = format!("pub mod {}_service;", service_name); + let service_use_line = format!( + "pub use {}_service::{}GrpcService;", + service_name, pascal_name + ); + + if content.contains("nestforge::tonic::include_proto!(\"hello\");") { + content = content.replace( + "pub mod proto {\n nestforge::tonic::include_proto!(\"hello\");\n}\n\npub mod service;\n", + "pub mod proto {\n pub mod hello {\n nestforge::tonic::include_proto!(\"hello\");\n }\n}\n\npub mod service;\n", + ); + } + + if !content.contains(&format!("pub mod {service_name} {{")) { + if let Some(insert_at) = content.find("}\n\npub mod service;") { + content.insert_str(insert_at, &proto_module); + } else if let Some(insert_at) = content.find("}\n") { + content.insert_str(insert_at, &proto_module); + } + } + + if !content.contains(&service_mod_line) { + content.push_str(&format!("\n{}", service_mod_line)); + } + if !content.contains(&service_use_line) { + content.push_str(&format!("\n{}", service_use_line)); + } + + fs::write(path, content)?; + Ok(()) +} + +fn patch_app_module( + app_root: &Path, + layout: GeneratorLayout, + resource: &str, + pascal_plural: &str, +) -> Result<()> { + patch_app_module_controllers_only(app_root, layout, resource, pascal_plural)?; + patch_app_module_providers_only(app_root, layout, resource, pascal_plural)?; + + let _ = resource; + Ok(()) +} + +fn patch_feature_module( + app_root: &Path, + module_name: &str, + layout: GeneratorLayout, + pascal_plural: &str, + include_controller: bool, + include_service: bool, +) -> Result<()> { + if layout == GeneratorLayout::Flat { + return patch_feature_module_flat( + app_root, + module_name, + pascal_plural, + include_controller, + include_service, + ); + } + + let path = app_root.join("src").join(module_name).join("mod.rs"); + let mut content = fs::read_to_string(&path)?; + + let controllers_marker = "/* nestforge:feature_controllers */"; + let providers_marker = "/* nestforge:feature_providers */"; + let exports_marker = "/* nestforge:feature_exports */"; + + let controller_entry = format!("controllers::{}Controller,", pascal_plural); + let provider_entry = format!("services::{}Service,", pascal_plural); + let export_entry = format!("services::{}Service,", pascal_plural); + + let controller_block = format!("{}\n {}", controllers_marker, controller_entry); + let provider_block = format!("{}\n {}", providers_marker, provider_entry); + let export_block = format!("{}\n {}", exports_marker, export_entry); + + if !content.contains(&controller_block) && content.contains(controllers_marker) { + content = content.replace( + controllers_marker, + &format!("{}\n {}", controllers_marker, controller_entry), + ); + } + if !content.contains(&provider_block) && content.contains(providers_marker) { + content = content.replace( + providers_marker, + &format!("{}\n {}", providers_marker, provider_entry), + ); + } + if !content.contains(&export_block) && content.contains(exports_marker) { + content = content.replace( + exports_marker, + &format!("{}\n {}", exports_marker, export_entry), + ); + } + + fs::write(path, content)?; + Ok(()) +} + +fn patch_feature_module_flat( + app_root: &Path, + module_name: &str, + pascal_plural: &str, + include_controller: bool, + include_service: bool, +) -> Result<()> { + let path = app_root.join("src").join(module_name).join("mod.rs"); + let mut content = fs::read_to_string(&path)?; + + if include_controller { + let controller_entry = format!("{pascal_plural}Controller,"); + let controller_block = + format!("/* nestforge:feature_controllers */\n {controller_entry}"); + if !content.contains(&controller_block) { + content = content.replacen( + "/* nestforge:feature_controllers */", + &format!("/* nestforge:feature_controllers */\n {controller_entry}"), + 1, + ); + } + } + + if include_service { + let provider_entry = format!("{pascal_plural}Service,"); + let provider_block = format!("/* nestforge:feature_providers */\n {provider_entry}"); + if !content.contains(&provider_block) { + content = content.replacen( + "/* nestforge:feature_providers */", + &format!("/* nestforge:feature_providers */\n {provider_entry}"), + 1, + ); + } + + let export_entry = format!("{pascal_plural}Service,"); + let export_block = format!("/* nestforge:feature_exports */\n {export_entry}"); + if !content.contains(&export_block) { + content = content.replacen( + "/* nestforge:feature_exports */", + &format!("/* nestforge:feature_exports */\n {export_entry}"), + 1, + ); + } + } + + fs::write(path, content)?; + Ok(()) +} + +fn patch_app_module_controllers_only( + app_root: &Path, + layout: GeneratorLayout, + resource: &str, + pascal_plural: &str, +) -> Result<()> { + let path = app_root.join("src/app_module.rs"); + let mut content = fs::read_to_string(&path)?; + + let marker = "/* nestforge:controllers */"; + let entry = format!("{}Controller,", pascal_plural); + + if content.contains(&entry) { + return Ok(()); + } + + content = content.replace(marker, &format!("{}\n {}", marker, entry)); + let import_line = match layout { + GeneratorLayout::Flat => { + format!( + "use crate::{}_controller::{}Controller;\n", + resource, pascal_plural + ) + } + GeneratorLayout::Nested => { + format!("use crate::controllers::{}Controller;\n", pascal_plural) + } + }; + if !content.contains(&import_line) { + content = format!("{import_line}{content}"); + } + fs::write(path, content)?; + Ok(()) +} + +fn patch_app_module_providers_only( + app_root: &Path, + layout: GeneratorLayout, + resource: &str, + pascal_plural: &str, +) -> Result<()> { + let path = app_root.join("src/app_module.rs"); + let mut content = fs::read_to_string(&path)?; + + let marker = "/* nestforge:providers */"; + let entry = format!("{}Service,", pascal_plural); + + if content.contains(&entry) { + return Ok(()); + } + + content = content.replace(marker, &format!("{}\n {}", marker, entry)); + let import_line = match layout { + GeneratorLayout::Flat => { + format!( + "use crate::{}_service::{}Service;\n", + resource, pascal_plural + ) + } + GeneratorLayout::Nested => { + format!("use crate::services::{}Service;\n", pascal_plural) + } + }; + if !content.contains(&import_line) { + content = format!("{import_line}{content}"); + } + fs::write(path, content)?; + Ok(()) +} + +/* ------------------------------ + TEMPLATE HELPERS +------------------------------ */ + +#[cfg_attr(not(test), allow(dead_code))] +fn parse_new_transport_arg(args: &[String]) -> Result<AppTransport> { + if args.is_empty() { + return Ok(AppTransport::Http); + } + + if args.len() == 2 && args[0] == "--transport" { + return AppTransport::parse(&args[1]); + } + + bail!("Invalid new app options. Use: nestforge new <app-name> --transport <http|graphql|grpc|microservices|websockets>") +} + +fn transport_supports_openapi(transport: AppTransport) -> bool { + matches!(transport, AppTransport::Http | AppTransport::Graphql) +} + +fn resolve_nestforge_dependency_line(transport: AppTransport, enable_openapi: bool) -> String { + let framework_version = env!("CARGO_PKG_VERSION"); + let mut features = match transport { + AppTransport::Http => vec!["\"config\""], + AppTransport::Graphql => vec!["\"config\"", "\"graphql\""], + AppTransport::Grpc => vec!["\"config\"", "\"grpc\""], + AppTransport::Microservices => vec!["\"config\"", "\"microservices\"", "\"testing\""], + AppTransport::Websockets => vec!["\"config\"", "\"websockets\""], + }; + if enable_openapi && transport_supports_openapi(transport) { + features.push("\"openapi\""); + } + let features = features.join(", "); + let local_path = Path::new(env!("CARGO_MANIFEST_DIR")) + .parent() + .map(|p| p.join("nestforge")); + + if let Some(path) = local_path { + if path.exists() { + let normalized = path.to_string_lossy().replace('\\', "/"); + return format!( + "nestforge = {{ path = \"{}\", features = [{}] }}", + normalized, features + ); + } + } + + format!( + "nestforge = {{ version = \"{}\", features = [{}] }}", + framework_version, features + ) +} + +fn template_app_cargo_toml( + app_name: &str, + nestforge_dep: String, + transport: AppTransport, +) -> String { + let package_extra = if matches!(transport, AppTransport::Grpc) { + "build = \"build.rs\"\n" + } else { + "" + }; + + let dependency_lines = match transport { + AppTransport::Http => { + "nestforge-config = \"1\"\naxum = \"0.8\"\ntokio = { version = \"1\", features = [\"full\"] }\nserde = { version = \"1\", features = [\"derive\"] }\nanyhow = \"1\"\n" + } + AppTransport::Graphql => { + "nestforge-config = \"1\"\naxum = \"0.8\"\nasync-graphql = \"7\"\ntokio = { version = \"1\", features = [\"full\"] }\nanyhow = \"1\"\n" + } + AppTransport::Grpc => { + "nestforge-config = \"1\"\naxum = \"0.8\"\ntokio = { version = \"1\", features = [\"full\"] }\nanyhow = \"1\"\ntonic = { version = \"0.12\", features = [\"transport\"] }\nprost = \"0.13\"\n" + } + AppTransport::Microservices => { + "nestforge-config = \"1\"\naxum = \"0.8\"\ntokio = { version = \"1\", features = [\"full\"] }\nanyhow = \"1\"\nserde = { version = \"1\", features = [\"derive\"] }\nserde_json = \"1\"\n" + } + AppTransport::Websockets => { + "nestforge-config = \"1\"\naxum = \"0.8\"\ntokio = { version = \"1\", features = [\"full\"] }\nanyhow = \"1\"\n" + } + }; + + let build_dependencies = if matches!(transport, AppTransport::Grpc) { + "\n[build-dependencies]\ntonic-build = \"0.12\"\nprotoc-bin-vendored = \"3\"\n" + } else { + "" + }; + + format!( + r#"[package] +name = "{app_name}" +version = "0.1.0" +edition = "2021" +{package_extra} + +[workspace] + +[dependencies] +{nestforge_dep} +{dependency_lines}{build_dependencies} +"#, + nestforge_dep = nestforge_dep, + package_extra = package_extra, + dependency_lines = dependency_lines, + build_dependencies = build_dependencies, + ) +} + +fn template_main_rs(app_name: &str, transport: AppTransport, enable_openapi: bool) -> String { + let crate_name = to_snake_case(app_name); + + match transport { + AppTransport::Http => { + let openapi_setup = if enable_openapi { + format!( + " .with_openapi_docs(\"{} API\", \"1.0.0\")?\n", + to_pascal_case(app_name).replace('_', " ") + ) + } else { + String::new() + }; + + format!( + r#"use {crate_name}::AppModule; +use nestforge::prelude::*; + +const PORT: u16 = 3000; + +async fn bootstrap() -> anyhow::Result<()> {{ + NestForgeFactory::<AppModule>::create()? + .with_global_prefix("api"){openapi_setup} + .with_version("v1") + .listen(PORT) + .await +}} + +#[tokio::main] +async fn main() -> anyhow::Result<()> {{ + bootstrap().await +}} +"#, + crate_name = crate_name + ) + } + AppTransport::Graphql => { + let openapi_setup = if enable_openapi { + format!( + "\n .with_openapi_docs(\"{} API\", \"1.0.0\")?", + to_pascal_case(app_name).replace('_', " ") + ) + } else { + String::new() + }; + + format!( + r#"use {crate_name}::{{build_schema, AppConfig, AppModule}}; +use nestforge::prelude::*; + +const PORT: u16 = 3000; + +async fn bootstrap() -> anyhow::Result<()> {{ + let factory = NestForgeFactory::<AppModule>::create()?; + let config = factory.container().resolve::<AppConfig>()?; + let schema = build_schema(config.app_name.clone()); + + factory +{openapi_setup} .with_graphql_config(schema, GraphQlConfig::new("/graphql").with_graphiql("/")) + .listen(PORT) + .await +}} + +#[tokio::main] +async fn main() -> anyhow::Result<()> {{ + bootstrap().await +}} +"#, + crate_name = crate_name + ) + } + AppTransport::Grpc => format!( + r#"use {crate_name}::{{proto::hello::greeter_server::GreeterServer, AppModule, GreeterGrpcService}}; +use nestforge::prelude::*; + +const ADDR: &str = "127.0.0.1:50051"; + +async fn bootstrap() -> anyhow::Result<()> {{ + NestForgeGrpcFactory::<AppModule>::create()? + .with_addr(ADDR) + .listen_with(|ctx, addr| async move {{ + nestforge::tonic::transport::Server::builder() + .add_service(GreeterServer::new(GreeterGrpcService::new(ctx))) + .serve(addr) + .await + }}) + .await +}} + +#[tokio::main] +async fn main() -> anyhow::Result<()> {{ + bootstrap().await +}} +"#, + crate_name = crate_name + ), + AppTransport::Microservices => format!( + r#"use {crate_name}::{{AppModule, AppPatterns}}; +use nestforge::prelude::*; + +#[tokio::main] +async fn main() -> anyhow::Result<()> {{ + let module = TestFactory::<AppModule>::create().build()?; + let patterns = module.resolve::<AppPatterns>()?; + let client = module.microservice_client_with_metadata( + patterns.registry().clone(), + "app-cli", + TransportMetadata::new().insert("source", "scaffold"), + ); + + let response: serde_json::Value = client + .send( + "app.ping", + serde_json::json!({{ + "name": "NestForge" + }}), + ) + .await?; + + println!("{{}}", serde_json::to_string_pretty(&response)?); + module.shutdown()?; + Ok(()) +}} +"#, + crate_name = crate_name + ), + AppTransport::Websockets => format!( + r#"use {crate_name}::{{AppModule, EventsGateway}}; +use nestforge::{{prelude::*, NestForgeFactory, NestForgeFactoryWebSocketExt}}; + +const PORT: u16 = 3000; + +async fn bootstrap() -> anyhow::Result<()> {{ + NestForgeFactory::<AppModule>::create()? + .with_websocket_gateway(EventsGateway) + .listen(PORT) + .await +}} + +#[tokio::main] +async fn main() -> anyhow::Result<()> {{ + bootstrap().await +}} +"#, + crate_name = crate_name + ), + } +} + +fn template_app_lib_rs(transport: AppTransport) -> String { + match transport { + AppTransport::Http => r#"pub mod app_config; +pub mod app_controller; +pub mod app_service; +pub mod app_module; +pub mod guards; +pub mod health_controller; +pub mod interceptors; +/* nestforge:app_modules */ + +pub use app_config::AppConfig; +pub use app_controller::AppController; +pub use app_service::AppService; +pub use app_module::AppModule; +pub use health_controller::HealthController; +/* nestforge:app_reexports */ +"# + .to_string(), + AppTransport::Graphql => r#"pub mod app_config; +pub mod app_module; +pub mod graphql; +/* nestforge:app_modules */ + +pub use app_config::AppConfig; +pub use app_module::AppModule; +pub use graphql::schema::build_schema; +/* nestforge:app_reexports */ +"# + .to_string(), + AppTransport::Grpc => r#"pub mod app_config; +pub mod app_module; +pub mod grpc; +/* nestforge:app_modules */ + +pub use app_config::AppConfig; +pub use app_module::AppModule; +pub use grpc::proto; +pub use grpc::service::GreeterGrpcService; +/* nestforge:app_reexports */ +"# + .to_string(), + AppTransport::Microservices => r#"pub mod app_config; +pub mod app_module; +pub mod microservices; +/* nestforge:app_modules */ + +pub use app_config::AppConfig; +pub use app_module::AppModule; +pub use microservices::AppPatterns; +/* nestforge:app_reexports */ +"# + .to_string(), + AppTransport::Websockets => r#"pub mod app_config; +pub mod app_module; +pub mod ws; +/* nestforge:app_modules */ + +pub use app_config::AppConfig; +pub use app_module::AppModule; +pub use ws::EventsGateway; +/* nestforge:app_reexports */ +"# + .to_string(), + } +} + +fn template_app_module_rs(transport: AppTransport) -> String { + match transport { + AppTransport::Http => r#"use nestforge::prelude::*; + +use crate::{ + app_config::{load_config, AppConfig}, + app_controller::AppController, + app_service::AppService, + health_controller::HealthController, +}; + +#[module( + imports = [ + /* nestforge:imports */ + ], + controllers = [ + AppController, + HealthController, + /* nestforge:controllers */ + ], + providers = [ + AppConfig, + load_config(), + AppService, + /* nestforge:providers */ + ], + exports = [nestforge::ConfigService, AppService] +)] +pub struct AppModule; +"# + .to_string(), + AppTransport::Graphql | AppTransport::Grpc | AppTransport::Websockets => { + r#"use nestforge::prelude::*; + +use crate::app_config::{load_config, AppConfig}; + +#[module( + imports = [], + providers = [AppConfig, load_config()], + exports = [nestforge::ConfigService] +)] +pub struct AppModule; +"# + .to_string() } - AppTransport::Graphql => { - let openapi_setup = if enable_openapi { - format!( - "\n .with_openapi_docs(\"{} API\", \"1.0.0\")?", - to_pascal_case(app_name).replace('_', " ") - ) - } else { - String::new() - }; - - format!( - r#"use {crate_name}::{{build_schema, AppConfig, AppModule}}; -use nestforge::prelude::*; - -const PORT: u16 = 3000; - -async fn bootstrap() -> anyhow::Result<()> {{ - let factory = NestForgeFactory::<AppModule>::create()?; - let config = factory.container().resolve::<AppConfig>()?; - let schema = build_schema(config.app_name.clone()); - - factory -{openapi_setup} .with_graphql_config(schema, GraphQlConfig::new("/graphql").with_graphiql("/")) - .listen(PORT) - .await -}} - -#[tokio::main] -async fn main() -> anyhow::Result<()> {{ - bootstrap().await -}} -"#, - crate_name = crate_name - ) - } - AppTransport::Grpc => format!( - r#"use {crate_name}::{{proto::hello::greeter_server::GreeterServer, AppModule, GreeterGrpcService}}; -use nestforge::prelude::*; - -const ADDR: &str = "127.0.0.1:50051"; - -async fn bootstrap() -> anyhow::Result<()> {{ - NestForgeGrpcFactory::<AppModule>::create()? - .with_addr(ADDR) - .listen_with(|ctx, addr| async move {{ - nestforge::tonic::transport::Server::builder() - .add_service(GreeterServer::new(GreeterGrpcService::new(ctx))) - .serve(addr) - .await - }}) - .await -}} - -#[tokio::main] -async fn main() -> anyhow::Result<()> {{ - bootstrap().await -}} -"#, - crate_name = crate_name - ), - AppTransport::Microservices => format!( - r#"use {crate_name}::{{AppModule, AppPatterns}}; -use nestforge::prelude::*; - -#[tokio::main] -async fn main() -> anyhow::Result<()> {{ - let module = TestFactory::<AppModule>::create().build()?; - let patterns = module.resolve::<AppPatterns>()?; - let client = module.microservice_client_with_metadata( - patterns.registry().clone(), - "app-cli", - TransportMetadata::new().insert("source", "scaffold"), - ); - - let response: serde_json::Value = client - .send( - "app.ping", - serde_json::json!({{ - "name": "NestForge" - }}), - ) - .await?; - - println!("{{}}", serde_json::to_string_pretty(&response)?); - module.shutdown()?; - Ok(()) -}} -"#, - crate_name = crate_name - ), - AppTransport::Websockets => format!( - r#"use {crate_name}::{{AppModule, EventsGateway}}; -use nestforge::{{prelude::*, NestForgeFactory, NestForgeFactoryWebSocketExt}}; - -const PORT: u16 = 3000; - -async fn bootstrap() -> anyhow::Result<()> {{ - NestForgeFactory::<AppModule>::create()? - .with_websocket_gateway(EventsGateway) - .listen(PORT) - .await -}} - -#[tokio::main] -async fn main() -> anyhow::Result<()> {{ - bootstrap().await -}} -"#, - crate_name = crate_name - ), - } -} - -fn template_app_lib_rs(transport: AppTransport) -> String { - match transport { - AppTransport::Http => r#"pub mod app_config; -pub mod app_controller; -pub mod app_service; -pub mod app_module; -pub mod guards; -pub mod health_controller; -pub mod interceptors; -/* nestforge:app_modules */ - -pub use app_config::AppConfig; -pub use app_controller::AppController; -pub use app_service::AppService; -pub use app_module::AppModule; -pub use health_controller::HealthController; -/* nestforge:app_reexports */ -"# - .to_string(), - AppTransport::Graphql => r#"pub mod app_config; -pub mod app_module; -pub mod graphql; -/* nestforge:app_modules */ - -pub use app_config::AppConfig; -pub use app_module::AppModule; -pub use graphql::schema::build_schema; -/* nestforge:app_reexports */ -"# - .to_string(), - AppTransport::Grpc => r#"pub mod app_config; -pub mod app_module; -pub mod grpc; -/* nestforge:app_modules */ - -pub use app_config::AppConfig; -pub use app_module::AppModule; -pub use grpc::proto; -pub use grpc::service::GreeterGrpcService; -/* nestforge:app_reexports */ -"# - .to_string(), - AppTransport::Microservices => r#"pub mod app_config; -pub mod app_module; -pub mod microservices; -/* nestforge:app_modules */ - -pub use app_config::AppConfig; -pub use app_module::AppModule; -pub use microservices::AppPatterns; -/* nestforge:app_reexports */ -"# - .to_string(), - AppTransport::Websockets => r#"pub mod app_config; -pub mod app_module; -pub mod ws; -/* nestforge:app_modules */ - -pub use app_config::AppConfig; -pub use app_module::AppModule; -pub use ws::EventsGateway; -/* nestforge:app_reexports */ -"# - .to_string(), - } -} - -fn template_app_module_rs(transport: AppTransport) -> String { - match transport { - AppTransport::Http => r#"use nestforge::prelude::*; - -use crate::{ - app_config::AppConfig, - app_controller::AppController, - app_service::AppService, - health_controller::HealthController, -}; - -#[module( - imports = [ - /* nestforge:imports */ - ], - controllers = [ - AppController, - HealthController, - /* nestforge:controllers */ - ], - providers = [ - AppConfig, - AppService, - /* nestforge:providers */ - ], - exports = [AppConfig, AppService] -)] -pub struct AppModule; -"# - .to_string(), - AppTransport::Graphql | AppTransport::Grpc | AppTransport::Websockets => { - r#"use nestforge::prelude::*; - -use crate::app_config::AppConfig; - -#[module( - imports = [], - controllers = [], - providers = [AppConfig], - exports = [AppConfig] -)] -pub struct AppModule; -"# - .to_string() - } - AppTransport::Microservices => r#"use nestforge::prelude::*; - -use crate::{ - app_config::AppConfig, - microservices::AppPatterns, -}; - -#[module( - imports = [], - controllers = [], - providers = [AppConfig, AppPatterns], - exports = [AppConfig, AppPatterns] -)] -pub struct AppModule; -"# - .to_string(), - } -} - -fn template_controllers_mod_rs() -> String { - "/* Controller exports get generated here */\n".to_string() -} - -fn template_services_mod_rs() -> String { - "/* Service exports get generated here */\n".to_string() -} - -fn template_guards_mod_rs() -> String { - "/* Guard exports get generated here */\n".to_string() -} - -fn template_decorators_mod_rs() -> String { - "/* Request decorator exports get generated here */\n".to_string() -} - -fn template_middleware_mod_rs() -> String { - "/* Middleware exports get generated here */\n".to_string() -} - -fn template_filters_mod_rs() -> String { - "/* Exception filter exports get generated here */\n".to_string() -} - -fn template_interceptors_mod_rs() -> String { - "/* Interceptor exports get generated here */\n".to_string() -} - -fn template_serializers_mod_rs() -> String { - "/* Serializer exports get generated here */\n".to_string() -} - -fn template_dto_mod_rs() -> String { - "/* DTO exports get generated here */\n".to_string() -} - -fn template_app_controller_rs() -> String { - r#"use nestforge::prelude::*; - -use crate::AppService; - -#[controller("")] -pub struct AppController; - -#[routes] -impl AppController { - #[nestforge::get("/")] - async fn root(service: Inject<AppService>) -> Result<String, HttpException> { - Ok(service.welcome_message()) - } -} -"# - .to_string() -} - -fn template_app_service_rs() -> String { - r#"use nestforge::prelude::*; - -use crate::AppConfig; - -#[injectable(factory = build_app_service)] -pub struct AppService { - app_name: String, -} - -fn build_app_service() -> anyhow::Result<AppService> { - let config = <AppConfig as nestforge::FromEnv>::from_env( - &nestforge::EnvStore::load_with_options( - &nestforge::ConfigOptions::new().env_file(".env"), - )?, - )?; - - Ok(AppService { - app_name: config.app_name, - }) -} - -impl AppService { - pub fn welcome_message(&self) -> String { - format!("Welcome to {}", self.app_name) - } -} -"# - .to_string() -} - -fn template_health_controller_rs() -> String { - r#"use nestforge::prelude::*; - -#[controller("")] -pub struct HealthController; - -#[routes] -impl HealthController { - #[nestforge::get("/health")] - async fn health() -> String { - "OK".to_string() - } -} -"# - .to_string() -} - -fn template_app_config_rs(_transport: AppTransport) -> String { - r#"use nestforge_config::{ConfigService, ConfigModule}; - -pub type AppConfig = ConfigService; - -pub fn load_config() -> AppConfig { - ConfigModule::for_root_with_options(ConfigModule::for_root().env_file(".env")) -} -"# - .to_string() -} - -fn template_graphql_schema_rs() -> String { - r#"use async_graphql::{EmptyMutation, EmptySubscription, Object, Schema}; - -pub type AppSchema = Schema<QueryRoot, EmptyMutation, EmptySubscription>; - -pub fn build_schema(app_name: String) -> AppSchema { - Schema::build(QueryRoot { app_name }, EmptyMutation, EmptySubscription).finish() -} - -pub struct QueryRoot { - app_name: String, -} - -#[Object] -impl QueryRoot { - async fn health(&self) -> &str { - "ok" - } - - async fn app_name(&self) -> &str { - &self.app_name - } -} -"# - .to_string() -} - -fn template_graphql_resolver_rs(resolver_name: &str, pascal_name: &str) -> String { - let field_name = format!("{}_status", resolver_name); - format!( - r#"use async_graphql::Object; - -pub struct {pascal_name}Resolver; - -#[Object] -impl {pascal_name}Resolver {{ - async fn {field_name}(&self) -> &str {{ - "ok" - }} -}} -"# - ) -} - -fn template_grpc_build_rs() -> String { - r#"fn main() -> Result<(), Box<dyn std::error::Error>> { - tonic_build::configure() - .build_client(true) - .build_server(true) - .compile_protos(&["proto/greeter.proto"], &["proto"])?; - - println!("cargo:rerun-if-changed=proto/greeter.proto"); - Ok(()) -} -"# - .to_string() -} - -fn template_grpc_proto() -> String { - r#"syntax = "proto3"; - -package hello; - -service Greeter { - rpc SayHello (HelloRequest) returns (HelloReply); -} - -message HelloRequest { - string name = 1; -} - -message HelloReply { - string message = 1; -} -"# - .to_string() -} - -fn template_named_grpc_proto(service_name: &str, pascal_name: &str) -> String { - format!( - r#"syntax = "proto3"; - -package {service_name}; - -service {pascal_name}Service {{ - rpc Get{pascal_name}Status ({pascal_name}StatusRequest) returns ({pascal_name}StatusReply); -}} - -message {pascal_name}StatusRequest {{ - string name = 1; -}} - -message {pascal_name}StatusReply {{ - string message = 1; -}} -"# - ) -} - -fn template_grpc_mod_rs() -> String { - r#"pub mod proto { - pub mod hello { - nestforge::tonic::include_proto!("hello"); - } -} - -pub mod service; -"# - .to_string() -} - -fn template_grpc_service_rs() -> String { - r#"use nestforge::{ - tonic::{Request, Response, Status}, - GrpcContext, -}; - -use crate::{ - AppConfig, - proto::hello::{greeter_server::Greeter, HelloReply, HelloRequest}, -}; - -#[derive(Clone)] -pub struct GreeterGrpcService { - ctx: GrpcContext, -} - -impl GreeterGrpcService { - pub fn new(ctx: GrpcContext) -> Self { - Self { ctx } - } -} - -#[nestforge::tonic::async_trait] -impl Greeter for GreeterGrpcService { - async fn say_hello( - &self, - request: Request<HelloRequest>, - ) -> Result<Response<HelloReply>, Status> { - let name = request.into_inner().name.trim().to_string(); - if name.is_empty() { - return Err(Status::invalid_argument("name is required")); - } - - let config = self.ctx.resolve::<AppConfig>()?; - Ok(Response::new(HelloReply { - message: format!("Hello, {name}! Welcome to {}.", config.app_name), - })) - } -} -"# - .to_string() -} - -fn template_ws_mod_rs() -> String { - r#"mod events_gateway; - -pub use events_gateway::EventsGateway; -"# - .to_string() -} - -fn template_microservices_app_mod_rs() -> String { - r#"mod app_patterns; - -pub use app_patterns::AppPatterns; -"# - .to_string() -} - -fn template_microservices_app_patterns_rs() -> String { - r#"use nestforge::injectable; - -use crate::AppConfig; - -#[injectable(factory = build_app_patterns)] -pub struct AppPatterns { - registry: nestforge::MicroserviceRegistry, -} - -fn build_app_patterns() -> AppPatterns { - AppPatterns { - registry: nestforge::MicroserviceRegistry::builder() - .message("app.ping", |payload: serde_json::Value, ctx| async move { - let config = ctx.resolve::<AppConfig>()?; - Ok(serde_json::json!({ - "app_name": config.app_name, - "received": payload, - "transport": ctx.transport(), - })) - }) - .build(), - } -} - -impl AppPatterns { - pub fn registry(&self) -> &nestforge::MicroserviceRegistry { - &self.registry - } -} -"# - .to_string() -} - -fn template_microservices_mod_rs() -> String { - "/* Microservice pattern exports get generated here */\n".to_string() -} - -fn template_ws_gateway_rs() -> String { - r#"use crate::AppConfig; -use nestforge::{Message, WebSocket, WebSocketContext, WebSocketGateway}; - -pub struct EventsGateway; - -impl WebSocketGateway for EventsGateway { - fn on_connect( - &self, - ctx: WebSocketContext, - mut socket: WebSocket, - ) -> core::pin::Pin<Box<dyn core::future::Future<Output = ()> + Send>> { - Box::pin(async move { - let app_name = ctx - .resolve::<AppConfig>() - .map(|config| config.app_name.clone()) - .unwrap_or_else(|_| "NestForge WebSockets".to_string()); - - let _ = socket - .send(Message::Text(format!("connected:{app_name}").into())) - .await; - - while let Some(Ok(message)) = socket.recv().await { - match message { - Message::Text(text) => { - let _ = socket - .send(Message::Text(format!("echo:{text}").into())) - .await; - } - Message::Binary(bytes) => { - let _ = socket.send(Message::Binary(bytes)).await; - } - Message::Close(_) => break, - _ => {} - } - } - }) - } -} -"# - .to_string() -} - -fn template_named_grpc_service_rs(service_name: &str, pascal_name: &str) -> String { - let rpc_name = format!("get_{}_status", service_name); - format!( - r#"use nestforge::{{ - tonic::{{Request, Response, Status}}, - GrpcContext, -}}; - -use crate::proto::{service_name}::{{ - {pascal_name}StatusReply, - {pascal_name}StatusRequest, - {service_name}_service_server::{pascal_name}Service, -}}; - -#[derive(Clone)] -pub struct {pascal_name}GrpcService {{ - ctx: GrpcContext, -}} - -impl {pascal_name}GrpcService {{ - pub fn new(ctx: GrpcContext) -> Self {{ - Self {{ ctx }} - }} -}} - -#[nestforge::tonic::async_trait] -impl {pascal_name}Service for {pascal_name}GrpcService {{ - async fn {rpc_name}( - &self, - request: Request<{pascal_name}StatusRequest>, - ) -> Result<Response<{pascal_name}StatusReply>, Status> {{ - let name = request.into_inner().name.trim().to_string(); - let message = if name.is_empty() {{ - format!("{pascal_name} service is ready") - }} else {{ - format!("{pascal_name} service is ready for {{name}}") - }}; - - let _ = self.ctx.container(); - - Ok(Response::new({pascal_name}StatusReply {{ message }})) - }} -}} -"# - ) -} - -fn template_env_file(app_name: &str, transport: AppTransport) -> String { - let transport_note = match transport { - AppTransport::Http => "# Generated for a NestForge HTTP app.\n", - AppTransport::Graphql => "# Generated for a NestForge GraphQL app.\n", - AppTransport::Grpc => "# Generated for a NestForge gRPC app.\n", - AppTransport::Websockets => "# Generated for a NestForge WebSocket app.\n", - AppTransport::Microservices => "# Generated for a NestForge Microservices app.\n", - }; - - format!( - "{transport_note}APP_NAME={}\n# Optional when you add SQL migrations later.\nDATABASE_URL=postgres://<user>:<password>@localhost/<database>\n", - to_pascal_case(app_name).replace('_', " ") - ) -} - -fn template_entity_dto_rs(pascal_singular: &str, fields: &[DtoFieldSpec]) -> String { - let field_lines = fields - .iter() - .map(template_entity_field_line) - .collect::<Vec<_>>() - .join("\n"); - - format!( - r#"#[nestforge::dto] -pub struct {pascal_singular}Dto {{ - pub id: u64, -{field_lines} -}} - -nestforge::impl_identifiable!({pascal_singular}Dto, id); -"# - ) -} - -fn template_create_dto_rs(pascal_singular: &str, fields: &[DtoFieldSpec]) -> String { - let field_lines = fields - .iter() - .map(template_create_field_block) - .collect::<Vec<_>>() - .join("\n"); - - format!( - r#"#[nestforge::dto] -pub struct Create{pascal_singular}Dto {{ -{field_lines} -}} -"# - ) -} - -fn template_update_dto_rs(pascal_singular: &str, fields: &[DtoFieldSpec]) -> String { - let field_lines = fields - .iter() - .map(template_update_field_line) - .collect::<Vec<_>>() - .join("\n"); - - format!( - r#"#[nestforge::dto] -pub struct Update{pascal_singular}Dto {{ -{field_lines} -}} -"# - ) -} - -fn template_entity_field_line(field: &DtoFieldSpec) -> String { - let ty = if field.required { - field.ty.rust_type().to_string() - } else { - format!("Option<{}>", field.ty.rust_type()) - }; - - format!(" pub {}: {},", field.name, ty) -} - -fn template_create_field_block(field: &DtoFieldSpec) -> String { - let mut lines = Vec::new(); - if field.required { - lines.push(" #[validate(required)]".to_string()); - } - let ty = if field.required { - field.ty.rust_type().to_string() - } else { - format!("Option<{}>", field.ty.rust_type()) - }; - lines.push(format!(" pub {}: {},", field.name, ty)); - lines.join("\n") -} - -fn template_update_field_line(field: &DtoFieldSpec) -> String { - format!(" pub {}: Option<{}>,", field.name, field.ty.rust_type()) -} - -fn template_resource_service_rs( - _resource: &str, - _singular: &str, - pascal_plural: &str, - pascal_singular: &str, - imports: &ResourceImportPaths, -) -> String { - format!( - r#"use nestforge::{{injectable, ResourceService}}; - - use {entity_dto_import}::{pascal_singular}Dto; - use {create_dto_import}::Create{pascal_singular}Dto; - use {update_dto_import}::Update{pascal_singular}Dto; - -#[injectable] -#[derive(Default)] -pub struct {pascal_plural}Service {{ - store: ResourceService<{pascal_singular}Dto>, -}} - -impl {pascal_plural}Service {{ - pub fn list(&self) -> Vec<{pascal_singular}Dto> {{ - self.store.all() - }} - - pub fn get(&self, id: u64) -> Option<{pascal_singular}Dto> {{ - self.store.get(id) - }} - - pub fn create(&self, dto: Create{pascal_singular}Dto) -> Result<{pascal_singular}Dto, nestforge::ResourceError> {{ - self.store.create(dto) - }} - - pub fn update(&self, id: u64, dto: Update{pascal_singular}Dto) -> Result<Option<{pascal_singular}Dto>, nestforge::ResourceError> {{ - self.store.update(id, dto) - }} -}} - "#, - entity_dto_import = imports.entity_dto_import, - create_dto_import = imports.create_dto_import, - update_dto_import = imports.update_dto_import - ) -} - -fn template_resource_controller_rs( - resource: &str, - _singular: &str, - pascal_plural: &str, - pascal_singular: &str, - imports: &ResourceImportPaths, -) -> String { - format!( - r#"use axum::Json; -use nestforge::{{controller, routes, ApiResult, Inject, List, OptionHttpExt, Param, ResultHttpExt, ValidatedBody}}; - - use {entity_dto_import}::{pascal_singular}Dto; - use {create_dto_import}::Create{pascal_singular}Dto; - use {update_dto_import}::Update{pascal_singular}Dto; - use {service_import}::{pascal_plural}Service; - -#[controller("/{resource}")] -pub struct {pascal_plural}Controller; - -#[routes] -impl {pascal_plural}Controller {{ - #[nestforge::get("/")] - async fn list( - service: Inject<{pascal_plural}Service>, - ) -> ApiResult<List<{pascal_singular}Dto>> {{ - Ok(Json(service.list())) - }} - - #[nestforge::get("/{{id}}")] - async fn get_one( - id: Param<u64>, - service: Inject<{pascal_plural}Service>, - ) -> ApiResult<{pascal_singular}Dto> {{ - let id = id.value(); - let item = service - .get(id) - .or_not_found_id("{pascal_singular}", id)?; - - Ok(Json(item)) - }} - - #[nestforge::post("/")] - async fn create( - service: Inject<{pascal_plural}Service>, - body: ValidatedBody<Create{pascal_singular}Dto>, - ) -> ApiResult<{pascal_singular}Dto> {{ - let item = service - .create(body.value()) - .or_bad_request()?; - Ok(Json(item)) - }} - - #[nestforge::put("/{{id}}")] - async fn update( - id: Param<u64>, - service: Inject<{pascal_plural}Service>, - body: ValidatedBody<Update{pascal_singular}Dto>, - ) -> ApiResult<{pascal_singular}Dto> {{ - let id = id.value(); - let item = service - .update(id, body.value()) - .or_bad_request()? - .or_not_found_id("{pascal_singular}", id)?; - - Ok(Json(item)) - }} -}} - "#, - resource = resource, - pascal_plural = pascal_plural, - pascal_singular = pascal_singular, - entity_dto_import = imports.entity_dto_import, - create_dto_import = imports.create_dto_import, - update_dto_import = imports.update_dto_import, - service_import = imports.service_import - ) -} - -fn template_guard_rs(pascal_guard: &str) -> String { - format!( - r#"nestforge::guard!({pascal_guard}); -"# - ) -} - -fn template_request_decorator_rs(pascal_decorator: &str) -> String { - format!( - r#"pub struct {pascal_decorator}; - -impl nestforge::RequestDecorator for {pascal_decorator} {{ - type Output = String; - - fn extract( - _ctx: &nestforge::RequestContext, - parts: &axum::http::request::Parts, - ) -> Result<Self::Output, nestforge::HttpException> {{ - parts - .headers - .get("x-{header_name}") - .and_then(|value| value.to_str().ok()) - .map(str::to_string) - .ok_or_else(|| nestforge::HttpException::bad_request("Missing x-{header_name}")) - }} -}} -"#, - header_name = to_snake_case(&pascal_decorator).replace('_', "-") - ) -} - -fn template_middleware_rs(pascal_middleware: &str) -> String { - format!( - r#"nestforge::middleware!({pascal_middleware}, |req, next| {{ - {{ - println!("{{}} {{}}", req.method(), req.uri().path()); - (next)(req).await - }} -}}); -"# - ) -} - -fn template_exception_filter_rs(pascal_filter: &str) -> String { - format!( - r#"#[derive(Default)] -pub struct {pascal_filter}; - -impl nestforge::ExceptionFilter for {pascal_filter} {{ - fn catch( - &self, - exception: nestforge::HttpException, - _ctx: &nestforge::RequestContext, - ) -> nestforge::HttpException {{ - exception - }} -}} -"# - ) -} - -fn template_interceptor_rs(pascal_interceptor: &str) -> String { - format!( - r#"nestforge::interceptor!({pascal_interceptor}); -"# - ) -} - -fn template_serializer_rs(serializer_name: &str, pascal_serializer: &str) -> String { - format!( - r#"#[nestforge::response_dto] -pub struct {pascal_serializer}Dto {{ - pub id: u64, - pub label: String, -}} - -pub struct {pascal_serializer}; - -impl nestforge::ResponseSerializer<serde_json::Value> for {pascal_serializer} {{ - type Output = {pascal_serializer}Dto; - - fn serialize(value: serde_json::Value) -> Self::Output {{ - {pascal_serializer}Dto {{ - id: value.get("id").and_then(|value| value.as_u64()).unwrap_or_default(), - label: value - .get("label") - .and_then(|value| value.as_str()) - .unwrap_or("{serializer_name}") - .to_string(), - }} - }} -}} -"# - ) -} - -fn template_named_ws_gateway_rs(pascal_gateway: &str) -> String { - format!( - r#"use nestforge::{{Message, WebSocket, WebSocketContext, WebSocketGateway}}; - -pub struct {pascal_gateway}; - -impl WebSocketGateway for {pascal_gateway} {{ - fn on_connect( - &self, - _ctx: WebSocketContext, - mut socket: WebSocket, - ) -> core::pin::Pin<Box<dyn core::future::Future<Output = ()> + Send>> {{ - Box::pin(async move {{ - let _ = socket - .send(Message::Text("connected".to_string().into())) - .await; - }}) - }} -}} -"# - ) -} - -fn template_microservice_patterns_rs(pattern_name: &str, pascal_name: &str) -> String { - format!( - r#"use nestforge::{{MicroserviceRegistry, TransportMetadata}}; - -pub struct {pascal_name}Patterns; - -impl {pascal_name}Patterns {{ - pub fn registry() -> MicroserviceRegistry {{ - MicroserviceRegistry::builder() - .message("{pattern_name}.ping", |_payload: (), ctx| async move {{ - Ok(serde_json::json!({{ - "pattern": ctx.pattern(), - "transport": ctx.transport(), - }})) - }}) - .event("{pattern_name}.created", |_payload: serde_json::Value, _ctx| async move {{ - Ok(()) - }}) - .build() - }} - - pub fn metadata() -> TransportMetadata {{ - TransportMetadata::new().insert("module", "{pattern_name}") - }} -}} -"# - ) -} - -/* ------------------------------ - UTILS ------------------------------- */ - -fn write_file(path: &Path, content: &str) -> Result<()> { - if let Some(parent) = path.parent() { - fs::create_dir_all(parent)?; - } - - fs::write(path, content).with_context(|| format!("Failed to write {}", path.display())) -} - -fn detect_app_root() -> Result<PathBuf> { - let cwd = env::current_dir()?; - - /* If user is inside an app folder */ - if cwd.join("src").exists() && cwd.join("Cargo.toml").exists() { - return Ok(cwd); - } - - Err(app_root_not_found()) -} - -fn collect_top_level_modules(main_rs: &Path) -> Result<Vec<String>> { - let content = fs::read_to_string(main_rs) - .with_context(|| format!("Failed to read {}", main_rs.display()))?; - let mut modules = Vec::new(); - - for line in content.lines() { - let trimmed = line.trim(); - if let Some(module_name) = trimmed - .strip_prefix("mod ") - .and_then(|value| value.strip_suffix(';')) - { - modules.push(module_name.trim().to_string()); - } - } - - if !modules - .iter() - .any(|module_name| module_name == "app_module") - { - return Err(missing_app_module_declaration( - &main_rs.display().to_string(), - content, - )); - } - - Ok(modules) -} - -fn resolve_top_level_module_path(app_root: &Path, module_name: &str) -> Result<PathBuf> { - let src_root = app_root.join("src"); - let file = src_root.join(format!("{module_name}.rs")); - if file.exists() { - return Ok(file); - } - - let directory_mod = src_root.join(module_name).join("mod.rs"); - if directory_mod.exists() { - return Ok(directory_mod); - } - - Err(module_file_not_found( - module_name, - &src_root.display().to_string(), - )) -} - -fn relative_path_from(from_file: &Path, to_file: &Path) -> Result<String> { - let from_dir = from_file - .parent() - .context("Temporary export file has no parent directory")?; - let from_components = from_dir.components().collect::<Vec<_>>(); - let to_components = to_file.components().collect::<Vec<_>>(); - let common_len = from_components - .iter() - .zip(&to_components) - .take_while(|(left, right)| left == right) - .count(); - - let mut relative = PathBuf::new(); - for _ in common_len..from_components.len() { - relative.push(".."); - } - for component in &to_components[common_len..] { - relative.push(component.as_os_str()); - } - - Ok(relative.to_string_lossy().replace('\\', "/")) -} - -fn normalize_resource_name(name: &str) -> String { - to_snake_case(name).replace(' ', "_") -} - -#[cfg_attr(not(test), allow(dead_code))] -fn parse_generator_options(args: &[String]) -> Result<GeneratorOptions> { - let mut target_module = None; - let mut layout = GeneratorLayout::Nested; - let mut prompt_for_dto = true; - let mut index = 0usize; - - while index < args.len() { - match args[index].as_str() { - "--module" => { - let Some(value) = args.get(index + 1) else { - bail!( - "Invalid generator options. Use: --module <feature> [--flat] [--no-prompt]" - ); - }; - let module = normalize_resource_name(value); - if module.is_empty() { - bail!("Module name cannot be empty."); - } - target_module = Some(module); - index += 2; - } - "--flat" => { - layout = GeneratorLayout::Flat; - index += 1; - } - "--no-prompt" => { - prompt_for_dto = false; - index += 1; - } - _ => bail!("Invalid generator options. Use: --module <feature> [--flat] [--no-prompt]"), - } - } - - Ok(GeneratorOptions { - target_module, - layout, - prompt_for_dto, - }) -} - -fn generator_target_root(app_root: &Path, target_module: Option<&str>) -> Result<PathBuf> { - if let Some(module_name) = target_module { - let root = app_root.join("src").join(module_name); - if !root.exists() { - bail!( - "Target module `{}` not found. Create it first with: nestforge g module {}", - module_name, - module_name - ); - } - return Ok(root); - } - - Ok(app_root.join("src")) -} - -fn dto_dir(target_root: &Path, layout: GeneratorLayout) -> PathBuf { - match layout { - GeneratorLayout::Nested => target_root.join("dto"), - GeneratorLayout::Flat => target_root.to_path_buf(), - } -} - -fn services_dir(target_root: &Path, layout: GeneratorLayout) -> PathBuf { - match layout { - GeneratorLayout::Nested => target_root.join("services"), - GeneratorLayout::Flat => target_root.to_path_buf(), - } -} - -fn controllers_dir(target_root: &Path, layout: GeneratorLayout) -> PathBuf { - match layout { - GeneratorLayout::Nested => target_root.join("controllers"), - GeneratorLayout::Flat => target_root.to_path_buf(), - } -} - -fn target_mod_file(target_root: &Path) -> Option<PathBuf> { - let path = target_root.join("mod.rs"); - path.exists().then_some(path) -} - -#[derive(Clone, Debug, Eq, PartialEq)] -struct ResourceImportPaths { - entity_dto_import: String, - create_dto_import: String, - update_dto_import: String, - service_import: String, -} - -fn resource_import_paths( - target_module: Option<&str>, - layout: GeneratorLayout, - resource: &str, - singular: &str, -) -> ResourceImportPaths { - match (target_module, layout) { - (Some(module_name), GeneratorLayout::Nested) => ResourceImportPaths { - entity_dto_import: format!("crate::{module_name}::dto"), - create_dto_import: format!("crate::{module_name}::dto"), - update_dto_import: format!("crate::{module_name}::dto"), - service_import: format!("crate::{module_name}::services"), - }, - (Some(module_name), GeneratorLayout::Flat) => ResourceImportPaths { - entity_dto_import: format!("crate::{module_name}"), - create_dto_import: format!("crate::{module_name}"), - update_dto_import: format!("crate::{module_name}"), - service_import: format!("crate::{module_name}"), - }, - (None, GeneratorLayout::Nested) => ResourceImportPaths { - entity_dto_import: "crate::dto".to_string(), - create_dto_import: "crate::dto".to_string(), - update_dto_import: "crate::dto".to_string(), - service_import: "crate::services".to_string(), - }, - (None, GeneratorLayout::Flat) => ResourceImportPaths { - entity_dto_import: format!("crate::{}_dto", singular), - create_dto_import: format!("crate::create_{}_dto", singular), - update_dto_import: format!("crate::update_{}_dto", singular), - service_import: format!("crate::{}_service", resource), - }, - } -} - -fn singular_name(resource: &str) -> String { - if resource.ends_with("ies") && resource.len() > 3 { - format!("{}y", &resource[..resource.len() - 3]) - } else if resource.ends_with('s') && resource.len() > 1 { - resource[..resource.len() - 1].to_string() - } else { - resource.to_string() - } -} - -fn to_pascal_case(input: &str) -> String { - input - .split(['_', '-', ' ']) - .filter(|s| !s.is_empty()) - .map(|part| { - let mut chars = part.chars(); - match chars.next() { - Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(), - std::option::Option::None => String::new(), - } - }) - .collect::<String>() -} - -fn to_snake_case(input: &str) -> String { - let mut out = String::new(); - - for (i, ch) in input.chars().enumerate() { - if ch.is_uppercase() { - if i != 0 { - out.push('_'); - } - for c in ch.to_lowercase() { - out.push(c); - } - } else if ch == '-' || ch == ' ' { - out.push('_'); - } else { - out.push(ch); - } - } - - out -} - -fn template_feature_mod_rs( - module_name: &str, - pascal_module: &str, - layout: GeneratorLayout, -) -> String { - match layout { - GeneratorLayout::Nested => format!( - r#"pub mod controllers; -pub mod dto; -pub mod services; - -use nestforge::module; - -#[module( - imports = [], - controllers = [ - /* nestforge:feature_controllers */ - ], - providers = [ - /* nestforge:feature_providers */ - ], - exports = [ - /* nestforge:feature_exports */ - ] -)] -pub struct {pascal_module}; - -// Feature module: {module_name} -"# - ), - GeneratorLayout::Flat => format!( - r#"/* nestforge:feature_modules */ - -/* nestforge:feature_reexports */ - -use nestforge::module; - -#[module( - imports = [], - controllers = [ - /* nestforge:feature_controllers */ - ], - providers = [ - /* nestforge:feature_providers */ - ], - exports = [ - /* nestforge:feature_exports */ - ] -)] -pub struct {pascal_module}; - -// Feature module: {module_name} -"# - ), - } -} - -fn template_feature_controllers_mod_rs(_module_name: &str, _pascal_module: &str) -> String { - template_controllers_mod_rs() -} - -fn template_feature_services_mod_rs(_module_name: &str, _pascal_module: &str) -> String { - template_services_mod_rs() -} - -fn template_feature_dto_mod_rs() -> String { - "/* feature dto exports */\n".to_string() -} - -fn patch_root_app_module_import( - app_root: &Path, - module_name: &str, - pascal_module: &str, -) -> Result<()> { - let path = app_root.join("src/app_module.rs"); - let mut content = fs::read_to_string(&path)?; - let import_line = format!("use crate::{}::{};\n", module_name, pascal_module); - - if !content.contains(&import_line) { - content = format!("{import_line}{content}"); - fs::write(path, content)?; - } - - Ok(()) -} - -fn patch_root_app_module_imports_list(app_root: &Path, pascal_module: &str) -> Result<()> { - let path = app_root.join("src/app_module.rs"); - let mut content = fs::read_to_string(&path)?; - let marker = "/* nestforge:imports */"; - let entry = format!("{pascal_module},"); - - if content.contains(&entry) { - return Ok(()); - } - - if content.contains(marker) { - content = content.replace(marker, &format!("{marker}\n {entry}")); - fs::write(path, content)?; - return Ok(()); - } - - if let Some(start) = content.find("imports = [") { - let segment = &content[start..]; - if let Some(close_rel) = segment.find(']') { - let close_idx = start + close_rel; - content.insert_str(close_idx, &format!("\n {entry}\n ")); - fs::write(path, content)?; - } - } - - Ok(()) -} - -fn patch_main_mod_decl(app_root: &Path, module_name: &str) -> Result<()> { - let lib_path = app_root.join("src/lib.rs"); - if lib_path.exists() { - let mut content = fs::read_to_string(&lib_path)?; - let decl = format!("pub mod {};", module_name); - let reexport = format!("pub use {}::*;", module_name); - - if !content.contains(&decl) { - if content.contains("/* nestforge:app_modules */") { - content = content.replace( - "/* nestforge:app_modules */", - &format!("/* nestforge:app_modules */\n{decl}"), - ); - } else { - content.push_str(&format!("\n{decl}")); - } - } - - if !content.contains(&reexport) { - if content.contains("/* nestforge:app_reexports */") { - content = content.replace( - "/* nestforge:app_reexports */", - &format!("/* nestforge:app_reexports */\n{reexport}"), - ); - } else { - content.push_str(&format!("\n{reexport}")); - } - } - - fs::write(lib_path, content)?; - return Ok(()); - } - - let path = app_root.join("src/main.rs"); - let mut content = fs::read_to_string(&path)?; - let decl = format!("mod {};", module_name); - if content.contains(&decl) { - return Ok(()); - } - - if let Some(idx) = content.find("mod app_module;") { - let insert_at = idx + "mod app_module;".len(); - content.insert_str(insert_at, &format!("\n{decl}")); - fs::write(path, content)?; - } - - Ok(()) -} - -fn current_unix_timestamp() -> Result<u64> { - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .context("System clock is before Unix epoch")?; - Ok(now.as_secs()) -} - -fn nestforge_dir(app_root: &Path) -> PathBuf { - app_root.join(".nestforge") -} - -fn migrations_dir(app_root: &Path) -> PathBuf { - app_root.join("migrations") -} - -fn applied_migrations_file(app_root: &Path) -> PathBuf { - nestforge_dir(app_root).join("applied_migrations.txt") -} - -fn list_migration_files(app_root: &Path) -> Result<Vec<PathBuf>> { - let mut files = Vec::new(); - let dir = migrations_dir(app_root); - - if !dir.exists() { - return Ok(files); - } - - for entry in fs::read_dir(&dir).with_context(|| format!("Failed to read {}", dir.display()))? { - let entry = entry?; - let path = entry.path(); - let is_sql = path - .extension() - .and_then(|ext| ext.to_str()) - .map(|ext| ext.eq_ignore_ascii_case("sql")) - .unwrap_or(false); - - if is_sql { - files.push(path); - } - } - - files.sort(); - Ok(files) -} - -fn read_applied_migrations(app_root: &Path) -> Result<HashMap<String, String>> { - let file = applied_migrations_file(app_root); - if !file.exists() { - return Ok(HashMap::new()); - } - - let content = fs::read_to_string(&file)?; - let mut map = HashMap::new(); - for line in content - .lines() - .map(str::trim) - .filter(|line| !line.is_empty()) - { - if let Some((name, hash)) = line.split_once('|') { - map.insert(name.to_string(), hash.to_string()); - } else { - map.insert(line.to_string(), String::new()); - } - } - Ok(map) -} - -fn append_applied_migration(app_root: &Path, migration_file_name: &str, hash: &str) -> Result<()> { - let file = applied_migrations_file(app_root); - let mut open = fs::OpenOptions::new() - .create(true) - .append(true) - .open(&file) - .with_context(|| format!("Failed to open {}", file.display()))?; - writeln!(open, "{migration_file_name}|{hash}")?; - Ok(()) -} - -fn resolve_database_url(app_root: &Path) -> Result<String> { - if let Ok(url) = env::var("DATABASE_URL") { - if !url.trim().is_empty() { - return Ok(url); - } - } - - let env_path = app_root.join(".env"); - if env_path.exists() { - let content = fs::read_to_string(&env_path)?; - for line in content.lines() { - let trimmed = line.trim(); - if trimmed.is_empty() || trimmed.starts_with('#') { - continue; - } - - if let Some(value) = trimmed.strip_prefix("DATABASE_URL=") { - let clean = value.trim().trim_matches('"').trim_matches('\''); - if !clean.is_empty() { - return Ok(clean.to_string()); - } - } - } - } - - bail!( - "DATABASE_URL not found. Set it in environment or add it to {}", - env_path_or_default(app_root).display() - ) -} - -fn env_path_or_default(app_root: &Path) -> PathBuf { - app_root.join(".env") -} - -fn compute_content_hash(content: &str) -> String { - use sha2::{Digest, Sha256}; - - let mut hasher = Sha256::new(); - hasher.update(content.as_bytes()); - format!("{:x}", hasher.finalize()) -} - -fn contains_sql_content(sql: &str) -> bool { - let mut chars = sql.chars().peekable(); - let mut in_line_comment = false; - let mut in_block_comment = false; - - while let Some(ch) = chars.next() { - if in_line_comment { - if ch == '\n' { - in_line_comment = false; - } - continue; - } - - if in_block_comment { - if ch == '*' && chars.peek() == Some(&'/') { - let _ = chars.next(); - in_block_comment = false; - } - continue; - } - - if ch == '-' && chars.peek() == Some(&'-') { - let _ = chars.next(); - in_line_comment = true; - continue; - } - - if ch == '/' && chars.peek() == Some(&'*') { - let _ = chars.next(); - in_block_comment = true; - continue; - } - - if !ch.is_whitespace() { - return true; - } - } - - false -} - -#[cfg(test)] -mod tests { - use std::path::PathBuf; - - use super::{ - compute_content_hash, contains_sql_content, parse_export_docs_options, - parse_generator_options, parse_new_transport_arg, template_create_dto_rs, - template_exception_filter_rs, template_feature_mod_rs, template_microservice_patterns_rs, - template_named_ws_gateway_rs, template_request_decorator_rs, template_serializer_rs, - AppTransport, DtoFieldSpec, DtoFieldType, ExportDocsOptions, GeneratorLayout, - GeneratorOptions, - }; - - #[test] - fn contains_sql_content_ignores_comment_only_input() { - let sql = "-- heading\n/* block comment */\n"; - - assert!(!contains_sql_content(sql)); - } - - #[test] - fn contains_sql_content_detects_real_sql_after_comments() { - let sql = "-- create users table\nCREATE TABLE users (id INT);\n"; - - assert!(contains_sql_content(sql)); - } - - #[test] - fn compute_content_hash_is_stable() { - let first = compute_content_hash("select 1;"); - let second = compute_content_hash("select 1;"); - - assert_eq!(first, second); - assert_eq!(first.len(), 64); - } - - #[test] - fn parse_new_transport_defaults_to_http() { - let args: Vec<String> = Vec::new(); - - assert!(matches!( - parse_new_transport_arg(&args).expect("transport should parse"), - AppTransport::Http - )); - } - - #[test] - fn parse_new_transport_accepts_graphql() { - let args = vec!["--transport".to_string(), "graphql".to_string()]; - - assert!(matches!( - parse_new_transport_arg(&args).expect("transport should parse"), - AppTransport::Graphql - )); - } - - #[test] - fn parse_new_transport_accepts_microservices() { - let args = vec!["--transport".to_string(), "microservices".to_string()]; - - assert!(matches!( - parse_new_transport_arg(&args).expect("transport should parse"), - AppTransport::Microservices - )); - } - - #[test] - fn parse_new_transport_accepts_websockets() { - let args = vec!["--transport".to_string(), "websockets".to_string()]; - - assert!(matches!( - parse_new_transport_arg(&args).expect("transport should parse"), - AppTransport::Websockets - )); - } - - #[test] - fn template_app_cargo_toml_declares_workspace_root() { - let manifest = super::template_app_cargo_toml( - "demo-app", - "nestforge = { version = \"1.2.1\", features = [\"config\"] }".to_string(), - AppTransport::Http, - ); - - assert!(manifest.contains("\n[workspace]\n")); - } - - #[test] - fn resolve_nestforge_dependency_line_adds_openapi_for_http_apps() { - let dependency = super::resolve_nestforge_dependency_line(AppTransport::Http, true); - - assert!(dependency.contains("\"openapi\"")); - } - - #[test] - fn template_main_rs_wires_openapi_when_requested() { - let main_rs = super::template_main_rs("marknon", AppTransport::Http, true); - - assert!(main_rs.contains("use nestforge::prelude::*;")); - assert!(main_rs.contains(".with_openapi_docs(\"Marknon API\", \"1.0.0\")?")); - } - - #[test] - fn template_main_rs_generates_valid_microservices_json_macro() { - let main_rs = super::template_main_rs("professor", AppTransport::Microservices, false); - - assert!(main_rs.contains("serde_json::json!({")); - assert!(!main_rs.contains("serde_json::json!({{")); - } - - #[test] - fn template_main_rs_declares_root_app_service_for_http_apps() { - let main_rs = super::template_main_rs("demo-api", AppTransport::Http, false); - - assert!(main_rs.contains("use demo_api::AppModule;")); - assert!(!main_rs.contains("mod app_service;")); - } - - #[test] - fn template_app_lib_rs_reexports_root_http_symbols() { - let lib_rs = super::template_app_lib_rs(AppTransport::Http); - - assert!(lib_rs.contains("pub mod app_service;")); - assert!(lib_rs.contains("pub use app_module::AppModule;")); - assert!(lib_rs.contains("/* nestforge:app_modules */")); - assert!(lib_rs.contains("/* nestforge:app_reexports */")); - } - - #[test] - fn template_graphql_schema_imports_async_graphql_crate_path() { - let schema = super::template_graphql_schema_rs(); - - assert!(schema - .contains("use async_graphql::{EmptyMutation, EmptySubscription, Object, Schema};")); - } - - #[test] - fn template_app_cargo_toml_includes_async_graphql_for_graphql_apps() { - let manifest = super::template_app_cargo_toml( - "demo-api", - super::resolve_nestforge_dependency_line(AppTransport::Graphql, false), - AppTransport::Graphql, - ); - - assert!(manifest.contains("async-graphql = \"7\"")); - } - - #[test] - fn template_app_module_registers_root_app_service_for_http_apps() { - let module_rs = super::template_app_module_rs(AppTransport::Http); - - assert!(module_rs.contains("app_service::AppService")); - assert!(module_rs.contains("providers = [")); - assert!(module_rs.contains("AppConfig,")); - assert!(module_rs.contains("AppService,")); - assert!(module_rs.contains("exports = [AppConfig, AppService]")); - } - - #[test] - fn template_app_cargo_toml_includes_axum_for_microservices() { - let manifest = super::template_app_cargo_toml( - "demo-app", - "nestforge = { version = \"1.2.1\", features = [\"config\", \"microservices\"] }" - .to_string(), - AppTransport::Microservices, - ); - - assert!(manifest.contains("axum = \"0.8\"")); - } - - #[test] - fn parse_generator_options_supports_flat_module_generation() { - let options = parse_generator_options(&[ - "--module".to_string(), - "users".to_string(), - "--flat".to_string(), - ]) - .expect("generator options should parse"); - - assert_eq!( - options, - GeneratorOptions { - target_module: Some("users".to_string()), - layout: GeneratorLayout::Flat, - prompt_for_dto: true, - } - ); - } - - #[test] - fn parse_generator_options_supports_no_prompt() { - let options = parse_generator_options(&["--no-prompt".to_string()]) - .expect("generator options should parse"); - - assert_eq!( - options, - GeneratorOptions { - target_module: None, - layout: GeneratorLayout::Nested, - prompt_for_dto: false, - } - ); - } - - #[test] - fn parse_export_docs_options_supports_custom_output() { - let options = parse_export_docs_options(&[ - "--format".to_string(), - "yaml".to_string(), - "--output".to_string(), - "docs/openapi.yaml".to_string(), - "--title".to_string(), - "Users API".to_string(), - "--version".to_string(), - "2.0.0".to_string(), - ]) - .expect("export options should parse"); - - assert_eq!( - options, - ExportDocsOptions { - format: "yaml".to_string(), - output: Some(PathBuf::from("docs/openapi.yaml")), - title: "Users API".to_string(), - version: "2.0.0".to_string(), - module_type: "AppModule".to_string(), - } - ); - } - - #[test] - fn template_create_dto_uses_custom_field_specs() { - let template = template_create_dto_rs( - "User", - &[ - DtoFieldSpec { - name: "email".to_string(), - ty: DtoFieldType::String, - required: true, - }, - DtoFieldSpec { - name: "age".to_string(), - ty: DtoFieldType::U32, - required: false, - }, - ], - ); - - assert!(template.contains("#[validate(required)]")); - assert!(template.contains("pub email: String,")); - assert!(template.contains("pub age: Option<u32>,")); - } - - #[test] - fn flat_feature_module_template_exposes_root_level_exports() { - let template = template_feature_mod_rs("users", "UsersModule", GeneratorLayout::Flat); - - assert!(template.contains("/* nestforge:feature_modules */")); - assert!(template.contains("/* nestforge:feature_reexports */")); - assert!(!template.contains("pub mod controller;")); - assert!(!template.contains("pub mod service;")); - assert!(!template.contains("Controller,")); - assert!(!template.contains("Service,")); - assert!(!template.contains("pub mod controllers;")); - } - - #[test] - fn nested_feature_module_template_starts_without_placeholder_imports() { - let template = template_feature_mod_rs("users", "UsersModule", GeneratorLayout::Nested); - - assert!(template.contains("pub mod controllers;")); - assert!(template.contains("pub mod services;")); - assert!(template.contains("pub mod dto;")); - assert!(!template.contains("use self::controllers")); - assert!(!template.contains("use self::services")); - assert!(!template.contains("Controller,")); - assert!(!template.contains("Service,")); - } - - #[test] - fn template_microservice_patterns_uses_requested_type_name() { - let template = template_microservice_patterns_rs("users", "Users"); - - assert!(template.contains("pub struct UsersPatterns;")); - assert!(template.contains(".message(\"users.ping\"")); - assert!(template.contains(".event(\"users.created\"")); - } - - #[test] - fn template_request_decorator_uses_requested_type_name() { - let template = template_request_decorator_rs("CorrelationId"); - - assert!(template.contains("pub struct CorrelationId;")); - assert!(template.contains("impl nestforge::RequestDecorator for CorrelationId")); - assert!(template.contains("x-correlation-id")); - } - - #[test] - fn template_serializer_uses_requested_type_name() { - let template = template_serializer_rs("user", "UserSerializer"); - - assert!(template.contains("pub struct UserSerializerDto")); - assert!(template.contains("pub struct UserSerializer;")); - assert!(template - .contains("impl nestforge::ResponseSerializer<serde_json::Value> for UserSerializer")); - } - - #[test] - fn template_named_ws_gateway_uses_requested_type_name() { - let template = template_named_ws_gateway_rs("EventsGateway"); - - assert!(template.contains("pub struct EventsGateway;")); - assert!(template.contains("impl WebSocketGateway for EventsGateway")); - } - - #[test] - fn template_exception_filter_uses_requested_type_name() { - let template = template_exception_filter_rs("RewriteBadRequestFilter"); - - assert!(template.contains("pub struct RewriteBadRequestFilter;")); - assert!(template.contains("impl nestforge::ExceptionFilter for RewriteBadRequestFilter")); - } - - #[test] - fn cli_docs_cover_generation_workflow() { - let docs = super::render_docs_plaintext(None); - - assert!(docs.contains("nestforge new my-app --transport http --no-tui")); - assert!(docs.contains("nestforge g module users")); - assert!(docs.contains("nestforge g resource users --module users")); - assert!(docs.contains("nestforge export-docs --format yaml")); - assert!(docs.contains("nestforge db migrate")); - } -} + AppTransport::Microservices => r#"use nestforge::prelude::*; + +use crate::{ + app_config::{load_config, AppConfig}, + microservices::AppPatterns, +}; + +#[module( + imports = [], + controllers = [], + providers = [AppConfig, load_config(), AppPatterns], + exports = [nestforge::ConfigService, AppPatterns] +)] +pub struct AppModule; +"# + .to_string(), + } +} + +fn template_controllers_mod_rs() -> String { + "/* Controller exports get generated here */\n".to_string() +} + +fn template_services_mod_rs() -> String { + "/* Service exports get generated here */\n".to_string() +} + +fn template_guards_mod_rs() -> String { + "/* Guard exports get generated here */\n".to_string() +} + +fn template_decorators_mod_rs() -> String { + "/* Request decorator exports get generated here */\n".to_string() +} + +fn template_middleware_mod_rs() -> String { + "/* Middleware exports get generated here */\n".to_string() +} + +fn template_filters_mod_rs() -> String { + "/* Exception filter exports get generated here */\n".to_string() +} + +fn template_interceptors_mod_rs() -> String { + "/* Interceptor exports get generated here */\n".to_string() +} + +fn template_serializers_mod_rs() -> String { + "/* Serializer exports get generated here */\n".to_string() +} + +fn template_dto_mod_rs() -> String { + "/* DTO exports get generated here */\n".to_string() +} + +fn template_app_controller_rs() -> String { + r#"use nestforge::prelude::*; + +use crate::AppService; + +#[controller("")] +pub struct AppController; + +#[routes] +impl AppController { + #[nestforge::get("/")] + async fn root(service: Inject<AppService>) -> Result<String, HttpException> { + Ok(service.welcome_message()) + } +} +"# + .to_string() +} + +fn template_app_service_rs() -> String { + r#"use nestforge::prelude::*; +use nestforge::ConfigService; + +use crate::app_config::load_config; + +#[injectable(factory = build_app_service)] +pub struct AppService { + app_name: String, +} + +fn build_app_service() -> anyhow::Result<AppService> { + let config = load_config(); + + Ok(AppService { + app_name: config.get_string_or("APP_NAME", "NestForge App"), + }) +} + +impl AppService { + pub fn welcome_message(&self) -> String { + format!("Welcome to {}", self.app_name) + } +} +"# + .to_string() +} + +fn template_health_controller_rs() -> String { + r#"use nestforge::prelude::*; + +#[controller("")] +pub struct HealthController; + +#[routes] +impl HealthController { + #[nestforge::get("/health")] + async fn health() -> String { + "OK".to_string() + } +} +"# + .to_string() +} + +fn template_app_config_rs(_transport: AppTransport) -> String { + r#"use nestforge::{ConfigModule, ConfigOptions, ConfigService}; + +pub fn load_config() -> ConfigService { + ConfigModule::for_root_with_options(ConfigOptions::new().env_file(".env")) +} +"# + .to_string() +} + +fn template_graphql_schema_rs() -> String { + r#"use async_graphql::{EmptyMutation, EmptySubscription, Object, Schema}; + +pub type AppSchema = Schema<QueryRoot, EmptyMutation, EmptySubscription>; + +pub fn build_schema(app_name: String) -> AppSchema { + Schema::build(QueryRoot { app_name }, EmptyMutation, EmptySubscription).finish() +} + +pub struct QueryRoot { + app_name: String, +} + +#[Object] +impl QueryRoot { + async fn health(&self) -> &str { + "ok" + } + + async fn app_name(&self) -> &str { + &self.app_name + } +} +"# + .to_string() +} + +fn template_graphql_resolver_rs(resolver_name: &str, pascal_name: &str) -> String { + let field_name = format!("{}_status", resolver_name); + format!( + r#"use async_graphql::Object; + +pub struct {pascal_name}Resolver; + +#[Object] +impl {pascal_name}Resolver {{ + async fn {field_name}(&self) -> &str {{ + "ok" + }} +}} +"# + ) +} + +fn template_grpc_build_rs() -> String { + r#"fn main() -> Result<(), Box<dyn std::error::Error>> { + tonic_build::configure() + .build_client(true) + .build_server(true) + .compile_protos(&["proto/greeter.proto"], &["proto"])?; + + println!("cargo:rerun-if-changed=proto/greeter.proto"); + Ok(()) +} +"# + .to_string() +} + +fn template_grpc_proto() -> String { + r#"syntax = "proto3"; + +package hello; + +service Greeter { + rpc SayHello (HelloRequest) returns (HelloReply); +} + +message HelloRequest { + string name = 1; +} + +message HelloReply { + string message = 1; +} +"# + .to_string() +} + +fn template_named_grpc_proto(service_name: &str, pascal_name: &str) -> String { + format!( + r#"syntax = "proto3"; + +package {service_name}; + +service {pascal_name}Service {{ + rpc Get{pascal_name}Status ({pascal_name}StatusRequest) returns ({pascal_name}StatusReply); +}} + +message {pascal_name}StatusRequest {{ + string name = 1; +}} + +message {pascal_name}StatusReply {{ + string message = 1; +}} +"# + ) +} + +fn template_grpc_mod_rs() -> String { + r#"pub mod proto { + pub mod hello { + nestforge::tonic::include_proto!("hello"); + } +} + +pub mod service; +"# + .to_string() +} + +fn template_grpc_service_rs() -> String { + r#"use nestforge::{ + tonic::{Request, Response, Status}, + GrpcContext, +}; + +use crate::{ + AppConfig, + proto::hello::{greeter_server::Greeter, HelloReply, HelloRequest}, +}; + +#[derive(Clone)] +pub struct GreeterGrpcService { + ctx: GrpcContext, +} + +impl GreeterGrpcService { + pub fn new(ctx: GrpcContext) -> Self { + Self { ctx } + } +} + +#[nestforge::tonic::async_trait] +impl Greeter for GreeterGrpcService { + async fn say_hello( + &self, + request: Request<HelloRequest>, + ) -> Result<Response<HelloReply>, Status> { + let name = request.into_inner().name.trim().to_string(); + if name.is_empty() { + return Err(Status::invalid_argument("name is required")); + } + + let config = self.ctx.resolve::<AppConfig>()?; + Ok(Response::new(HelloReply { + message: format!("Hello, {name}! Welcome to {}.", config.app_name), + })) + } +} +"# + .to_string() +} + +fn template_ws_mod_rs() -> String { + r#"mod events_gateway; + +pub use events_gateway::EventsGateway; +"# + .to_string() +} + +fn template_microservices_app_mod_rs() -> String { + r#"mod app_patterns; + +pub use app_patterns::AppPatterns; +"# + .to_string() +} + +fn template_microservices_app_patterns_rs() -> String { + r#"use nestforge::injectable; + +use crate::AppConfig; + +#[injectable(factory = build_app_patterns)] +pub struct AppPatterns { + registry: nestforge::MicroserviceRegistry, +} + +fn build_app_patterns() -> AppPatterns { + AppPatterns { + registry: nestforge::MicroserviceRegistry::builder() + .message("app.ping", |payload: serde_json::Value, ctx| async move { + let config = ctx.resolve::<AppConfig>()?; + Ok(serde_json::json!({ + "app_name": config.app_name, + "received": payload, + "transport": ctx.transport(), + })) + }) + .build(), + } +} + +impl AppPatterns { + pub fn registry(&self) -> &nestforge::MicroserviceRegistry { + &self.registry + } +} +"# + .to_string() +} + +fn template_microservices_mod_rs() -> String { + "/* Microservice pattern exports get generated here */\n".to_string() +} + +fn template_ws_gateway_rs() -> String { + r#"use nestforge::{Message, WebSocket, WebSocketContext, WebSocketGateway, ConfigService}; + +use crate::app_config::load_config; + +pub struct EventsGateway; + +impl WebSocketGateway for EventsGateway { + fn on_connect( + &self, + ctx: WebSocketContext, + mut socket: WebSocket, + ) -> core::pin::Pin<Box<dyn core::future::Future<Output = ()> + Send>> { + Box::pin(async move { + let config = load_config(); + let app_name = config.get_string_or("APP_NAME", "NestForge WebSockets"); + + let _ = socket + .send(Message::Text(format!("connected:{app_name}").into())) + .await; + + while let Some(Ok(message)) = socket.recv().await { + match message { + Message::Text(text) => { + let _ = socket + .send(Message::Text(format!("echo:{text}").into())) + .await; + } + Message::Binary(bytes) => { + let _ = socket.send(Message::Binary(bytes)).await; + } + Message::Close(_) => break, + _ => {} + } + } + }) + } +} +"# + .to_string() +} + +fn template_named_grpc_service_rs(service_name: &str, pascal_name: &str) -> String { + let rpc_name = format!("get_{}_status", service_name); + format!( + r#"use nestforge::{{ + tonic::{{Request, Response, Status}}, + GrpcContext, +}}; + +use crate::proto::{service_name}::{{ + {pascal_name}StatusReply, + {pascal_name}StatusRequest, + {service_name}_service_server::{pascal_name}Service, +}}; + +#[derive(Clone)] +pub struct {pascal_name}GrpcService {{ + ctx: GrpcContext, +}} + +impl {pascal_name}GrpcService {{ + pub fn new(ctx: GrpcContext) -> Self {{ + Self {{ ctx }} + }} +}} + +#[nestforge::tonic::async_trait] +impl {pascal_name}Service for {pascal_name}GrpcService {{ + async fn {rpc_name}( + &self, + request: Request<{pascal_name}StatusRequest>, + ) -> Result<Response<{pascal_name}StatusReply>, Status> {{ + let name = request.into_inner().name.trim().to_string(); + let message = if name.is_empty() {{ + format!("{pascal_name} service is ready") + }} else {{ + format!("{pascal_name} service is ready for {{name}}") + }}; + + let _ = self.ctx.container(); + + Ok(Response::new({pascal_name}StatusReply {{ message }})) + }} +}} +"# + ) +} + +fn template_env_file(app_name: &str, transport: AppTransport) -> String { + let transport_note = match transport { + AppTransport::Http => "# Generated for a NestForge HTTP app.\n", + AppTransport::Graphql => "# Generated for a NestForge GraphQL app.\n", + AppTransport::Grpc => "# Generated for a NestForge gRPC app.\n", + AppTransport::Websockets => "# Generated for a NestForge WebSocket app.\n", + AppTransport::Microservices => "# Generated for a NestForge Microservices app.\n", + }; + + format!( + "{transport_note}APP_NAME={}\n# Optional when you add SQL migrations later.\nDATABASE_URL=postgres://<user>:<password>@localhost/<database>\n", + to_pascal_case(app_name).replace('_', " ") + ) +} + +fn template_entity_dto_rs(pascal_singular: &str, fields: &[DtoFieldSpec]) -> String { + let field_lines = fields + .iter() + .map(template_entity_field_line) + .collect::<Vec<_>>() + .join("\n"); + + format!( + r#"#[nestforge::dto] +pub struct {pascal_singular}Dto {{ + pub id: u64, +{field_lines} +}} + +nestforge::impl_identifiable!({pascal_singular}Dto, id); +"# + ) +} + +fn template_create_dto_rs(pascal_singular: &str, fields: &[DtoFieldSpec]) -> String { + let field_lines = fields + .iter() + .map(template_create_field_block) + .collect::<Vec<_>>() + .join("\n"); + + format!( + r#"#[nestforge::dto] +pub struct Create{pascal_singular}Dto {{ +{field_lines} +}} +"# + ) +} + +fn template_update_dto_rs(pascal_singular: &str, fields: &[DtoFieldSpec]) -> String { + let field_lines = fields + .iter() + .map(template_update_field_line) + .collect::<Vec<_>>() + .join("\n"); + + format!( + r#"#[nestforge::dto] +pub struct Update{pascal_singular}Dto {{ +{field_lines} +}} +"# + ) +} + +fn template_entity_field_line(field: &DtoFieldSpec) -> String { + let ty = if field.required { + field.ty.rust_type().to_string() + } else { + format!("Option<{}>", field.ty.rust_type()) + }; + + format!(" pub {}: {},", field.name, ty) +} + +fn template_create_field_block(field: &DtoFieldSpec) -> String { + let mut lines = Vec::new(); + if field.required { + lines.push(" #[validate(required)]".to_string()); + } + let ty = if field.required { + field.ty.rust_type().to_string() + } else { + format!("Option<{}>", field.ty.rust_type()) + }; + lines.push(format!(" pub {}: {},", field.name, ty)); + lines.join("\n") +} + +fn template_update_field_line(field: &DtoFieldSpec) -> String { + format!(" pub {}: Option<{}>,", field.name, field.ty.rust_type()) +} + +fn template_resource_service_rs( + _resource: &str, + _singular: &str, + pascal_plural: &str, + pascal_singular: &str, + imports: &ResourceImportPaths, +) -> String { + format!( + r#"use nestforge::{{injectable, ResourceService}}; + + use {entity_dto_import}::{pascal_singular}Dto; + use {create_dto_import}::Create{pascal_singular}Dto; + use {update_dto_import}::Update{pascal_singular}Dto; + +#[injectable] +#[derive(Default)] +pub struct {pascal_plural}Service {{ + store: ResourceService<{pascal_singular}Dto>, +}} + +impl {pascal_plural}Service {{ + pub fn list(&self) -> Vec<{pascal_singular}Dto> {{ + self.store.all() + }} + + pub fn get(&self, id: u64) -> Option<{pascal_singular}Dto> {{ + self.store.get(id) + }} + + pub fn create(&self, dto: Create{pascal_singular}Dto) -> Result<{pascal_singular}Dto, nestforge::ResourceError> {{ + self.store.create(dto) + }} + + pub fn update(&self, id: u64, dto: Update{pascal_singular}Dto) -> Result<Option<{pascal_singular}Dto>, nestforge::ResourceError> {{ + self.store.update(id, dto) + }} +}} + "#, + entity_dto_import = imports.entity_dto_import, + create_dto_import = imports.create_dto_import, + update_dto_import = imports.update_dto_import + ) +} + +fn template_resource_controller_rs( + resource: &str, + _singular: &str, + pascal_plural: &str, + pascal_singular: &str, + imports: &ResourceImportPaths, +) -> String { + format!( + r#"use axum::Json; +use nestforge::{{controller, routes, ApiResult, Inject, List, OptionHttpExt, Param, ResultHttpExt, ValidatedBody}}; + + use {entity_dto_import}::{pascal_singular}Dto; + use {create_dto_import}::Create{pascal_singular}Dto; + use {update_dto_import}::Update{pascal_singular}Dto; + use {service_import}::{pascal_plural}Service; + +#[controller("/{resource}")] +pub struct {pascal_plural}Controller; + +#[routes] +impl {pascal_plural}Controller {{ + #[nestforge::get("/")] + async fn list( + service: Inject<{pascal_plural}Service>, + ) -> ApiResult<List<{pascal_singular}Dto>> {{ + Ok(Json(service.list())) + }} + + #[nestforge::get("/{{id}}")] + async fn get_one( + id: Param<u64>, + service: Inject<{pascal_plural}Service>, + ) -> ApiResult<{pascal_singular}Dto> {{ + let id = id.value(); + let item = service + .get(id) + .or_not_found_id("{pascal_singular}", id)?; + + Ok(Json(item)) + }} + + #[nestforge::post("/")] + async fn create( + service: Inject<{pascal_plural}Service>, + body: ValidatedBody<Create{pascal_singular}Dto>, + ) -> ApiResult<{pascal_singular}Dto> {{ + let item = service + .create(body.value()) + .or_bad_request()?; + Ok(Json(item)) + }} + + #[nestforge::put("/{{id}}")] + async fn update( + id: Param<u64>, + service: Inject<{pascal_plural}Service>, + body: ValidatedBody<Update{pascal_singular}Dto>, + ) -> ApiResult<{pascal_singular}Dto> {{ + let id = id.value(); + let item = service + .update(id, body.value()) + .or_bad_request()? + .or_not_found_id("{pascal_singular}", id)?; + + Ok(Json(item)) + }} +}} + "#, + resource = resource, + pascal_plural = pascal_plural, + pascal_singular = pascal_singular, + entity_dto_import = imports.entity_dto_import, + create_dto_import = imports.create_dto_import, + update_dto_import = imports.update_dto_import, + service_import = imports.service_import + ) +} + +fn template_guard_rs(pascal_guard: &str) -> String { + format!( + r#"nestforge::guard!({pascal_guard}); +"# + ) +} + +fn template_request_decorator_rs(pascal_decorator: &str) -> String { + format!( + r#"pub struct {pascal_decorator}; + +impl nestforge::RequestDecorator for {pascal_decorator} {{ + type Output = String; + + fn extract( + _ctx: &nestforge::RequestContext, + parts: &axum::http::request::Parts, + ) -> Result<Self::Output, nestforge::HttpException> {{ + parts + .headers + .get("x-{header_name}") + .and_then(|value| value.to_str().ok()) + .map(str::to_string) + .ok_or_else(|| nestforge::HttpException::bad_request("Missing x-{header_name}")) + }} +}} +"#, + header_name = to_snake_case(&pascal_decorator).replace('_', "-") + ) +} + +fn template_middleware_rs(pascal_middleware: &str) -> String { + format!( + r#"nestforge::middleware!({pascal_middleware}, |req, next| {{ + {{ + println!("{{}} {{}}", req.method(), req.uri().path()); + (next)(req).await + }} +}}); +"# + ) +} + +fn template_exception_filter_rs(pascal_filter: &str) -> String { + format!( + r#"#[derive(Default)] +pub struct {pascal_filter}; + +impl nestforge::ExceptionFilter for {pascal_filter} {{ + fn catch( + &self, + exception: nestforge::HttpException, + _ctx: &nestforge::RequestContext, + ) -> nestforge::HttpException {{ + exception + }} +}} +"# + ) +} + +fn template_interceptor_rs(pascal_interceptor: &str) -> String { + format!( + r#"nestforge::interceptor!({pascal_interceptor}); +"# + ) +} + +fn template_serializer_rs(serializer_name: &str, pascal_serializer: &str) -> String { + format!( + r#"#[nestforge::response_dto] +pub struct {pascal_serializer}Dto {{ + pub id: u64, + pub label: String, +}} + +pub struct {pascal_serializer}; + +impl nestforge::ResponseSerializer<serde_json::Value> for {pascal_serializer} {{ + type Output = {pascal_serializer}Dto; + + fn serialize(value: serde_json::Value) -> Self::Output {{ + {pascal_serializer}Dto {{ + id: value.get("id").and_then(|value| value.as_u64()).unwrap_or_default(), + label: value + .get("label") + .and_then(|value| value.as_str()) + .unwrap_or("{serializer_name}") + .to_string(), + }} + }} +}} +"# + ) +} + +fn template_named_ws_gateway_rs(pascal_gateway: &str) -> String { + format!( + r#"use nestforge::{{Message, WebSocket, WebSocketContext, WebSocketGateway}}; + +pub struct {pascal_gateway}; + +impl WebSocketGateway for {pascal_gateway} {{ + fn on_connect( + &self, + _ctx: WebSocketContext, + mut socket: WebSocket, + ) -> core::pin::Pin<Box<dyn core::future::Future<Output = ()> + Send>> {{ + Box::pin(async move {{ + let _ = socket + .send(Message::Text("connected".to_string().into())) + .await; + }}) + }} +}} +"# + ) +} + +fn template_microservice_patterns_rs(pattern_name: &str, pascal_name: &str) -> String { + format!( + r#"use nestforge::{{MicroserviceRegistry, TransportMetadata}}; + +pub struct {pascal_name}Patterns; + +impl {pascal_name}Patterns {{ + pub fn registry() -> MicroserviceRegistry {{ + MicroserviceRegistry::builder() + .message("{pattern_name}.ping", |_payload: (), ctx| async move {{ + Ok(serde_json::json!({{ + "pattern": ctx.pattern(), + "transport": ctx.transport(), + }})) + }}) + .event("{pattern_name}.created", |_payload: serde_json::Value, _ctx| async move {{ + Ok(()) + }}) + .build() + }} + + pub fn metadata() -> TransportMetadata {{ + TransportMetadata::new().insert("module", "{pattern_name}") + }} +}} +"# + ) +} + +/* ------------------------------ + UTILS +------------------------------ */ + +fn write_file(path: &Path, content: &str) -> Result<()> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + + fs::write(path, content).with_context(|| format!("Failed to write {}", path.display())) +} + +fn detect_app_root() -> Result<PathBuf> { + let cwd = env::current_dir()?; + + /* If user is inside an app folder */ + if cwd.join("src").exists() && cwd.join("Cargo.toml").exists() { + return Ok(cwd); + } + + Err(app_root_not_found()) +} + +fn collect_top_level_modules(main_rs: &Path) -> Result<Vec<String>> { + let content = fs::read_to_string(main_rs) + .with_context(|| format!("Failed to read {}", main_rs.display()))?; + let mut modules = Vec::new(); + + for line in content.lines() { + let trimmed = line.trim(); + if let Some(module_name) = trimmed + .strip_prefix("mod ") + .and_then(|value| value.strip_suffix(';')) + { + modules.push(module_name.trim().to_string()); + } + } + + if !modules + .iter() + .any(|module_name| module_name == "app_module") + { + return Err(missing_app_module_declaration( + &main_rs.display().to_string(), + content, + )); + } + + Ok(modules) +} + +fn resolve_top_level_module_path(app_root: &Path, module_name: &str) -> Result<PathBuf> { + let src_root = app_root.join("src"); + let file = src_root.join(format!("{module_name}.rs")); + if file.exists() { + return Ok(file); + } + + let directory_mod = src_root.join(module_name).join("mod.rs"); + if directory_mod.exists() { + return Ok(directory_mod); + } + + Err(module_file_not_found( + module_name, + &src_root.display().to_string(), + )) +} + +fn relative_path_from(from_file: &Path, to_file: &Path) -> Result<String> { + let from_dir = from_file + .parent() + .context("Temporary export file has no parent directory")?; + let from_components = from_dir.components().collect::<Vec<_>>(); + let to_components = to_file.components().collect::<Vec<_>>(); + let common_len = from_components + .iter() + .zip(&to_components) + .take_while(|(left, right)| left == right) + .count(); + + let mut relative = PathBuf::new(); + for _ in common_len..from_components.len() { + relative.push(".."); + } + for component in &to_components[common_len..] { + relative.push(component.as_os_str()); + } + + Ok(relative.to_string_lossy().replace('\\', "/")) +} + +fn normalize_resource_name(name: &str) -> String { + to_snake_case(name).replace(' ', "_") +} + +#[cfg_attr(not(test), allow(dead_code))] +fn parse_generator_options(args: &[String]) -> Result<GeneratorOptions> { + let mut target_module = None; + let mut layout = GeneratorLayout::Nested; + let mut prompt_for_dto = true; + let mut index = 0usize; + + while index < args.len() { + match args[index].as_str() { + "--module" => { + let Some(value) = args.get(index + 1) else { + bail!( + "Invalid generator options. Use: --module <feature> [--flat] [--no-prompt]" + ); + }; + let module = normalize_resource_name(value); + if module.is_empty() { + bail!("Module name cannot be empty."); + } + target_module = Some(module); + index += 2; + } + "--flat" => { + layout = GeneratorLayout::Flat; + index += 1; + } + "--no-prompt" => { + prompt_for_dto = false; + index += 1; + } + _ => bail!("Invalid generator options. Use: --module <feature> [--flat] [--no-prompt]"), + } + } + + Ok(GeneratorOptions { + target_module, + layout, + prompt_for_dto, + }) +} + +fn generator_target_root(app_root: &Path, target_module: Option<&str>) -> Result<PathBuf> { + if let Some(module_name) = target_module { + let root = app_root.join("src").join(module_name); + if !root.exists() { + bail!( + "Target module `{}` not found. Create it first with: nestforge g module {}", + module_name, + module_name + ); + } + return Ok(root); + } + + Ok(app_root.join("src")) +} + +fn dto_dir(target_root: &Path, layout: GeneratorLayout) -> PathBuf { + match layout { + GeneratorLayout::Nested => target_root.join("dto"), + GeneratorLayout::Flat => target_root.to_path_buf(), + } +} + +fn services_dir(target_root: &Path, layout: GeneratorLayout) -> PathBuf { + match layout { + GeneratorLayout::Nested => target_root.join("services"), + GeneratorLayout::Flat => target_root.to_path_buf(), + } +} + +fn controllers_dir(target_root: &Path, layout: GeneratorLayout) -> PathBuf { + match layout { + GeneratorLayout::Nested => target_root.join("controllers"), + GeneratorLayout::Flat => target_root.to_path_buf(), + } +} + +fn target_mod_file(target_root: &Path) -> Option<PathBuf> { + let path = target_root.join("mod.rs"); + path.exists().then_some(path) +} + +#[derive(Clone, Debug, Eq, PartialEq)] +struct ResourceImportPaths { + entity_dto_import: String, + create_dto_import: String, + update_dto_import: String, + service_import: String, +} + +fn resource_import_paths( + target_module: Option<&str>, + layout: GeneratorLayout, + resource: &str, + singular: &str, +) -> ResourceImportPaths { + match (target_module, layout) { + (Some(module_name), GeneratorLayout::Nested) => ResourceImportPaths { + entity_dto_import: format!("crate::{module_name}::dto"), + create_dto_import: format!("crate::{module_name}::dto"), + update_dto_import: format!("crate::{module_name}::dto"), + service_import: format!("crate::{module_name}::services"), + }, + (Some(module_name), GeneratorLayout::Flat) => ResourceImportPaths { + entity_dto_import: format!("crate::{module_name}"), + create_dto_import: format!("crate::{module_name}"), + update_dto_import: format!("crate::{module_name}"), + service_import: format!("crate::{module_name}"), + }, + (None, GeneratorLayout::Nested) => ResourceImportPaths { + entity_dto_import: "crate::dto".to_string(), + create_dto_import: "crate::dto".to_string(), + update_dto_import: "crate::dto".to_string(), + service_import: "crate::services".to_string(), + }, + (None, GeneratorLayout::Flat) => ResourceImportPaths { + entity_dto_import: format!("crate::{}_dto", singular), + create_dto_import: format!("crate::create_{}_dto", singular), + update_dto_import: format!("crate::update_{}_dto", singular), + service_import: format!("crate::{}_service", resource), + }, + } +} + +fn singular_name(resource: &str) -> String { + if resource.ends_with("ies") && resource.len() > 3 { + format!("{}y", &resource[..resource.len() - 3]) + } else if resource.ends_with('s') && resource.len() > 1 { + resource[..resource.len() - 1].to_string() + } else { + resource.to_string() + } +} + +fn to_pascal_case(input: &str) -> String { + input + .split(['_', '-', ' ']) + .filter(|s| !s.is_empty()) + .map(|part| { + let mut chars = part.chars(); + match chars.next() { + Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(), + std::option::Option::None => String::new(), + } + }) + .collect::<String>() +} + +fn to_snake_case(input: &str) -> String { + let mut out = String::new(); + + for (i, ch) in input.chars().enumerate() { + if ch.is_uppercase() { + if i != 0 { + out.push('_'); + } + for c in ch.to_lowercase() { + out.push(c); + } + } else if ch == '-' || ch == ' ' { + out.push('_'); + } else { + out.push(ch); + } + } + + out +} + +fn template_feature_mod_rs( + module_name: &str, + pascal_module: &str, + layout: GeneratorLayout, +) -> String { + match layout { + GeneratorLayout::Nested => format!( + r#"pub mod controllers; +pub mod dto; +pub mod services; + +use nestforge::module; + +#[module( + imports = [], + controllers = [ + /* nestforge:feature_controllers */ + ], + providers = [ + /* nestforge:feature_providers */ + ], + exports = [ + /* nestforge:feature_exports */ + ] +)] +pub struct {pascal_module}; + +// Feature module: {module_name} +"# + ), + GeneratorLayout::Flat => format!( + r#"/* nestforge:feature_modules */ + +/* nestforge:feature_reexports */ + +use nestforge::module; + +#[module( + imports = [], + controllers = [ + /* nestforge:feature_controllers */ + ], + providers = [ + /* nestforge:feature_providers */ + ], + exports = [ + /* nestforge:feature_exports */ + ] +)] +pub struct {pascal_module}; + +// Feature module: {module_name} +"# + ), + } +} + +fn template_feature_controllers_mod_rs(_module_name: &str, _pascal_module: &str) -> String { + template_controllers_mod_rs() +} + +fn template_feature_services_mod_rs(_module_name: &str, _pascal_module: &str) -> String { + template_services_mod_rs() +} + +fn template_feature_dto_mod_rs() -> String { + "/* feature dto exports */\n".to_string() +} + +fn patch_root_app_module_import( + app_root: &Path, + module_name: &str, + pascal_module: &str, +) -> Result<()> { + let path = app_root.join("src/app_module.rs"); + let mut content = fs::read_to_string(&path)?; + let import_line = format!("use crate::{}::{};\n", module_name, pascal_module); + + if !content.contains(&import_line) { + content = format!("{import_line}{content}"); + fs::write(path, content)?; + } + + Ok(()) +} + +fn patch_root_app_module_imports_list(app_root: &Path, pascal_module: &str) -> Result<()> { + let path = app_root.join("src/app_module.rs"); + let mut content = fs::read_to_string(&path)?; + let marker = "/* nestforge:imports */"; + let entry = format!("{pascal_module},"); + + if content.contains(&entry) { + return Ok(()); + } + + if content.contains(marker) { + content = content.replace(marker, &format!("{marker}\n {entry}")); + fs::write(path, content)?; + return Ok(()); + } + + if let Some(start) = content.find("imports = [") { + let segment = &content[start..]; + if let Some(close_rel) = segment.find(']') { + let close_idx = start + close_rel; + content.insert_str(close_idx, &format!("\n {entry}\n ")); + fs::write(path, content)?; + } + } + + Ok(()) +} + +fn patch_main_mod_decl(app_root: &Path, module_name: &str) -> Result<()> { + let lib_path = app_root.join("src/lib.rs"); + if lib_path.exists() { + let mut content = fs::read_to_string(&lib_path)?; + let decl = format!("pub mod {};", module_name); + let reexport = format!("pub use {}::*;", module_name); + + if !content.contains(&decl) { + if content.contains("/* nestforge:app_modules */") { + content = content.replace( + "/* nestforge:app_modules */", + &format!("/* nestforge:app_modules */\n{decl}"), + ); + } else { + content.push_str(&format!("\n{decl}")); + } + } + + if !content.contains(&reexport) { + if content.contains("/* nestforge:app_reexports */") { + content = content.replace( + "/* nestforge:app_reexports */", + &format!("/* nestforge:app_reexports */\n{reexport}"), + ); + } else { + content.push_str(&format!("\n{reexport}")); + } + } + + fs::write(lib_path, content)?; + return Ok(()); + } + + let path = app_root.join("src/main.rs"); + let mut content = fs::read_to_string(&path)?; + let decl = format!("mod {};", module_name); + if content.contains(&decl) { + return Ok(()); + } + + if let Some(idx) = content.find("mod app_module;") { + let insert_at = idx + "mod app_module;".len(); + content.insert_str(insert_at, &format!("\n{decl}")); + fs::write(path, content)?; + } + + Ok(()) +} + +fn current_unix_timestamp() -> Result<u64> { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .context("System clock is before Unix epoch")?; + Ok(now.as_secs()) +} + +fn nestforge_dir(app_root: &Path) -> PathBuf { + app_root.join(".nestforge") +} + +fn migrations_dir(app_root: &Path) -> PathBuf { + app_root.join("migrations") +} + +fn applied_migrations_file(app_root: &Path) -> PathBuf { + nestforge_dir(app_root).join("applied_migrations.txt") +} + +fn list_migration_files(app_root: &Path) -> Result<Vec<PathBuf>> { + let mut files = Vec::new(); + let dir = migrations_dir(app_root); + + if !dir.exists() { + return Ok(files); + } + + for entry in fs::read_dir(&dir).with_context(|| format!("Failed to read {}", dir.display()))? { + let entry = entry?; + let path = entry.path(); + let is_sql = path + .extension() + .and_then(|ext| ext.to_str()) + .map(|ext| ext.eq_ignore_ascii_case("sql")) + .unwrap_or(false); + + if is_sql { + files.push(path); + } + } + + files.sort(); + Ok(files) +} + +fn read_applied_migrations(app_root: &Path) -> Result<HashMap<String, String>> { + let file = applied_migrations_file(app_root); + if !file.exists() { + return Ok(HashMap::new()); + } + + let content = fs::read_to_string(&file)?; + let mut map = HashMap::new(); + for line in content + .lines() + .map(str::trim) + .filter(|line| !line.is_empty()) + { + if let Some((name, hash)) = line.split_once('|') { + map.insert(name.to_string(), hash.to_string()); + } else { + map.insert(line.to_string(), String::new()); + } + } + Ok(map) +} + +fn append_applied_migration(app_root: &Path, migration_file_name: &str, hash: &str) -> Result<()> { + let file = applied_migrations_file(app_root); + let mut open = fs::OpenOptions::new() + .create(true) + .append(true) + .open(&file) + .with_context(|| format!("Failed to open {}", file.display()))?; + writeln!(open, "{migration_file_name}|{hash}")?; + Ok(()) +} + +fn resolve_database_url(app_root: &Path) -> Result<String> { + if let Ok(url) = env::var("DATABASE_URL") { + if !url.trim().is_empty() { + return Ok(url); + } + } + + let env_path = app_root.join(".env"); + if env_path.exists() { + let content = fs::read_to_string(&env_path)?; + for line in content.lines() { + let trimmed = line.trim(); + if trimmed.is_empty() || trimmed.starts_with('#') { + continue; + } + + if let Some(value) = trimmed.strip_prefix("DATABASE_URL=") { + let clean = value.trim().trim_matches('"').trim_matches('\''); + if !clean.is_empty() { + return Ok(clean.to_string()); + } + } + } + } + + bail!( + "DATABASE_URL not found. Set it in environment or add it to {}", + env_path_or_default(app_root).display() + ) +} + +fn env_path_or_default(app_root: &Path) -> PathBuf { + app_root.join(".env") +} + +fn compute_content_hash(content: &str) -> String { + use sha2::{Digest, Sha256}; + + let mut hasher = Sha256::new(); + hasher.update(content.as_bytes()); + format!("{:x}", hasher.finalize()) +} + +fn contains_sql_content(sql: &str) -> bool { + let mut chars = sql.chars().peekable(); + let mut in_line_comment = false; + let mut in_block_comment = false; + + while let Some(ch) = chars.next() { + if in_line_comment { + if ch == '\n' { + in_line_comment = false; + } + continue; + } + + if in_block_comment { + if ch == '*' && chars.peek() == Some(&'/') { + let _ = chars.next(); + in_block_comment = false; + } + continue; + } + + if ch == '-' && chars.peek() == Some(&'-') { + let _ = chars.next(); + in_line_comment = true; + continue; + } + + if ch == '/' && chars.peek() == Some(&'*') { + let _ = chars.next(); + in_block_comment = true; + continue; + } + + if !ch.is_whitespace() { + return true; + } + } + + false +} + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + + use super::{ + compute_content_hash, contains_sql_content, parse_export_docs_options, + parse_generator_options, parse_new_transport_arg, template_create_dto_rs, + template_exception_filter_rs, template_feature_mod_rs, template_microservice_patterns_rs, + template_named_ws_gateway_rs, template_request_decorator_rs, template_serializer_rs, + AppTransport, DtoFieldSpec, DtoFieldType, ExportDocsOptions, GeneratorLayout, + GeneratorOptions, + }; + + #[test] + fn contains_sql_content_ignores_comment_only_input() { + let sql = "-- heading\n/* block comment */\n"; + + assert!(!contains_sql_content(sql)); + } + + #[test] + fn contains_sql_content_detects_real_sql_after_comments() { + let sql = "-- create users table\nCREATE TABLE users (id INT);\n"; + + assert!(contains_sql_content(sql)); + } + + #[test] + fn compute_content_hash_is_stable() { + let first = compute_content_hash("select 1;"); + let second = compute_content_hash("select 1;"); + + assert_eq!(first, second); + assert_eq!(first.len(), 64); + } + + #[test] + fn parse_new_transport_defaults_to_http() { + let args: Vec<String> = Vec::new(); + + assert!(matches!( + parse_new_transport_arg(&args).expect("transport should parse"), + AppTransport::Http + )); + } + + #[test] + fn parse_new_transport_accepts_graphql() { + let args = vec!["--transport".to_string(), "graphql".to_string()]; + + assert!(matches!( + parse_new_transport_arg(&args).expect("transport should parse"), + AppTransport::Graphql + )); + } + + #[test] + fn parse_new_transport_accepts_microservices() { + let args = vec!["--transport".to_string(), "microservices".to_string()]; + + assert!(matches!( + parse_new_transport_arg(&args).expect("transport should parse"), + AppTransport::Microservices + )); + } + + #[test] + fn parse_new_transport_accepts_websockets() { + let args = vec!["--transport".to_string(), "websockets".to_string()]; + + assert!(matches!( + parse_new_transport_arg(&args).expect("transport should parse"), + AppTransport::Websockets + )); + } + + #[test] + fn template_app_cargo_toml_declares_workspace_root() { + let manifest = super::template_app_cargo_toml( + "demo-app", + "nestforge = { version = \"1.2.1\", features = [\"config\"] }".to_string(), + AppTransport::Http, + ); + + assert!(manifest.contains("\n[workspace]\n")); + } + + #[test] + fn resolve_nestforge_dependency_line_adds_openapi_for_http_apps() { + let dependency = super::resolve_nestforge_dependency_line(AppTransport::Http, true); + + assert!(dependency.contains("\"openapi\"")); + } + + #[test] + fn template_main_rs_wires_openapi_when_requested() { + let main_rs = super::template_main_rs("marknon", AppTransport::Http, true); + + assert!(main_rs.contains("use nestforge::prelude::*;")); + assert!(main_rs.contains(".with_openapi_docs(\"Marknon API\", \"1.0.0\")?")); + } + + #[test] + fn template_main_rs_generates_valid_microservices_json_macro() { + let main_rs = super::template_main_rs("professor", AppTransport::Microservices, false); + + assert!(main_rs.contains("serde_json::json!({")); + assert!(!main_rs.contains("serde_json::json!({{")); + } + + #[test] + fn template_main_rs_declares_root_app_service_for_http_apps() { + let main_rs = super::template_main_rs("demo-api", AppTransport::Http, false); + + assert!(main_rs.contains("use demo_api::AppModule;")); + assert!(!main_rs.contains("mod app_service;")); + } + + #[test] + fn template_app_lib_rs_reexports_root_http_symbols() { + let lib_rs = super::template_app_lib_rs(AppTransport::Http); + + assert!(lib_rs.contains("pub mod app_service;")); + assert!(lib_rs.contains("pub use app_module::AppModule;")); + assert!(lib_rs.contains("/* nestforge:app_modules */")); + assert!(lib_rs.contains("/* nestforge:app_reexports */")); + } + + #[test] + fn template_graphql_schema_imports_async_graphql_crate_path() { + let schema = super::template_graphql_schema_rs(); + + assert!(schema + .contains("use async_graphql::{EmptyMutation, EmptySubscription, Object, Schema};")); + } + + #[test] + fn template_app_cargo_toml_includes_async_graphql_for_graphql_apps() { + let manifest = super::template_app_cargo_toml( + "demo-api", + super::resolve_nestforge_dependency_line(AppTransport::Graphql, false), + AppTransport::Graphql, + ); + + assert!(manifest.contains("async-graphql = \"7\"")); + } + + #[test] + fn template_app_module_registers_root_app_service_for_http_apps() { + let module_rs = super::template_app_module_rs(AppTransport::Http); + + assert!(module_rs.contains("app_service::AppService")); + assert!(module_rs.contains("providers = [")); + assert!(module_rs.contains("AppConfig,")); + assert!(module_rs.contains("AppService,")); + assert!(module_rs.contains("exports = [AppConfig, AppService]")); + } + + #[test] + fn template_app_cargo_toml_includes_axum_for_microservices() { + let manifest = super::template_app_cargo_toml( + "demo-app", + "nestforge = { version = \"1.2.1\", features = [\"config\", \"microservices\"] }" + .to_string(), + AppTransport::Microservices, + ); + + assert!(manifest.contains("axum = \"0.8\"")); + } + + #[test] + fn parse_generator_options_supports_flat_module_generation() { + let options = parse_generator_options(&[ + "--module".to_string(), + "users".to_string(), + "--flat".to_string(), + ]) + .expect("generator options should parse"); + + assert_eq!( + options, + GeneratorOptions { + target_module: Some("users".to_string()), + layout: GeneratorLayout::Flat, + prompt_for_dto: true, + } + ); + } + + #[test] + fn parse_generator_options_supports_no_prompt() { + let options = parse_generator_options(&["--no-prompt".to_string()]) + .expect("generator options should parse"); + + assert_eq!( + options, + GeneratorOptions { + target_module: None, + layout: GeneratorLayout::Nested, + prompt_for_dto: false, + } + ); + } + + #[test] + fn parse_export_docs_options_supports_custom_output() { + let options = parse_export_docs_options(&[ + "--format".to_string(), + "yaml".to_string(), + "--output".to_string(), + "docs/openapi.yaml".to_string(), + "--title".to_string(), + "Users API".to_string(), + "--version".to_string(), + "2.0.0".to_string(), + ]) + .expect("export options should parse"); + + assert_eq!( + options, + ExportDocsOptions { + format: "yaml".to_string(), + output: Some(PathBuf::from("docs/openapi.yaml")), + title: "Users API".to_string(), + version: "2.0.0".to_string(), + module_type: "AppModule".to_string(), + } + ); + } + + #[test] + fn template_create_dto_uses_custom_field_specs() { + let template = template_create_dto_rs( + "User", + &[ + DtoFieldSpec { + name: "email".to_string(), + ty: DtoFieldType::String, + required: true, + }, + DtoFieldSpec { + name: "age".to_string(), + ty: DtoFieldType::U32, + required: false, + }, + ], + ); + + assert!(template.contains("#[validate(required)]")); + assert!(template.contains("pub email: String,")); + assert!(template.contains("pub age: Option<u32>,")); + } + + #[test] + fn flat_feature_module_template_exposes_root_level_exports() { + let template = template_feature_mod_rs("users", "UsersModule", GeneratorLayout::Flat); + + assert!(template.contains("/* nestforge:feature_modules */")); + assert!(template.contains("/* nestforge:feature_reexports */")); + assert!(!template.contains("pub mod controller;")); + assert!(!template.contains("pub mod service;")); + assert!(!template.contains("Controller,")); + assert!(!template.contains("Service,")); + assert!(!template.contains("pub mod controllers;")); + } + + #[test] + fn nested_feature_module_template_starts_without_placeholder_imports() { + let template = template_feature_mod_rs("users", "UsersModule", GeneratorLayout::Nested); + + assert!(template.contains("pub mod controllers;")); + assert!(template.contains("pub mod services;")); + assert!(template.contains("pub mod dto;")); + assert!(!template.contains("use self::controllers")); + assert!(!template.contains("use self::services")); + assert!(!template.contains("Controller,")); + assert!(!template.contains("Service,")); + } + + #[test] + fn template_microservice_patterns_uses_requested_type_name() { + let template = template_microservice_patterns_rs("users", "Users"); + + assert!(template.contains("pub struct UsersPatterns;")); + assert!(template.contains(".message(\"users.ping\"")); + assert!(template.contains(".event(\"users.created\"")); + } + + #[test] + fn template_request_decorator_uses_requested_type_name() { + let template = template_request_decorator_rs("CorrelationId"); + + assert!(template.contains("pub struct CorrelationId;")); + assert!(template.contains("impl nestforge::RequestDecorator for CorrelationId")); + assert!(template.contains("x-correlation-id")); + } + + #[test] + fn template_serializer_uses_requested_type_name() { + let template = template_serializer_rs("user", "UserSerializer"); + + assert!(template.contains("pub struct UserSerializerDto")); + assert!(template.contains("pub struct UserSerializer;")); + assert!(template + .contains("impl nestforge::ResponseSerializer<serde_json::Value> for UserSerializer")); + } + + #[test] + fn template_named_ws_gateway_uses_requested_type_name() { + let template = template_named_ws_gateway_rs("EventsGateway"); + + assert!(template.contains("pub struct EventsGateway;")); + assert!(template.contains("impl WebSocketGateway for EventsGateway")); + } + + #[test] + fn template_exception_filter_uses_requested_type_name() { + let template = template_exception_filter_rs("RewriteBadRequestFilter"); + + assert!(template.contains("pub struct RewriteBadRequestFilter;")); + assert!(template.contains("impl nestforge::ExceptionFilter for RewriteBadRequestFilter")); + } + + #[test] + fn cli_docs_cover_generation_workflow() { + let docs = super::render_docs_plaintext(None); + + assert!(docs.contains("nestforge new my-app --transport http --no-tui")); + assert!(docs.contains("nestforge g module users")); + assert!(docs.contains("nestforge g resource users --module users")); + assert!(docs.contains("nestforge export-docs --format yaml")); + assert!(docs.contains("nestforge db migrate")); + } +} diff --git a/crates/nestforge-config/src/lib.rs b/crates/nestforge-config/src/lib.rs index 645b05a..b04c470 100644 --- a/crates/nestforge-config/src/lib.rs +++ b/crates/nestforge-config/src/lib.rs @@ -11,8 +11,17 @@ pub enum ConfigError { #[source] source: dotenvy::Error, }, + #[error("Failed to parse env file `{path}` at line {line}: {source}")] + ParseEnvFile { + path: String, + line: usize, + #[source] + source: dotenvy::Error, + }, #[error("Missing config key: {key}")] MissingKey { key: String }, + #[error("Failed to parse config key `{key}`: {value}")] + ParseError { key: String, value: String }, } #[derive(Clone, Debug, Default)] @@ -88,23 +97,27 @@ impl ConfigService { pub fn load_with_options(options: &ConfigOptions) -> Result<Self, ConfigError> { let path_ref = Path::new(&options.env_file_path); - let mut values = if options.include_process_env { + let mut values: HashMap<String, String> = if options.include_process_env { env::vars().collect::<HashMap<_, _>>() } else { HashMap::new() }; if path_ref.exists() { - dotenvy::from_path_iter(path_ref) - .map_err(|source| ConfigError::ReadEnvFile { + let iter = + dotenvy::from_path_iter(path_ref).map_err(|source| ConfigError::ReadEnvFile { + path: path_ref.display().to_string(), + source, + })?; + + for result in iter { + let (key, value) = result.map_err(|source| ConfigError::ParseEnvFile { path: path_ref.display().to_string(), + line: 0, source, - })? - .for_each(|result| { - if let Ok((key, value)) = result { - values.insert(key, value); - } - }); + })?; + values.entry(key).or_insert(value); + } } Ok(Self { values }) @@ -263,6 +276,10 @@ impl ConfigModule { ConfigService::load_with_options(&options).expect("Failed to load configuration") } + pub fn try_for_root_with_options(options: ConfigOptions) -> Result<ConfigService, ConfigError> { + ConfigService::load_with_options(&options) + } + pub fn for_feature() -> ConfigOptions { ConfigOptions::new() } @@ -272,21 +289,83 @@ pub fn load_config() -> ConfigService { ConfigModule::for_root_with_options(ConfigModule::for_root()) } +use std::sync::Arc; + pub struct Config<T> { _phantom: std::marker::PhantomData<T>, + service: Arc<ConfigService>, } impl<T> Config<T> { - pub fn new() -> Self { + pub fn new(service: ConfigService) -> Self { Self { _phantom: std::marker::PhantomData, + service: Arc::new(service), } } + + pub fn get(&self, key: &str) -> Option<&str> { + self.service.get(key) + } + + pub fn get_string(&self, key: &str) -> String { + self.service.get_string(key) + } + + pub fn get_string_or(&self, key: &str, default: &str) -> String { + self.service.get_string_or(key, default) + } + + pub fn get_i32(&self, key: &str) -> i32 { + self.service.get_i32(key) + } + + pub fn get_i32_or(&self, key: &str, default: i32) -> i32 { + self.service.get_i32_or(key, default) + } + + pub fn get_u16(&self, key: &str) -> u16 { + self.service.get_u16(key) + } + + pub fn get_u16_or(&self, key: &str, default: u16) -> u16 { + self.service.get_u16_or(key, default) + } + + pub fn get_u32(&self, key: &str) -> u32 { + self.service.get_u32(key) + } + + pub fn get_u32_or(&self, key: &str, default: u32) -> u32 { + self.service.get_u32_or(key, default) + } + + pub fn get_bool(&self, key: &str) -> bool { + self.service.get_bool(key) + } + + pub fn get_bool_or(&self, key: &str, default: bool) -> bool { + self.service.get_bool_or(key, default) + } + + pub fn get_usize(&self, key: &str) -> usize { + self.service.get_usize(key) + } + + pub fn get_usize_or(&self, key: &str, default: usize) -> usize { + self.service.get_usize_or(key, default) + } + + pub fn has(&self, key: &str) -> bool { + self.service.has(key) + } } -impl<T> Default for Config<T> { - fn default() -> Self { - Self::new() +impl<T> std::ops::Deref for Config<T> { + type Target = ConfigService; + + fn deref(&self) -> &Self::Target { + &self.service } } diff --git a/crates/nestforge-core/src/config.rs b/crates/nestforge-core/src/config.rs index caeda07..e385337 100644 --- a/crates/nestforge-core/src/config.rs +++ b/crates/nestforge-core/src/config.rs @@ -1,30 +1,31 @@ -use crate::Container; -use anyhow::Result; -use nestforge_config::{ConfigModule, ConfigOptions, ConfigService}; - -pub fn register_config(container: &Container, options: ConfigOptions) -> Result<()> { - let config = ConfigModule::for_root_with_options(options); - container.register(config).map_err(Into::into) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_register_config_loads() { - std::env::set_var("APP_NAME", "TestApp"); - - let container = Container::new(); - let options = ConfigOptions::new(); - - let result = register_config(&container, options); - assert!(result.is_ok()); - - let resolved = container.resolve::<ConfigService>(); - assert!(resolved.is_ok()); - assert_eq!(resolved.unwrap().get("APP_NAME"), Some("TestApp")); - - std::env::remove_var("APP_NAME"); - } -} +use crate::Container; +use anyhow::Result; +use nestforge_config::{ConfigModule, ConfigOptions}; + +pub fn register_config(container: &Container, options: ConfigOptions) -> Result<()> { + let config = ConfigModule::try_for_root_with_options(options)?; + container.register(config).map_err(Into::into) +} + +#[cfg(test)] +mod tests { + use super::*; + use nestforge_config::ConfigService; + + #[test] + fn test_register_config_loads() { + std::env::set_var("APP_NAME", "TestApp"); + + let container = Container::new(); + let options = ConfigOptions::new(); + + let result = register_config(&container, options); + assert!(result.is_ok()); + + let resolved = container.resolve::<ConfigService>(); + assert!(resolved.is_ok()); + assert_eq!(resolved.unwrap().get("APP_NAME"), Some("TestApp")); + + std::env::remove_var("APP_NAME"); + } +} diff --git a/crates/nestforge-macros/src/lib.rs b/crates/nestforge-macros/src/lib.rs index 21c2365..2105cb6 100644 --- a/crates/nestforge-macros/src/lib.rs +++ b/crates/nestforge-macros/src/lib.rs @@ -1,2063 +1,2025 @@ -use proc_macro::TokenStream; -use proc_macro2::TokenStream as TokenStream2; -use quote::quote; -use syn::{ - bracketed, - parse::{Parse, ParseStream}, - parse_macro_input, parse_quote, - punctuated::Punctuated, - spanned::Spanned, - Attribute, Data, DeriveInput, Expr, Field, Fields, FnArg, GenericArgument, Ident, ImplItem, - ImplItemFn, ItemImpl, ItemStruct, LitStr, Meta, PatType, PathArguments, ReturnType, Token, - Type, -}; - -/// The `#[controller]` macro is your starting point for defining a route group. -/// -/// It takes a base path (like `"/users"`) and attaches it to your struct. -/// Behind the scenes, this implements the `ControllerBasePath` trait, which -/// NestForge uses later to mount your routes at the right URL. -#[proc_macro_attribute] -pub fn controller(attr: TokenStream, item: TokenStream) -> TokenStream { - /* - We expect the attribute to be a simple string literal: #[controller("/path")] - This path will be used as a prefix for all routes in the controller. - */ - let base_path = parse_macro_input!(attr as LitStr); - let input = parse_macro_input!(item as ItemStruct); - - let name = &input.ident; - let path = base_path.value(); - - /* - We keep your original struct but add the metadata trait implementation. - This allows the framework to discover the base path at runtime. - */ - let expanded = quote! { - #input - - impl nestforge::ControllerBasePath for #name { - fn base_path() -> &'static str { - #path - } - } - }; - - TokenStream::from(expanded) -} - -/// `#[injectable]` marks a struct as something NestForge can manage for you. -/// -/// By default, it assumes your struct implements `Default`. If you need more -/// control, you can provide a factory function: `#[injectable(factory = my_factory)]`. -/// -/// It also automatically adds `#[derive(Clone)]` to your struct, because providers -/// need to be shared across the application. -#[proc_macro_attribute] -pub fn injectable(attr: TokenStream, item: TokenStream) -> TokenStream { - let args = parse_macro_input!(attr as InjectableArgs); - let mut input = parse_macro_input!(item as ItemStruct); - - /* - Providers must be Clone so they can be passed around the DI container. - We check if Clone is already derived; if not, we add it. - */ - ensure_derive_trait(&mut input.attrs, "Clone"); - - let name = &input.ident; - - /* - We decide how to register the provider based on whether a factory was provided. - If a factory is present, we wrap it in a closure that converts the result into an `IntoInjectableResult`. - Otherwise, we just use the Default trait. - */ - let register_body = if let Some(factory) = args.factory { - quote! { - let value: Self = - nestforge::IntoInjectableResult::into_injectable_result((#factory)())?; - container.register(value)?; - Ok(()) - } - } else { - quote! { - container.register(<Self as std::default::Default>::default())?; - Ok(()) - } - }; - - let expanded = quote! { - #input - - impl nestforge::Injectable for #name { - fn register(container: &nestforge::Container) -> anyhow::Result<()> { - #register_body - } - } - }; - - TokenStream::from(expanded) -} - -/// `#[routes]` is where the magic happens for your controllers. -/// -/// It looks at all the methods in your `impl` block and finds ones marked with -/// `#[get]`, `#[post]`, etc. It then generates a `ControllerDefinition` that -/// knows how to build an Axum router with all those routes wired up. -/// -/// It also handles: -/// - Extracting guards, interceptors, and filters. -/// - Merging controller-level metadata with method-level metadata. -/// - Generating documentation for your API. -#[proc_macro_attribute] -pub fn routes(_attr: TokenStream, item: TokenStream) -> TokenStream { - let mut input = parse_macro_input!(item as ItemImpl); - - let self_ty = input.self_ty.clone(); - - /* - First, we pull out any metadata from the top of the impl block. - This includes things like `#[tag(...)]`, `#[authenticated]`, or `#[roles(...)]` that apply to all routes. - */ - let controller_meta = extract_controller_route_meta(&mut input); - - let mut route_calls = Vec::new(); - let mut route_docs = Vec::new(); - - /* - Now we loop through every method to see if it's a route. - We look for methods decorated with `#[get]`, `#[post]`, etc. - */ - for impl_item in &mut input.items { - let ImplItem::Fn(ref mut method) = impl_item else { - continue; - }; - - /* - Extract all the "middleware-like" metadata for this specific method. - This includes guards, interceptors, and exception filters. - */ - let (guards, interceptors, exception_filters) = extract_pipeline_meta(method); - let version = extract_version_meta(method); - let mut doc_meta = extract_route_doc_meta(method); - - /* - Merge the controller-level settings (like tags or auth) into the route. - Route-level settings generally add to or override controller-level ones. - */ - doc_meta.tags = merge_string_lists(controller_meta.tags.clone(), doc_meta.tags); - doc_meta.required_roles = merge_string_lists( - controller_meta.required_roles.clone(), - doc_meta.required_roles, - ); - doc_meta.requires_auth = controller_meta.requires_auth - || doc_meta.requires_auth - || !doc_meta.required_roles.is_empty(); - - let guards = merge_type_lists(controller_meta.guards.clone(), guards); - let interceptors = merge_type_lists(controller_meta.interceptors.clone(), interceptors); - let exception_filters = - merge_type_lists(controller_meta.exception_filters.clone(), exception_filters); - - /* - If the method has an HTTP attribute (like #[get("/")]), we process it. - This is where we generate the router configuration code. - */ - if let Some((http_method, path)) = extract_route_meta(method) { - let method_name = &method.sig.ident; - let path_lit = LitStr::new(&path, method.sig.ident.span()); - - /* - We generate the code to initialize all the guards and interceptors. - These are instantiated as Arcs and passed to the route builder. - */ - let guard_inits = guards.iter().map(|ty| { - quote! { std::sync::Arc::new(<#ty as std::default::Default>::default()) as std::sync::Arc<dyn nestforge::Guard> } - }); - - /* - Special handling for auth and role guards. - If authentication is required, we add the standard RequireAuthenticationGuard. - If roles are required, we add the RoleRequirementsGuard. - */ - let auth_guard_init = if doc_meta.requires_auth && doc_meta.required_roles.is_empty() { - quote! { - std::sync::Arc::new(nestforge::RequireAuthenticationGuard::default()) - as std::sync::Arc<dyn nestforge::Guard> - } - } else { - quote! {} - }; - let role_guard_init = if doc_meta.required_roles.is_empty() { - quote! {} - } else { - let roles = doc_meta - .required_roles - .iter() - .map(|role| LitStr::new(role, method.sig.ident.span())); - quote! { - std::sync::Arc::new(nestforge::RoleRequirementsGuard::new([#(#roles),*])) - as std::sync::Arc<dyn nestforge::Guard> - } - }; - - let interceptor_inits = interceptors.iter().map(|ty| { - quote! { std::sync::Arc::new(<#ty as std::default::Default>::default()) as std::sync::Arc<dyn nestforge::Interceptor> } - }); - let exception_filter_inits = exception_filters.iter().map(|ty| { - quote! { std::sync::Arc::new(<#ty as std::default::Default>::default()) as std::sync::Arc<dyn nestforge::ExceptionFilter> } - }); - - let guard_tokens = if doc_meta.requires_auth || !doc_meta.required_roles.is_empty() { - quote! { vec![#(#guard_inits,)* #auth_guard_init #role_guard_init] } - } else { - quote! { vec![#(#guard_inits),*] } - }; - - let version_tokens = if let Some(version) = &version { - let lit = LitStr::new(version, method.sig.ident.span()); - quote! { Some(#lit) } - } else { - quote! { None } - }; - - /* - We build the actual call to the framework's RouteBuilder. - This corresponds to `builder.get(...)`, `builder.post(...)`, etc. - */ - let call = match http_method.as_str() { - "get" => quote! { - builder = builder.get_with_pipeline( - #path_lit, - Self::#method_name, - #guard_tokens, - vec![#(#interceptor_inits),*], - vec![#(#exception_filter_inits),*], - #version_tokens - ); - }, - "post" => quote! { - builder = builder.post_with_pipeline( - #path_lit, - Self::#method_name, - #guard_tokens, - vec![#(#interceptor_inits),*], - vec![#(#exception_filter_inits),*], - #version_tokens - ); - }, - "put" => quote! { - builder = builder.put_with_pipeline( - #path_lit, - Self::#method_name, - #guard_tokens, - vec![#(#interceptor_inits),*], - vec![#(#exception_filter_inits),*], - #version_tokens - ); - }, - "delete" => quote! { - builder = builder.delete_with_pipeline( - #path_lit, - Self::#method_name, - #guard_tokens, - vec![#(#interceptor_inits),*], - vec![#(#exception_filter_inits),*], - #version_tokens - ); - }, - _ => continue, - }; - - route_calls.push(call); - - let method_lit = LitStr::new(&http_method.to_uppercase(), method.sig.ident.span()); - let response_docs = if doc_meta.responses.is_empty() { - quote! { - vec![nestforge::RouteResponseDocumentation { - status: 200, - description: "OK".to_string(), - schema: None, - }] - } - } else { - let responses = doc_meta.responses.iter().map(|response| { - let description = LitStr::new(&response.description, method.sig.ident.span()); - let status = response.status; - quote! { - nestforge::RouteResponseDocumentation { - status: #status, - description: #description.to_string(), - schema: None, - } - } - }); - quote! { vec![#(#responses),*] } - }; - let request_schema_tokens = infer_request_body_doc_tokens(method); - let response_schema_tokens = infer_response_body_doc_tokens(&method.sig.output); - let summary_tokens = if let Some(summary) = &doc_meta.summary { - let summary_lit = LitStr::new(summary, method.sig.ident.span()); - quote! { doc = doc.with_summary(#summary_lit); } - } else { - quote! {} - }; - let description_tokens = if let Some(description) = &doc_meta.description { - let description_lit = LitStr::new(description, method.sig.ident.span()); - quote! { doc = doc.with_description(#description_lit); } - } else { - quote! {} - }; - let tag_tokens = if doc_meta.tags.is_empty() { - quote! {} - } else { - let tags = doc_meta - .tags - .iter() - .map(|tag| LitStr::new(tag, method.sig.ident.span())); - quote! { doc = doc.with_tags([#(#tags),*]); } - }; - let auth_tokens = if doc_meta.requires_auth { - quote! { doc = doc.requires_auth(); } - } else { - quote! {} - }; - let role_tokens = if doc_meta.required_roles.is_empty() { - quote! {} - } else { - let roles = doc_meta - .required_roles - .iter() - .map(|role| LitStr::new(role, method.sig.ident.span())); - quote! { doc = doc.with_required_roles([#(#roles),*]); } - }; - - route_docs.push(quote! { - { - let mut doc = nestforge::RouteDocumentation::new( - #method_lit, - nestforge::RouteBuilder::<#self_ty>::full_path(#path_lit, #version_tokens), - ) - .with_responses(#response_docs); - #summary_tokens - #description_tokens - #tag_tokens - #auth_tokens - #role_tokens - #request_schema_tokens - #response_schema_tokens - doc - } - }); - } - } - - let expanded = quote! { - #input - - impl nestforge::ControllerDefinition for #self_ty { - fn router() -> axum::Router<nestforge::Container> { - nestforge::framework_log_event( - "controller_register", - &[("controller", std::string::String::from(std::any::type_name::<#self_ty>()))] as &[(&str, std::string::String)], - ); - let mut builder = nestforge::RouteBuilder::<#self_ty>::new(); - #(#route_calls)* - builder.build() - } - } - - impl nestforge::DocumentedController for #self_ty { - fn route_docs() -> Vec<nestforge::RouteDocumentation> { - vec![#(#route_docs),*] - } - } - }; - - TokenStream::from(expanded) -} - -/* -#[module( - imports = [AuthModule], - controllers = [AppController, UsersController], - providers = [AppConfig { ... }, UsersService::new()], - exports = [UsersService] -)] -Generates ModuleDefinition for the struct. -*/ -#[proc_macro_attribute] -pub fn module(attr: TokenStream, item: TokenStream) -> TokenStream { - let args = parse_macro_input!(attr as ModuleArgs); - let input = parse_macro_input!(item as ItemStruct); - - let name = &input.ident; - - let controller_calls = args.controllers.iter().map(|ty| { - quote! { <#ty as nestforge::ControllerDefinition>::router() } - }); - let controller_doc_calls = args.controllers.iter().map(|ty| { - quote! { docs.extend(<#ty as nestforge::DocumentedController>::route_docs()); } - }); - - let provider_regs = args.providers.iter().map(build_provider_registration); - - let import_refs = args.imports.iter().map(|ty| { - quote! { nestforge::ModuleRef::of::<#ty>() } - }); - let module_init_hooks = args.on_module_init.iter().map(|expr| { - quote! { #expr as nestforge::LifecycleHook } - }); - let module_destroy_hooks = args.on_module_destroy.iter().map(|expr| { - quote! { #expr as nestforge::LifecycleHook } - }); - let application_bootstrap_hooks = args.on_application_bootstrap.iter().map(|expr| { - quote! { #expr as nestforge::LifecycleHook } - }); - let application_shutdown_hooks = args.on_application_shutdown.iter().map(|expr| { - quote! { #expr as nestforge::LifecycleHook } - }); - - let exported_types = args.exports.iter().map(|ty| { - quote! { std::any::type_name::<#ty>() } - }); - let global_flag = args.global; - - let expanded = quote! { - #input - - impl nestforge::ModuleDefinition for #name { - fn register(container: &nestforge::Container) -> anyhow::Result<()> { - #(#provider_regs)* - Ok(()) - } - - fn imports() -> Vec<nestforge::ModuleRef> { - vec![ - #(#import_refs),* - ] - } - - fn exports() -> Vec<&'static str> { - vec![ - #(#exported_types),* - ] - } - - fn is_global() -> bool { - #global_flag - } - - fn controllers() -> Vec<axum::Router<nestforge::Container>> { - vec![ - #(#controller_calls),* - ] - } - - fn route_docs() -> Vec<nestforge::RouteDocumentation> { - let mut docs = Vec::new(); - #(#controller_doc_calls)* - docs - } - - fn on_module_init() -> Vec<nestforge::LifecycleHook> { - vec![#(#module_init_hooks),*] - } - - fn on_module_destroy() -> Vec<nestforge::LifecycleHook> { - vec![#(#module_destroy_hooks),*] - } - - fn on_application_bootstrap() -> Vec<nestforge::LifecycleHook> { - vec![#(#application_bootstrap_hooks),*] - } - - fn on_application_shutdown() -> Vec<nestforge::LifecycleHook> { - vec![#(#application_shutdown_hooks),*] - } - } - }; - - TokenStream::from(expanded) -} - -/* -Method route attributes are markers consumed by #[routes]. -*/ -#[proc_macro_attribute] -pub fn get(_attr: TokenStream, item: TokenStream) -> TokenStream { - item -} - -#[proc_macro_attribute] -pub fn post(_attr: TokenStream, item: TokenStream) -> TokenStream { - item -} - -#[proc_macro_attribute] -pub fn put(_attr: TokenStream, item: TokenStream) -> TokenStream { - item -} - -#[proc_macro_attribute] -pub fn delete(_attr: TokenStream, item: TokenStream) -> TokenStream { - item -} - -#[proc_macro_attribute] -pub fn version(_attr: TokenStream, item: TokenStream) -> TokenStream { - item -} - -#[proc_macro_attribute] -pub fn use_guard(_attr: TokenStream, item: TokenStream) -> TokenStream { - item -} - -#[proc_macro_attribute] -pub fn use_interceptor(_attr: TokenStream, item: TokenStream) -> TokenStream { - item -} - -#[proc_macro_attribute] -pub fn use_exception_filter(_attr: TokenStream, item: TokenStream) -> TokenStream { - item -} - -#[proc_macro_attribute] -pub fn summary(_attr: TokenStream, item: TokenStream) -> TokenStream { - item -} - -#[proc_macro_attribute] -pub fn description(_attr: TokenStream, item: TokenStream) -> TokenStream { - item -} - -#[proc_macro_attribute] -pub fn tag(_attr: TokenStream, item: TokenStream) -> TokenStream { - item -} - -#[proc_macro_attribute] -pub fn response(_attr: TokenStream, item: TokenStream) -> TokenStream { - item -} - -#[proc_macro_attribute] -pub fn authenticated(_attr: TokenStream, item: TokenStream) -> TokenStream { - item -} - -#[proc_macro_attribute] -pub fn roles(_attr: TokenStream, item: TokenStream) -> TokenStream { - item -} - -fn build_openapi_schema_impl(input: &ItemStruct) -> TokenStream2 { - let name = &input.ident; - let schema_body = build_openapi_schema_body(input); - - quote! { - impl nestforge::OpenApiSchema for #name { - fn schema_name() -> Option<&'static str> { - Some(stringify!(#name)) - } - - fn schema() -> nestforge::serde_json::Value { - #schema_body - } - } - } -} - -fn build_openapi_schema_body(input: &ItemStruct) -> TokenStream2 { - let Fields::Named(fields) = &input.fields else { - return quote! { - nestforge::serde_json::json!({ - "type": "object", - "properties": {}, - "required": [] - }) - }; - }; - - let property_builders = fields - .named - .iter() - .filter_map(build_openapi_property_tokens); - let required_fields = fields - .named - .iter() - .filter_map(required_field_literal) - .collect::<Vec<_>>(); - - quote! {{ - let mut properties = nestforge::serde_json::Map::new(); - #(#property_builders)* - nestforge::serde_json::json!({ - "type": "object", - "properties": properties, - "required": [#(#required_fields),*] - }) - }} -} - -fn build_openapi_property_tokens(field: &Field) -> Option<TokenStream2> { - let field_ident = field.ident.as_ref()?; - let field_name = LitStr::new(&field_ident.to_string(), field_ident.span()); - let field_ty = &field.ty; - let rules = parse_validate_rules(&field.attrs); - let schema_expr = schema_expression_for_type(field_ty); - let validations = validation_schema_mutations(&rules); - - Some(quote! { - { - let mut property = #schema_expr; - #validations - properties.insert(#field_name.to_string(), property); - } - }) -} - -fn required_field_literal(field: &Field) -> Option<LitStr> { - let field_ident = field.ident.as_ref()?; - let rules = parse_validate_rules(&field.attrs); - if is_option_any(&field.ty) && !rules.required { - return None; - } - - Some(LitStr::new(&field_ident.to_string(), field_ident.span())) -} - -fn validation_schema_mutations(rules: &ValidateRules) -> TokenStream2 { - let mut tokens = Vec::new(); - - if rules.email { - tokens.push(quote! { - if let Some(object) = property.as_object_mut() { - object.insert( - "format".to_string(), - nestforge::serde_json::Value::String("email".to_string()), - ); - } - }); - } - - if let Some(min_length) = rules.min_length { - tokens.push(quote! { - if let Some(object) = property.as_object_mut() { - object.insert( - "minLength".to_string(), - nestforge::serde_json::json!(#min_length), - ); - } - }); - } - - if let Some(max_length) = rules.max_length { - tokens.push(quote! { - if let Some(object) = property.as_object_mut() { - object.insert( - "maxLength".to_string(), - nestforge::serde_json::json!(#max_length), - ); - } - }); - } - - if let Some(min) = &rules.min { - tokens.push(quote! { - if let Some(object) = property.as_object_mut() { - object.insert( - "minimum".to_string(), - nestforge::serde_json::json!(#min), - ); - } - }); - } - - if let Some(max) = &rules.max { - tokens.push(quote! { - if let Some(object) = property.as_object_mut() { - object.insert( - "maximum".to_string(), - nestforge::serde_json::json!(#max), - ); - } - }); - } - - quote! { #(#tokens)* } -} - -#[proc_macro_attribute] -pub fn dto(_attr: TokenStream, item: TokenStream) -> TokenStream { - let mut input = parse_macro_input!(item as ItemStruct); - let schema_impl = build_openapi_schema_impl(&input); - - input.attrs.push(parse_quote!( - #[derive(Debug, Clone, serde::Serialize, serde::Deserialize, nestforge::Validate)] - )); - - TokenStream::from(quote! { - #input - #schema_impl - }) -} - -#[proc_macro_attribute] -pub fn identifiable(_attr: TokenStream, item: TokenStream) -> TokenStream { - let input = parse_macro_input!(item as ItemStruct); - let name = &input.ident; - - let Some((id_field_name, id_field_ty)) = find_id_field(&input.fields) else { - return syn::Error::new( - input.ident.span(), - "identifiable requires an `id: u64` field or a field marked with #[id]", - ) - .to_compile_error() - .into(); - }; - let ty_ok = matches!(id_field_ty, Type::Path(ref tp) if tp.path.is_ident("u64")); - if !ty_ok { - return syn::Error::new( - id_field_ty.span(), - "identifiable id field must be of type `u64`", - ) - .to_compile_error() - .into(); - } - - TokenStream::from(quote! { - #input - - impl nestforge::Identifiable for #name { - fn id(&self) -> u64 { - self.#id_field_name - } - - fn set_id(&mut self, id: u64) { - self.#id_field_name = id; - } - } - }) -} - -#[proc_macro_attribute] -pub fn response_dto(_attr: TokenStream, item: TokenStream) -> TokenStream { - let mut input = parse_macro_input!(item as ItemStruct); - let schema_impl = build_openapi_schema_impl(&input); - - input - .attrs - .push(parse_quote!(#[derive(Debug, Clone, serde::Serialize)])); - - TokenStream::from(quote! { - #input - #schema_impl - }) -} - -#[proc_macro_attribute] -pub fn entity_dto(_attr: TokenStream, item: TokenStream) -> TokenStream { - let mut input = parse_macro_input!(item as ItemStruct); - - let Some((id_field_name, id_field_ty)) = find_id_field(&input.fields) else { - return syn::Error::new( - input.ident.span(), - "entity_dto requires an `id: u64` field or a field marked with #[id]", - ) - .to_compile_error() - .into(); - }; - let ty_ok = matches!(id_field_ty, Type::Path(ref tp) if tp.path.is_ident("u64")); - if !ty_ok { - return syn::Error::new( - id_field_ty.span(), - "entity_dto id field must be of type `u64`", - ) - .to_compile_error() - .into(); - } - - input.attrs.push(parse_quote!( - #[derive(Debug, Clone, serde::Serialize, serde::Deserialize, nestforge::Validate)] - )); - - let name = &input.ident; - let schema_impl = build_openapi_schema_impl(&input); - - TokenStream::from(quote! { - #input - - impl nestforge::Identifiable for #name { - fn id(&self) -> u64 { - self.#id_field_name - } - - fn set_id(&mut self, id: u64) { - self.#id_field_name = id; - } - } - - #schema_impl - }) -} - -#[proc_macro_attribute] -pub fn entity(attr: TokenStream, item: TokenStream) -> TokenStream { - let args = parse_macro_input!(attr as EntityArgs); - let mut input = parse_macro_input!(item as ItemStruct); - - let name = &input.ident; - let Some((id_field_name, id_field_ty)) = extract_id_field(&mut input.fields) else { - return syn::Error::new( - input.ident.span(), - "#[entity(...)] requires exactly one field annotated with #[id]", - ) - .to_compile_error() - .into(); - }; - - let table_name = args.table.value(); - let id_column = id_field_name.to_string(); - - let expanded = quote! { - #input - - impl nestforge::EntityMeta for #name { - type Id = #id_field_ty; - - fn table_name() -> &'static str { - #table_name - } - - fn id_column() -> &'static str { - #id_column - } - - fn id_value(&self) -> &Self::Id { - &self.#id_field_name - } - } - }; - - TokenStream::from(expanded) -} - -#[proc_macro_attribute] -pub fn id(_attr: TokenStream, item: TokenStream) -> TokenStream { - item -} - -#[proc_macro_derive(Identifiable, attributes(id))] -pub fn derive_identifiable(item: TokenStream) -> TokenStream { - let input = parse_macro_input!(item as DeriveInput); - let name = &input.ident; - - let Data::Struct(data) = &input.data else { - return syn::Error::new( - input.ident.span(), - "Identifiable can only be derived on structs", - ) - .to_compile_error() - .into(); - }; - - let Some((id_field_name, id_field_ty)) = find_id_field(&data.fields) else { - return syn::Error::new( - input.ident.span(), - "Identifiable derive requires an `id: u64` field or a field marked with #[id]", - ) - .to_compile_error() - .into(); - }; - - let ty_ok = matches!(id_field_ty, Type::Path(ref tp) if tp.path.is_ident("u64")); - if !ty_ok { - return syn::Error::new( - id_field_ty.span(), - "Identifiable id field must be of type `u64`", - ) - .to_compile_error() - .into(); - } - - let expanded = quote! { - impl nestforge::Identifiable for #name { - fn id(&self) -> u64 { - self.#id_field_name - } - - fn set_id(&mut self, id: u64) { - self.#id_field_name = id; - } - } - }; - - TokenStream::from(expanded) -} - -#[proc_macro_derive(Validate, attributes(validate))] -pub fn derive_validate(item: TokenStream) -> TokenStream { - let input = parse_macro_input!(item as DeriveInput); - let name = &input.ident; - - let Data::Struct(data) = &input.data else { - return syn::Error::new( - input.ident.span(), - "Validate can only be derived on structs", - ) - .to_compile_error() - .into(); - }; - - let Fields::Named(fields) = &data.fields else { - return syn::Error::new(input.ident.span(), "Validate derive requires named fields") - .to_compile_error() - .into(); - }; - - let mut checks = Vec::new(); - for field in &fields.named { - let Some(field_ident) = &field.ident else { - continue; - }; - let field_name_lit = field_ident.to_string(); - let rules = parse_validate_rules(&field.attrs); - if !rules.has_rules() { - continue; - } - - let is_string = is_type_named(&field.ty, "String"); - let is_option_string = is_option_of(&field.ty, "String"); - let is_option_any = is_option_any(&field.ty); - let is_numeric = is_numeric_type(&field.ty); - let is_option_numeric = is_option_numeric_type(&field.ty); - - if rules.required { - if is_string { - checks.push(quote! { - if self.#field_ident.trim().is_empty() { - errors.push(nestforge::ValidationIssue { - field: #field_name_lit, - message: format!("{} is required", #field_name_lit), - }); - } - }); - } else if is_option_string { - checks.push(quote! { - match &self.#field_ident { - Some(v) if !v.trim().is_empty() => {} - _ => { - errors.push(nestforge::ValidationIssue { - field: #field_name_lit, - message: format!("{} is required", #field_name_lit), - }); - } - } - }); - } else if is_option_any { - checks.push(quote! { - if self.#field_ident.is_none() { - errors.push(nestforge::ValidationIssue { - field: #field_name_lit, - message: format!("{} is required", #field_name_lit), - }); - } - }); - } - } - - if rules.email { - if is_string { - checks.push(quote! { - if !self.#field_ident.trim().is_empty() && !self.#field_ident.contains('@') { - errors.push(nestforge::ValidationIssue { - field: #field_name_lit, - message: format!("{} must be a valid email", #field_name_lit), - }); - } - }); - } else if is_option_string { - checks.push(quote! { - if let Some(v) = &self.#field_ident { - if !v.trim().is_empty() && !v.contains('@') { - errors.push(nestforge::ValidationIssue { - field: #field_name_lit, - message: format!("{} must be a valid email", #field_name_lit), - }); - } - } - }); - } - } - - if let Some(min_length) = rules.min_length { - if is_string { - checks.push(quote! { - if self.#field_ident.len() < #min_length { - errors.push(nestforge::ValidationIssue { - field: #field_name_lit, - message: format!("{} must be at least {} characters", #field_name_lit, #min_length), - }); - } - }); - } else if is_option_string { - checks.push(quote! { - if let Some(v) = &self.#field_ident { - if v.len() < #min_length { - errors.push(nestforge::ValidationIssue { - field: #field_name_lit, - message: format!("{} must be at least {} characters", #field_name_lit, #min_length), - }); - } - } - }); - } - } - - if let Some(max_length) = rules.max_length { - if is_string { - checks.push(quote! { - if self.#field_ident.len() > #max_length { - errors.push(nestforge::ValidationIssue { - field: #field_name_lit, - message: format!("{} must be at most {} characters", #field_name_lit, #max_length), - }); - } - }); - } else if is_option_string { - checks.push(quote! { - if let Some(v) = &self.#field_ident { - if v.len() > #max_length { - errors.push(nestforge::ValidationIssue { - field: #field_name_lit, - message: format!("{} must be at most {} characters", #field_name_lit, #max_length), - }); - } - } - }); - } - } - - if let Some(min) = &rules.min { - if is_numeric { - checks.push(quote! { - if self.#field_ident < #min { - errors.push(nestforge::ValidationIssue { - field: #field_name_lit, - message: format!("{} must be at least {}", #field_name_lit, #min), - }); - } - }); - } else if is_option_numeric { - checks.push(quote! { - if let Some(v) = self.#field_ident { - if v < #min { - errors.push(nestforge::ValidationIssue { - field: #field_name_lit, - message: format!("{} must be at least {}", #field_name_lit, #min), - }); - } - } - }); - } - } - - if let Some(max) = &rules.max { - if is_numeric { - checks.push(quote! { - if self.#field_ident > #max { - errors.push(nestforge::ValidationIssue { - field: #field_name_lit, - message: format!("{} must be at most {}", #field_name_lit, #max), - }); - } - }); - } else if is_option_numeric { - checks.push(quote! { - if let Some(v) = self.#field_ident { - if v > #max { - errors.push(nestforge::ValidationIssue { - field: #field_name_lit, - message: format!("{} must be at most {}", #field_name_lit, #max), - }); - } - } - }); - } - } - } - - let expanded = quote! { - impl nestforge::Validate for #name { - fn validate(&self) -> Result<(), nestforge::ValidationErrors> { - let mut errors = Vec::new(); - #(#checks)* - if errors.is_empty() { - Ok(()) - } else { - Err(nestforge::ValidationErrors::new(errors)) - } - } - } - }; - - TokenStream::from(expanded) -} - -/* -------- helpers -------- */ - -fn extract_route_meta(method: &mut ImplItemFn) -> Option<(String, String)> { - let mut found: Option<(String, String)> = None; - let mut kept_attrs: Vec<Attribute> = Vec::new(); - - for attr in method.attrs.drain(..) { - let Some((verb, path)) = parse_route_attr(&attr) else { - kept_attrs.push(attr); - continue; - }; - - if found.is_none() { - found = Some((verb, path)); - } - } - - method.attrs = kept_attrs; - found -} - -fn extract_pipeline_meta(method: &mut ImplItemFn) -> (Vec<Type>, Vec<Type>, Vec<Type>) { - let mut guards = Vec::new(); - let mut interceptors = Vec::new(); - let mut exception_filters = Vec::new(); - let mut kept_attrs: Vec<Attribute> = Vec::new(); - - for attr in method.attrs.drain(..) { - let ident = attr - .path() - .segments - .last() - .map(|seg| seg.ident.to_string()) - .unwrap_or_default(); - - if ident == "use_guard" { - if let Ok(ty) = attr.parse_args::<Type>() { - guards.push(ty); - } - continue; - } - - if ident == "use_interceptor" { - if let Ok(ty) = attr.parse_args::<Type>() { - interceptors.push(ty); - } - continue; - } - - if ident == "use_exception_filter" { - if let Ok(ty) = attr.parse_args::<Type>() { - exception_filters.push(ty); - } - continue; - } - - kept_attrs.push(attr); - } - - method.attrs = kept_attrs; - (guards, interceptors, exception_filters) -} - -#[derive(Default)] -struct ControllerRouteMeta { - guards: Vec<Type>, - interceptors: Vec<Type>, - exception_filters: Vec<Type>, - tags: Vec<String>, - requires_auth: bool, - required_roles: Vec<String>, -} - -fn extract_controller_route_meta(input: &mut ItemImpl) -> ControllerRouteMeta { - let mut meta = ControllerRouteMeta::default(); - let mut kept_attrs: Vec<Attribute> = Vec::new(); - - for attr in input.attrs.drain(..) { - let ident = attr - .path() - .segments - .last() - .map(|seg| seg.ident.to_string()) - .unwrap_or_default(); - - match ident.as_str() { - "use_guard" => { - if let Ok(ty) = attr.parse_args::<Type>() { - meta.guards.push(ty); - } - } - "use_interceptor" => { - if let Ok(ty) = attr.parse_args::<Type>() { - meta.interceptors.push(ty); - } - } - "use_exception_filter" => { - if let Ok(ty) = attr.parse_args::<Type>() { - meta.exception_filters.push(ty); - } - } - "tag" => { - if let Ok(lit) = attr.parse_args::<LitStr>() { - meta.tags.push(lit.value()); - } - } - "authenticated" => { - meta.requires_auth = true; - } - "roles" => { - if let Ok(values) = - attr.parse_args_with(Punctuated::<LitStr, Token![,]>::parse_terminated) - { - meta.required_roles - .extend(values.into_iter().map(|value| value.value())); - meta.requires_auth = true; - } - } - _ => kept_attrs.push(attr), - } - } - - input.attrs = kept_attrs; - meta -} - -fn extract_version_meta(method: &mut ImplItemFn) -> Option<String> { - let mut version: Option<String> = None; - let mut kept_attrs: Vec<Attribute> = Vec::new(); - - for attr in method.attrs.drain(..) { - let ident = attr - .path() - .segments - .last() - .map(|seg| seg.ident.to_string()) - .unwrap_or_default(); - - if ident == "version" { - if let Ok(lit) = attr.parse_args::<LitStr>() { - version = Some(lit.value()); - } - continue; - } - - kept_attrs.push(attr); - } - - method.attrs = kept_attrs; - version -} - -#[derive(Default)] -struct RouteDocMeta { - summary: Option<String>, - description: Option<String>, - tags: Vec<String>, - responses: Vec<RouteResponseMeta>, - requires_auth: bool, - required_roles: Vec<String>, -} - -struct RouteResponseMeta { - status: u16, - description: String, -} - -fn extract_route_doc_meta(method: &mut ImplItemFn) -> RouteDocMeta { - let mut meta = RouteDocMeta::default(); - let mut kept_attrs: Vec<Attribute> = Vec::new(); - - for attr in method.attrs.drain(..) { - let ident = attr - .path() - .segments - .last() - .map(|seg| seg.ident.to_string()) - .unwrap_or_default(); - - match ident.as_str() { - "summary" => { - if let Ok(lit) = attr.parse_args::<LitStr>() { - meta.summary = Some(lit.value()); - } - } - "description" => { - if let Ok(lit) = attr.parse_args::<LitStr>() { - meta.description = Some(lit.value()); - } - } - "tag" => { - if let Ok(lit) = attr.parse_args::<LitStr>() { - meta.tags.push(lit.value()); - } - } - "response" => { - if let Ok(response) = attr.parse_args::<RouteResponseArgs>() { - meta.responses.push(RouteResponseMeta { - status: response.status, - description: response.description.value(), - }); - } - } - "authenticated" => { - meta.requires_auth = true; - } - "roles" => { - if let Ok(values) = - attr.parse_args_with(Punctuated::<LitStr, Token![,]>::parse_terminated) - { - meta.required_roles - .extend(values.into_iter().map(|value| value.value())); - meta.requires_auth = true; - } - } - _ => kept_attrs.push(attr), - } - } - - method.attrs = kept_attrs; - meta -} - -fn merge_string_lists(primary: Vec<String>, secondary: Vec<String>) -> Vec<String> { - let mut merged = primary; - for value in secondary { - if !merged.contains(&value) { - merged.push(value); - } - } - merged -} - -fn merge_type_lists(primary: Vec<Type>, secondary: Vec<Type>) -> Vec<Type> { - let mut merged = primary; - for ty in secondary { - if !merged - .iter() - .any(|existing| quote!(#existing).to_string() == quote!(#ty).to_string()) - { - merged.push(ty); - } - } - merged -} - -fn parse_route_attr(attr: &Attribute) -> Option<(String, String)> { - /* - Support both: - - #[get("/")] - - #[nestforge::get("/")] - */ - let ident = attr.path().segments.last()?.ident.to_string(); - - if ident != "get" && ident != "post" && ident != "put" && ident != "delete" { - return None; - } - - let path = match &attr.meta { - Meta::List(_) => attr.parse_args::<LitStr>().ok()?.value(), - _ => return None, - }; - - Some((ident, path)) -} - -fn infer_request_body_doc_tokens(method: &ImplItemFn) -> TokenStream2 { - let Some(payload_ty) = method - .sig - .inputs - .iter() - .find_map(extract_request_payload_type) - else { - return quote! {}; - }; - - let schema_expr = schema_expression_for_type(&payload_ty); - quote! { - doc = doc.with_request_body_schema(#schema_expr); - doc = doc.with_schema_components(nestforge::openapi_schema_components_for::<#payload_ty>()); - } -} - -fn infer_response_body_doc_tokens(output: &ReturnType) -> TokenStream2 { - let Some(schema_doc) = extract_response_payload_doc(output) else { - return quote! {}; - }; - - schema_doc -} - -fn extract_request_payload_type(arg: &FnArg) -> Option<Type> { - let FnArg::Typed(PatType { ty, .. }) = arg else { - return None; - }; - - extract_inner_type_named(ty, &["ValidatedBody", "Body", "Json"]) -} - -fn extract_response_payload_doc(output: &ReturnType) -> Option<TokenStream2> { - let ReturnType::Type(_, ty) = output else { - return None; - }; - - response_payload_doc_tokens(ty) -} - -fn response_payload_doc_tokens(ty: &Type) -> Option<TokenStream2> { - if let Some((value_ty, serializer_ty)) = - extract_two_inner_types_named(ty, &["ApiSerializedResult"]) - { - return Some(quote! { - doc = doc.with_success_response_schema( - nestforge::openapi_schema_for::<<#serializer_ty as nestforge::ResponseSerializer<#value_ty>>::Output>() - ); - doc = doc.with_schema_components( - nestforge::openapi_schema_components_for::<<#serializer_ty as nestforge::ResponseSerializer<#value_ty>>::Output>() - ); - }); - } - - if let Some(inner) = extract_inner_type_named(ty, &["ApiEnvelopeResult"]) { - let schema_expr = quote! {{ - nestforge::serde_json::json!({ - "type": "object", - "properties": { - "success": nestforge::openapi_schema_for::<bool>(), - "data": nestforge::openapi_schema_for::<#inner>() - }, - "required": ["success", "data"] - }) - }}; - return Some(quote! { - doc = doc.with_success_response_schema(#schema_expr); - doc = doc.with_schema_components(nestforge::openapi_schema_components_for::<#inner>()); - }); - } - - if let Some(inner) = extract_inner_type_named(ty, &["ApiResult", "Json"]) { - return response_payload_doc_tokens(&inner).or_else(|| { - let schema_expr = schema_expression_for_type(&inner); - Some(quote! { - doc = doc.with_success_response_schema(#schema_expr); - doc = doc.with_schema_components(nestforge::openapi_schema_components_for::<#inner>()); - }) - }); - } - - if let Some(inner) = extract_inner_type_named(ty, &["Result"]) { - return response_payload_doc_tokens(&inner).or_else(|| { - let schema_expr = schema_expression_for_type(&inner); - Some(quote! { - doc = doc.with_success_response_schema(#schema_expr); - doc = doc.with_schema_components(nestforge::openapi_schema_components_for::<#inner>()); - }) - }); - } - - if let Some((value_ty, serializer_ty)) = extract_serialized_types(ty) { - return Some(quote! { - doc = doc.with_success_response_schema( - nestforge::openapi_schema_for::<<#serializer_ty as nestforge::ResponseSerializer<#value_ty>>::Output>() - ); - doc = doc.with_schema_components( - nestforge::openapi_schema_components_for::<<#serializer_ty as nestforge::ResponseSerializer<#value_ty>>::Output>() - ); - }); - } - - if let Some(inner) = extract_inner_type_named(ty, &["ResponseEnvelope"]) { - let schema_expr = quote!({ - nestforge::serde_json::json!({ - "type": "object", - "properties": { - "success": nestforge::openapi_schema_for::<bool>(), - "data": nestforge::openapi_schema_for::<#inner>() - }, - "required": ["success", "data"] - }) - }); - return Some(quote! { - doc = doc.with_success_response_schema(#schema_expr); - doc = doc.with_schema_components(nestforge::openapi_schema_components_for::<#inner>()); - }); - } - - let schema_expr = schema_expression_for_type(ty); - Some(quote! { - doc = doc.with_success_response_schema(#schema_expr); - doc = doc.with_schema_components(nestforge::openapi_schema_components_for::<#ty>()); - }) -} - -fn schema_expression_for_type(ty: &Type) -> TokenStream2 { - if let Some(inner) = extract_inner_type_named(ty, &["Vec", "List"]) { - return quote! { nestforge::openapi_array_schema_for::<#inner>() }; - } - - if let Some(inner) = extract_inner_type_named(ty, &["Option"]) { - return quote! { nestforge::openapi_nullable_schema_for::<#inner>() }; - } - - quote! { nestforge::openapi_schema_for::<#ty>() } -} - -fn extract_inner_type_named(ty: &Type, names: &[&str]) -> Option<Type> { - let Type::Path(type_path) = ty else { - return None; - }; - let segment = type_path.path.segments.last()?; - if !names.iter().any(|name| segment.ident == *name) { - return None; - } - - let PathArguments::AngleBracketed(args) = &segment.arguments else { - return None; - }; - - args.args.iter().find_map(|arg| match arg { - GenericArgument::Type(inner) => Some(inner.clone()), - _ => None, - }) -} - -fn extract_serialized_types(ty: &Type) -> Option<(Type, Type)> { - extract_two_inner_types_named(ty, &["Serialized"]) -} - -fn extract_two_inner_types_named(ty: &Type, names: &[&str]) -> Option<(Type, Type)> { - let Type::Path(type_path) = ty else { - return None; - }; - let segment = type_path.path.segments.last()?; - if !names.iter().any(|name| segment.ident == *name) { - return None; - } - - let PathArguments::AngleBracketed(args) = &segment.arguments else { - return None; - }; - - let mut types = args.args.iter().filter_map(|arg| match arg { - GenericArgument::Type(inner) => Some(inner.clone()), - _ => None, - }); - - let value_ty = types.next()?; - let serializer_ty = types.next()?; - Some((value_ty, serializer_ty)) -} - -/* -------- module parser -------- */ - -struct ModuleArgs { - imports: Vec<Type>, - controllers: Vec<Type>, - providers: Vec<Expr>, - exports: Vec<Type>, - on_module_init: Vec<Expr>, - on_module_destroy: Vec<Expr>, - on_application_bootstrap: Vec<Expr>, - on_application_shutdown: Vec<Expr>, - global: bool, -} - -#[derive(Default)] -struct InjectableArgs { - factory: Option<Expr>, -} - -struct RouteResponseArgs { - status: u16, - description: LitStr, -} - -struct EntityArgs { - table: LitStr, -} - -impl Parse for EntityArgs { - fn parse(input: ParseStream) -> syn::Result<Self> { - let key: Ident = input.parse()?; - if key != "table" { - return Err(syn::Error::new( - key.span(), - "Unsupported entity key. Use `table = \"...\"`.", - )); - } - input.parse::<Token![=]>()?; - let table = input.parse::<LitStr>()?; - Ok(Self { table }) - } -} - -impl Parse for RouteResponseArgs { - fn parse(input: ParseStream) -> syn::Result<Self> { - let mut status = None; - let mut description = None; - - while !input.is_empty() { - let key: Ident = input.parse()?; - input.parse::<Token![=]>()?; - - if key == "status" { - let value = input.parse::<syn::LitInt>()?; - status = Some(value.base10_parse()?); - } else if key == "description" { - description = Some(input.parse::<LitStr>()?); - } else { - return Err(syn::Error::new( - key.span(), - "Unsupported response key. Use `status = ...` and `description = \"...\"`.", - )); - } - - if input.peek(Token![,]) { - input.parse::<Token![,]>()?; - } - } - - Ok(Self { - status: status.ok_or_else(|| { - syn::Error::new(input.span(), "response metadata requires `status = ...`") - })?, - description: description.ok_or_else(|| { - syn::Error::new( - input.span(), - "response metadata requires `description = \"...\"`", - ) - })?, - }) - } -} - -impl Parse for ModuleArgs { - fn parse(input: ParseStream) -> syn::Result<Self> { - let mut imports: Vec<Type> = Vec::new(); - let mut controllers: Vec<Type> = Vec::new(); - let mut providers: Vec<Expr> = Vec::new(); - let mut exports: Vec<Type> = Vec::new(); - let mut on_module_init: Vec<Expr> = Vec::new(); - let mut on_module_destroy: Vec<Expr> = Vec::new(); - let mut on_application_bootstrap: Vec<Expr> = Vec::new(); - let mut on_application_shutdown: Vec<Expr> = Vec::new(); - let mut global = false; - - while !input.is_empty() { - let key: Ident = input.parse()?; - input.parse::<Token![=]>()?; - - if key == "imports" { - imports = parse_bracket_list::<Type>(input)?; - } else if key == "controllers" { - controllers = parse_bracket_list::<Type>(input)?; - } else if key == "providers" { - providers = parse_bracket_list::<Expr>(input)?; - } else if key == "exports" { - exports = parse_bracket_list::<Type>(input)?; - } else if key == "on_module_init" { - on_module_init = parse_bracket_list::<Expr>(input)?; - } else if key == "on_module_destroy" { - on_module_destroy = parse_bracket_list::<Expr>(input)?; - } else if key == "on_application_bootstrap" { - on_application_bootstrap = parse_bracket_list::<Expr>(input)?; - } else if key == "on_application_shutdown" { - on_application_shutdown = parse_bracket_list::<Expr>(input)?; - } else if key == "global" { - let lit: syn::LitBool = input.parse()?; - global = lit.value; - } else { - return Err(syn::Error::new( - key.span(), - "Unsupported module key. Use `imports`, `controllers`, `providers`, `exports`, lifecycle hook lists, or `global`.", - )); - } - - if input.peek(Token![,]) { - input.parse::<Token![,]>()?; - } - } - - Ok(Self { - imports, - controllers, - providers, - exports, - on_module_init, - on_module_destroy, - on_application_bootstrap, - on_application_shutdown, - global, - }) - } -} - -impl Parse for InjectableArgs { - fn parse(input: ParseStream) -> syn::Result<Self> { - if input.is_empty() { - return Ok(Self::default()); - } - - let key: Ident = input.parse()?; - if key != "factory" { - return Err(syn::Error::new( - key.span(), - "Unsupported injectable key. Use `factory = some_fn`.", - )); - } - input.parse::<Token![=]>()?; - let factory = input.parse::<Expr>()?; - - if input.peek(Token![,]) { - input.parse::<Token![,]>()?; - } - - if !input.is_empty() { - return Err(syn::Error::new( - input.span(), - "Unexpected tokens in #[injectable(...)]", - )); - } - - Ok(Self { - factory: Some(factory), - }) - } -} - -fn parse_bracket_list<T>(input: ParseStream) -> syn::Result<Vec<T>> -where - T: Parse, -{ - let content; - bracketed!(content in input); - - let items: Punctuated<T, Token![,]> = content.parse_terminated(T::parse, Token![,])?; - Ok(items.into_iter().collect()) -} - -fn build_provider_registration(expr: &Expr) -> TokenStream2 { - if is_provider_builder_expr(expr) { - quote! { nestforge::register_provider(container, #expr)?; } - } else if let Some(ty) = injectable_type_expr(expr) { - quote! { nestforge::register_injectable::<#ty>(container)?; } - } else { - quote! { nestforge::register_provider(container, nestforge::Provider::value(#expr))?; } - } -} - -fn is_provider_builder_expr(expr: &Expr) -> bool { - let Expr::Call(call) = expr else { - return false; - }; - let Expr::Path(path_expr) = call.func.as_ref() else { - return false; - }; - - let mut segments = path_expr.path.segments.iter().rev(); - let Some(method) = segments.next() else { - return false; - }; - - if method.ident != "value" && method.ident != "factory" { - return false; - } - - let Some(provider) = segments.next() else { - return false; - }; - - provider.ident == "Provider" -} - -fn injectable_type_expr(expr: &Expr) -> Option<Type> { - let Expr::Path(path) = expr else { - return None; - }; - - Some(Type::Path(syn::TypePath { - qself: None, - path: path.path.clone(), - })) -} - -fn ensure_derive_trait(attrs: &mut Vec<Attribute>, trait_name: &str) { - for attr in attrs.iter_mut() { - if !attr.path().is_ident("derive") { - continue; - } - - let Ok(mut derives) = - attr.parse_args_with(Punctuated::<syn::Path, Token![,]>::parse_terminated) - else { - continue; - }; - - if derives.iter().any(|path| path.is_ident(trait_name)) { - return; - } - - derives.push(parse_quote!(Clone)); - *attr = parse_quote!(#[derive(#derives)]); - return; - } - - attrs.push(parse_quote!(#[derive(Clone)])); -} - -fn extract_id_field(fields: &mut Fields) -> Option<(Ident, Type)> { - let Fields::Named(named_fields) = fields else { - return None; - }; - - let mut found: Option<(Ident, Type)> = None; - - for field in &mut named_fields.named { - let has_id_attr = remove_id_attr(field); - if !has_id_attr { - continue; - } - - let field_name = field.ident.clone()?; - let field_ty = field.ty.clone(); - - if found.is_some() { - return None; - } - - found = Some((field_name, field_ty)); - } - - found -} - -fn remove_id_attr(field: &mut Field) -> bool { - let mut kept = Vec::new(); - let mut has_id = false; - - for attr in field.attrs.drain(..) { - let is_id = attr - .path() - .segments - .last() - .map(|seg| seg.ident == "id") - .unwrap_or(false); - if is_id { - has_id = true; - } else { - kept.push(attr); - } - } - - field.attrs = kept; - has_id -} - -fn find_id_field(fields: &Fields) -> Option<(Ident, Type)> { - let Fields::Named(named_fields) = fields else { - return None; - }; - - let mut by_attr: Option<(Ident, Type)> = None; - let mut by_name: Option<(Ident, Type)> = None; - - for field in &named_fields.named { - let field_ident = field.ident.clone()?; - if field_ident == "id" { - by_name = Some((field_ident.clone(), field.ty.clone())); - } - let has_id_attr = field.attrs.iter().any(|attr| { - attr.path() - .segments - .last() - .map(|s| s.ident == "id") - .unwrap_or(false) - }); - if has_id_attr { - by_attr = Some((field_ident, field.ty.clone())); - } - } - - by_attr.or(by_name) -} - -#[derive(Default)] -struct ValidateRules { - required: bool, - email: bool, - min_length: Option<usize>, - max_length: Option<usize>, - min: Option<syn::Lit>, - max: Option<syn::Lit>, -} - -impl ValidateRules { - fn has_rules(&self) -> bool { - self.required - || self.email - || self.min_length.is_some() - || self.max_length.is_some() - || self.min.is_some() - || self.max.is_some() - } -} - -fn parse_validate_rules(attrs: &[Attribute]) -> ValidateRules { - let mut rules = ValidateRules::default(); - - for attr in attrs { - let is_validate = attr - .path() - .segments - .last() - .map(|seg| seg.ident == "validate") - .unwrap_or(false); - if !is_validate { - continue; - } - - let _ = attr.parse_nested_meta(|meta| { - if meta.path.is_ident("required") { - rules.required = true; - } else if meta.path.is_ident("email") { - rules.email = true; - } else if meta.path.is_ident("min_length") { - let value = meta.value()?.parse::<syn::LitInt>()?; - rules.min_length = Some(value.base10_parse()?); - } else if meta.path.is_ident("max_length") { - let value = meta.value()?.parse::<syn::LitInt>()?; - rules.max_length = Some(value.base10_parse()?); - } else if meta.path.is_ident("min") { - rules.min = Some(meta.value()?.parse::<syn::Lit>()?); - } else if meta.path.is_ident("max") { - rules.max = Some(meta.value()?.parse::<syn::Lit>()?); - } - Ok(()) - }); - } - - rules -} - -fn is_type_named(ty: &Type, name: &str) -> bool { - match ty { - Type::Path(tp) => tp.path.is_ident(name), - _ => false, - } -} - -fn is_option_any(ty: &Type) -> bool { - match ty { - Type::Path(tp) => tp - .path - .segments - .last() - .map(|seg| seg.ident == "Option") - .unwrap_or(false), - _ => false, - } -} - -fn is_option_of(ty: &Type, inner_name: &str) -> bool { - let Type::Path(tp) = ty else { - return false; - }; - let Some(seg) = tp.path.segments.last() else { - return false; - }; - if seg.ident != "Option" { - return false; - } - let syn::PathArguments::AngleBracketed(args) = &seg.arguments else { - return false; - }; - let Some(syn::GenericArgument::Type(ref inner_ty)) = args.args.first() else { - return false; - }; - is_type_named(inner_ty, inner_name) -} - -fn is_numeric_type(ty: &Type) -> bool { - let Type::Path(tp) = ty else { - return false; - }; - tp.path.is_ident("u8") - || tp.path.is_ident("u16") - || tp.path.is_ident("u32") - || tp.path.is_ident("u64") - || tp.path.is_ident("usize") - || tp.path.is_ident("i8") - || tp.path.is_ident("i16") - || tp.path.is_ident("i32") - || tp.path.is_ident("i64") - || tp.path.is_ident("isize") - || tp.path.is_ident("f32") - || tp.path.is_ident("f64") -} - -fn is_option_numeric_type(ty: &Type) -> bool { - let Type::Path(tp) = ty else { - return false; - }; - let Some(seg) = tp.path.segments.last() else { - return false; - }; - if seg.ident != "Option" { - return false; - } - let syn::PathArguments::AngleBracketed(args) = &seg.arguments else { - return false; - }; - let Some(syn::GenericArgument::Type(ref inner_ty)) = args.args.first() else { - return false; - }; - is_numeric_type(inner_ty) -} - -#[proc_macro_derive(Config)] -pub fn derive_config(item: TokenStream) -> TokenStream { - let input = parse_macro_input!(item as DeriveInput); - let name = &input.ident; - let (impl_generics, ty_generics, where_clause) = &input.generics.split_for_impl(); - - let Data::Struct(_data) = &input.data else { - return syn::Error::new(input.ident.span(), "Config can only be derived on structs") - .to_compile_error() - .into(); - }; - - let expanded = quote! { - impl #impl_generics nestforge_config::FromEnv for #name #ty_generics #where_clause { - fn from_env(env: &nestforge_config::EnvStore) -> Result<Self, nestforge_config::ConfigError> { - std::compile_error!( - "Config derive requires manual FromEnv implementation. \ - Use `impl FromEnv for YourConfig` with `env.get(\"KEY\")` to read values." - ); - } - - fn config_key() -> &'static str { - stringify!(#name) - } - } - - impl #impl_generics std::default::Default for #name #ty_generics #where_clause { - fn default() -> Self { - std::compile_error!( - "Config derive requires manual Default implementation or provide default values." - ); - } - } - }; - - TokenStream::from(expanded) -} +use proc_macro::TokenStream; +use proc_macro2::TokenStream as TokenStream2; +use quote::quote; +use syn::{ + bracketed, + parse::{Parse, ParseStream}, + parse_macro_input, parse_quote, + punctuated::Punctuated, + spanned::Spanned, + Attribute, Data, DeriveInput, Expr, Field, Fields, FnArg, GenericArgument, Ident, ImplItem, + ImplItemFn, ItemImpl, ItemStruct, LitStr, Meta, PatType, PathArguments, ReturnType, Token, + Type, +}; + +/// The `#[controller]` macro is your starting point for defining a route group. +/// +/// It takes a base path (like `"/users"`) and attaches it to your struct. +/// Behind the scenes, this implements the `ControllerBasePath` trait, which +/// NestForge uses later to mount your routes at the right URL. +#[proc_macro_attribute] +pub fn controller(attr: TokenStream, item: TokenStream) -> TokenStream { + /* + We expect the attribute to be a simple string literal: #[controller("/path")] + This path will be used as a prefix for all routes in the controller. + */ + let base_path = parse_macro_input!(attr as LitStr); + let input = parse_macro_input!(item as ItemStruct); + + let name = &input.ident; + let path = base_path.value(); + + /* + We keep your original struct but add the metadata trait implementation. + This allows the framework to discover the base path at runtime. + */ + let expanded = quote! { + #input + + impl nestforge::ControllerBasePath for #name { + fn base_path() -> &'static str { + #path + } + } + }; + + TokenStream::from(expanded) +} + +/// `#[injectable]` marks a struct as something NestForge can manage for you. +/// +/// By default, it assumes your struct implements `Default`. If you need more +/// control, you can provide a factory function: `#[injectable(factory = my_factory)]`. +/// +/// It also automatically adds `#[derive(Clone)]` to your struct, because providers +/// need to be shared across the application. +#[proc_macro_attribute] +pub fn injectable(attr: TokenStream, item: TokenStream) -> TokenStream { + let args = parse_macro_input!(attr as InjectableArgs); + let mut input = parse_macro_input!(item as ItemStruct); + + /* + Providers must be Clone so they can be passed around the DI container. + We check if Clone is already derived; if not, we add it. + */ + ensure_derive_trait(&mut input.attrs, "Clone"); + + let name = &input.ident; + + /* + We decide how to register the provider based on whether a factory was provided. + If a factory is present, we wrap it in a closure that converts the result into an `IntoInjectableResult`. + Otherwise, we just use the Default trait. + */ + let register_body = if let Some(factory) = args.factory { + quote! { + let value: Self = + nestforge::IntoInjectableResult::into_injectable_result((#factory)())?; + container.register(value)?; + Ok(()) + } + } else { + quote! { + container.register(<Self as std::default::Default>::default())?; + Ok(()) + } + }; + + let expanded = quote! { + #input + + impl nestforge::Injectable for #name { + fn register(container: &nestforge::Container) -> anyhow::Result<()> { + #register_body + } + } + }; + + TokenStream::from(expanded) +} + +/// `#[routes]` is where the magic happens for your controllers. +/// +/// It looks at all the methods in your `impl` block and finds ones marked with +/// `#[get]`, `#[post]`, etc. It then generates a `ControllerDefinition` that +/// knows how to build an Axum router with all those routes wired up. +/// +/// It also handles: +/// - Extracting guards, interceptors, and filters. +/// - Merging controller-level metadata with method-level metadata. +/// - Generating documentation for your API. +#[proc_macro_attribute] +pub fn routes(_attr: TokenStream, item: TokenStream) -> TokenStream { + let mut input = parse_macro_input!(item as ItemImpl); + + let self_ty = input.self_ty.clone(); + + /* + First, we pull out any metadata from the top of the impl block. + This includes things like `#[tag(...)]`, `#[authenticated]`, or `#[roles(...)]` that apply to all routes. + */ + let controller_meta = extract_controller_route_meta(&mut input); + + let mut route_calls = Vec::new(); + let mut route_docs = Vec::new(); + + /* + Now we loop through every method to see if it's a route. + We look for methods decorated with `#[get]`, `#[post]`, etc. + */ + for impl_item in &mut input.items { + let ImplItem::Fn(ref mut method) = impl_item else { + continue; + }; + + /* + Extract all the "middleware-like" metadata for this specific method. + This includes guards, interceptors, and exception filters. + */ + let (guards, interceptors, exception_filters) = extract_pipeline_meta(method); + let version = extract_version_meta(method); + let mut doc_meta = extract_route_doc_meta(method); + + /* + Merge the controller-level settings (like tags or auth) into the route. + Route-level settings generally add to or override controller-level ones. + */ + doc_meta.tags = merge_string_lists(controller_meta.tags.clone(), doc_meta.tags); + doc_meta.required_roles = merge_string_lists( + controller_meta.required_roles.clone(), + doc_meta.required_roles, + ); + doc_meta.requires_auth = controller_meta.requires_auth + || doc_meta.requires_auth + || !doc_meta.required_roles.is_empty(); + + let guards = merge_type_lists(controller_meta.guards.clone(), guards); + let interceptors = merge_type_lists(controller_meta.interceptors.clone(), interceptors); + let exception_filters = + merge_type_lists(controller_meta.exception_filters.clone(), exception_filters); + + /* + If the method has an HTTP attribute (like #[get("/")]), we process it. + This is where we generate the router configuration code. + */ + if let Some((http_method, path)) = extract_route_meta(method) { + let method_name = &method.sig.ident; + let path_lit = LitStr::new(&path, method.sig.ident.span()); + + /* + We generate the code to initialize all the guards and interceptors. + These are instantiated as Arcs and passed to the route builder. + */ + let guard_inits = guards.iter().map(|ty| { + quote! { std::sync::Arc::new(<#ty as std::default::Default>::default()) as std::sync::Arc<dyn nestforge::Guard> } + }); + + /* + Special handling for auth and role guards. + If authentication is required, we add the standard RequireAuthenticationGuard. + If roles are required, we add the RoleRequirementsGuard. + */ + let auth_guard_init = if doc_meta.requires_auth && doc_meta.required_roles.is_empty() { + quote! { + std::sync::Arc::new(nestforge::RequireAuthenticationGuard::default()) + as std::sync::Arc<dyn nestforge::Guard> + } + } else { + quote! {} + }; + let role_guard_init = if doc_meta.required_roles.is_empty() { + quote! {} + } else { + let roles = doc_meta + .required_roles + .iter() + .map(|role| LitStr::new(role, method.sig.ident.span())); + quote! { + std::sync::Arc::new(nestforge::RoleRequirementsGuard::new([#(#roles),*])) + as std::sync::Arc<dyn nestforge::Guard> + } + }; + + let interceptor_inits = interceptors.iter().map(|ty| { + quote! { std::sync::Arc::new(<#ty as std::default::Default>::default()) as std::sync::Arc<dyn nestforge::Interceptor> } + }); + let exception_filter_inits = exception_filters.iter().map(|ty| { + quote! { std::sync::Arc::new(<#ty as std::default::Default>::default()) as std::sync::Arc<dyn nestforge::ExceptionFilter> } + }); + + let guard_tokens = if doc_meta.requires_auth || !doc_meta.required_roles.is_empty() { + quote! { vec![#(#guard_inits,)* #auth_guard_init #role_guard_init] } + } else { + quote! { vec![#(#guard_inits),*] } + }; + + let version_tokens = if let Some(version) = &version { + let lit = LitStr::new(version, method.sig.ident.span()); + quote! { Some(#lit) } + } else { + quote! { None } + }; + + /* + We build the actual call to the framework's RouteBuilder. + This corresponds to `builder.get(...)`, `builder.post(...)`, etc. + */ + let call = match http_method.as_str() { + "get" => quote! { + builder = builder.get_with_pipeline( + #path_lit, + Self::#method_name, + #guard_tokens, + vec![#(#interceptor_inits),*], + vec![#(#exception_filter_inits),*], + #version_tokens + ); + }, + "post" => quote! { + builder = builder.post_with_pipeline( + #path_lit, + Self::#method_name, + #guard_tokens, + vec![#(#interceptor_inits),*], + vec![#(#exception_filter_inits),*], + #version_tokens + ); + }, + "put" => quote! { + builder = builder.put_with_pipeline( + #path_lit, + Self::#method_name, + #guard_tokens, + vec![#(#interceptor_inits),*], + vec![#(#exception_filter_inits),*], + #version_tokens + ); + }, + "delete" => quote! { + builder = builder.delete_with_pipeline( + #path_lit, + Self::#method_name, + #guard_tokens, + vec![#(#interceptor_inits),*], + vec![#(#exception_filter_inits),*], + #version_tokens + ); + }, + _ => continue, + }; + + route_calls.push(call); + + let method_lit = LitStr::new(&http_method.to_uppercase(), method.sig.ident.span()); + let response_docs = if doc_meta.responses.is_empty() { + quote! { + vec![nestforge::RouteResponseDocumentation { + status: 200, + description: "OK".to_string(), + schema: None, + }] + } + } else { + let responses = doc_meta.responses.iter().map(|response| { + let description = LitStr::new(&response.description, method.sig.ident.span()); + let status = response.status; + quote! { + nestforge::RouteResponseDocumentation { + status: #status, + description: #description.to_string(), + schema: None, + } + } + }); + quote! { vec![#(#responses),*] } + }; + let request_schema_tokens = infer_request_body_doc_tokens(method); + let response_schema_tokens = infer_response_body_doc_tokens(&method.sig.output); + let summary_tokens = if let Some(summary) = &doc_meta.summary { + let summary_lit = LitStr::new(summary, method.sig.ident.span()); + quote! { doc = doc.with_summary(#summary_lit); } + } else { + quote! {} + }; + let description_tokens = if let Some(description) = &doc_meta.description { + let description_lit = LitStr::new(description, method.sig.ident.span()); + quote! { doc = doc.with_description(#description_lit); } + } else { + quote! {} + }; + let tag_tokens = if doc_meta.tags.is_empty() { + quote! {} + } else { + let tags = doc_meta + .tags + .iter() + .map(|tag| LitStr::new(tag, method.sig.ident.span())); + quote! { doc = doc.with_tags([#(#tags),*]); } + }; + let auth_tokens = if doc_meta.requires_auth { + quote! { doc = doc.requires_auth(); } + } else { + quote! {} + }; + let role_tokens = if doc_meta.required_roles.is_empty() { + quote! {} + } else { + let roles = doc_meta + .required_roles + .iter() + .map(|role| LitStr::new(role, method.sig.ident.span())); + quote! { doc = doc.with_required_roles([#(#roles),*]); } + }; + + route_docs.push(quote! { + { + let mut doc = nestforge::RouteDocumentation::new( + #method_lit, + nestforge::RouteBuilder::<#self_ty>::full_path(#path_lit, #version_tokens), + ) + .with_responses(#response_docs); + #summary_tokens + #description_tokens + #tag_tokens + #auth_tokens + #role_tokens + #request_schema_tokens + #response_schema_tokens + doc + } + }); + } + } + + let expanded = quote! { + #input + + impl nestforge::ControllerDefinition for #self_ty { + fn router() -> axum::Router<nestforge::Container> { + nestforge::framework_log_event( + "controller_register", + &[("controller", std::string::String::from(std::any::type_name::<#self_ty>()))] as &[(&str, std::string::String)], + ); + let mut builder = nestforge::RouteBuilder::<#self_ty>::new(); + #(#route_calls)* + builder.build() + } + } + + impl nestforge::DocumentedController for #self_ty { + fn route_docs() -> Vec<nestforge::RouteDocumentation> { + vec![#(#route_docs),*] + } + } + }; + + TokenStream::from(expanded) +} + +/* +#[module( + imports = [AuthModule], + controllers = [AppController, UsersController], + providers = [AppConfig { ... }, UsersService::new()], + exports = [UsersService] +)] +Generates ModuleDefinition for the struct. +*/ +#[proc_macro_attribute] +pub fn module(attr: TokenStream, item: TokenStream) -> TokenStream { + let args = parse_macro_input!(attr as ModuleArgs); + let input = parse_macro_input!(item as ItemStruct); + + let name = &input.ident; + + let controller_calls = args.controllers.iter().map(|ty| { + quote! { <#ty as nestforge::ControllerDefinition>::router() } + }); + let controller_doc_calls = args.controllers.iter().map(|ty| { + quote! { docs.extend(<#ty as nestforge::DocumentedController>::route_docs()); } + }); + + let provider_regs = args.providers.iter().map(build_provider_registration); + + let import_refs = args.imports.iter().map(|ty| { + quote! { nestforge::ModuleRef::of::<#ty>() } + }); + let module_init_hooks = args.on_module_init.iter().map(|expr| { + quote! { #expr as nestforge::LifecycleHook } + }); + let module_destroy_hooks = args.on_module_destroy.iter().map(|expr| { + quote! { #expr as nestforge::LifecycleHook } + }); + let application_bootstrap_hooks = args.on_application_bootstrap.iter().map(|expr| { + quote! { #expr as nestforge::LifecycleHook } + }); + let application_shutdown_hooks = args.on_application_shutdown.iter().map(|expr| { + quote! { #expr as nestforge::LifecycleHook } + }); + + let exported_types = args.exports.iter().map(|ty| { + quote! { std::any::type_name::<#ty>() } + }); + let global_flag = args.global; + + let expanded = quote! { + #input + + impl nestforge::ModuleDefinition for #name { + fn register(container: &nestforge::Container) -> anyhow::Result<()> { + #(#provider_regs)* + Ok(()) + } + + fn imports() -> Vec<nestforge::ModuleRef> { + vec![ + #(#import_refs),* + ] + } + + fn exports() -> Vec<&'static str> { + vec![ + #(#exported_types),* + ] + } + + fn is_global() -> bool { + #global_flag + } + + fn controllers() -> Vec<axum::Router<nestforge::Container>> { + vec![ + #(#controller_calls),* + ] + } + + fn route_docs() -> Vec<nestforge::RouteDocumentation> { + let mut docs = Vec::new(); + #(#controller_doc_calls)* + docs + } + + fn on_module_init() -> Vec<nestforge::LifecycleHook> { + vec![#(#module_init_hooks),*] + } + + fn on_module_destroy() -> Vec<nestforge::LifecycleHook> { + vec![#(#module_destroy_hooks),*] + } + + fn on_application_bootstrap() -> Vec<nestforge::LifecycleHook> { + vec![#(#application_bootstrap_hooks),*] + } + + fn on_application_shutdown() -> Vec<nestforge::LifecycleHook> { + vec![#(#application_shutdown_hooks),*] + } + } + }; + + TokenStream::from(expanded) +} + +/* +Method route attributes are markers consumed by #[routes]. +*/ +#[proc_macro_attribute] +pub fn get(_attr: TokenStream, item: TokenStream) -> TokenStream { + item +} + +#[proc_macro_attribute] +pub fn post(_attr: TokenStream, item: TokenStream) -> TokenStream { + item +} + +#[proc_macro_attribute] +pub fn put(_attr: TokenStream, item: TokenStream) -> TokenStream { + item +} + +#[proc_macro_attribute] +pub fn delete(_attr: TokenStream, item: TokenStream) -> TokenStream { + item +} + +#[proc_macro_attribute] +pub fn version(_attr: TokenStream, item: TokenStream) -> TokenStream { + item +} + +#[proc_macro_attribute] +pub fn use_guard(_attr: TokenStream, item: TokenStream) -> TokenStream { + item +} + +#[proc_macro_attribute] +pub fn use_interceptor(_attr: TokenStream, item: TokenStream) -> TokenStream { + item +} + +#[proc_macro_attribute] +pub fn use_exception_filter(_attr: TokenStream, item: TokenStream) -> TokenStream { + item +} + +#[proc_macro_attribute] +pub fn summary(_attr: TokenStream, item: TokenStream) -> TokenStream { + item +} + +#[proc_macro_attribute] +pub fn description(_attr: TokenStream, item: TokenStream) -> TokenStream { + item +} + +#[proc_macro_attribute] +pub fn tag(_attr: TokenStream, item: TokenStream) -> TokenStream { + item +} + +#[proc_macro_attribute] +pub fn response(_attr: TokenStream, item: TokenStream) -> TokenStream { + item +} + +#[proc_macro_attribute] +pub fn authenticated(_attr: TokenStream, item: TokenStream) -> TokenStream { + item +} + +#[proc_macro_attribute] +pub fn roles(_attr: TokenStream, item: TokenStream) -> TokenStream { + item +} + +fn build_openapi_schema_impl(input: &ItemStruct) -> TokenStream2 { + let name = &input.ident; + let schema_body = build_openapi_schema_body(input); + + quote! { + impl nestforge::OpenApiSchema for #name { + fn schema_name() -> Option<&'static str> { + Some(stringify!(#name)) + } + + fn schema() -> nestforge::serde_json::Value { + #schema_body + } + } + } +} + +fn build_openapi_schema_body(input: &ItemStruct) -> TokenStream2 { + let Fields::Named(fields) = &input.fields else { + return quote! { + nestforge::serde_json::json!({ + "type": "object", + "properties": {}, + "required": [] + }) + }; + }; + + let property_builders = fields + .named + .iter() + .filter_map(build_openapi_property_tokens); + let required_fields = fields + .named + .iter() + .filter_map(required_field_literal) + .collect::<Vec<_>>(); + + quote! {{ + let mut properties = nestforge::serde_json::Map::new(); + #(#property_builders)* + nestforge::serde_json::json!({ + "type": "object", + "properties": properties, + "required": [#(#required_fields),*] + }) + }} +} + +fn build_openapi_property_tokens(field: &Field) -> Option<TokenStream2> { + let field_ident = field.ident.as_ref()?; + let field_name = LitStr::new(&field_ident.to_string(), field_ident.span()); + let field_ty = &field.ty; + let rules = parse_validate_rules(&field.attrs); + let schema_expr = schema_expression_for_type(field_ty); + let validations = validation_schema_mutations(&rules); + + Some(quote! { + { + let mut property = #schema_expr; + #validations + properties.insert(#field_name.to_string(), property); + } + }) +} + +fn required_field_literal(field: &Field) -> Option<LitStr> { + let field_ident = field.ident.as_ref()?; + let rules = parse_validate_rules(&field.attrs); + if is_option_any(&field.ty) && !rules.required { + return None; + } + + Some(LitStr::new(&field_ident.to_string(), field_ident.span())) +} + +fn validation_schema_mutations(rules: &ValidateRules) -> TokenStream2 { + let mut tokens = Vec::new(); + + if rules.email { + tokens.push(quote! { + if let Some(object) = property.as_object_mut() { + object.insert( + "format".to_string(), + nestforge::serde_json::Value::String("email".to_string()), + ); + } + }); + } + + if let Some(min_length) = rules.min_length { + tokens.push(quote! { + if let Some(object) = property.as_object_mut() { + object.insert( + "minLength".to_string(), + nestforge::serde_json::json!(#min_length), + ); + } + }); + } + + if let Some(max_length) = rules.max_length { + tokens.push(quote! { + if let Some(object) = property.as_object_mut() { + object.insert( + "maxLength".to_string(), + nestforge::serde_json::json!(#max_length), + ); + } + }); + } + + if let Some(min) = &rules.min { + tokens.push(quote! { + if let Some(object) = property.as_object_mut() { + object.insert( + "minimum".to_string(), + nestforge::serde_json::json!(#min), + ); + } + }); + } + + if let Some(max) = &rules.max { + tokens.push(quote! { + if let Some(object) = property.as_object_mut() { + object.insert( + "maximum".to_string(), + nestforge::serde_json::json!(#max), + ); + } + }); + } + + quote! { #(#tokens)* } +} + +#[proc_macro_attribute] +pub fn dto(_attr: TokenStream, item: TokenStream) -> TokenStream { + let mut input = parse_macro_input!(item as ItemStruct); + let schema_impl = build_openapi_schema_impl(&input); + + input.attrs.push(parse_quote!( + #[derive(Debug, Clone, serde::Serialize, serde::Deserialize, nestforge::Validate)] + )); + + TokenStream::from(quote! { + #input + #schema_impl + }) +} + +#[proc_macro_attribute] +pub fn identifiable(_attr: TokenStream, item: TokenStream) -> TokenStream { + let input = parse_macro_input!(item as ItemStruct); + let name = &input.ident; + + let Some((id_field_name, id_field_ty)) = find_id_field(&input.fields) else { + return syn::Error::new( + input.ident.span(), + "identifiable requires an `id: u64` field or a field marked with #[id]", + ) + .to_compile_error() + .into(); + }; + let ty_ok = matches!(id_field_ty, Type::Path(ref tp) if tp.path.is_ident("u64")); + if !ty_ok { + return syn::Error::new( + id_field_ty.span(), + "identifiable id field must be of type `u64`", + ) + .to_compile_error() + .into(); + } + + TokenStream::from(quote! { + #input + + impl nestforge::Identifiable for #name { + fn id(&self) -> u64 { + self.#id_field_name + } + + fn set_id(&mut self, id: u64) { + self.#id_field_name = id; + } + } + }) +} + +#[proc_macro_attribute] +pub fn response_dto(_attr: TokenStream, item: TokenStream) -> TokenStream { + let mut input = parse_macro_input!(item as ItemStruct); + let schema_impl = build_openapi_schema_impl(&input); + + input + .attrs + .push(parse_quote!(#[derive(Debug, Clone, serde::Serialize)])); + + TokenStream::from(quote! { + #input + #schema_impl + }) +} + +#[proc_macro_attribute] +pub fn entity_dto(_attr: TokenStream, item: TokenStream) -> TokenStream { + let mut input = parse_macro_input!(item as ItemStruct); + + let Some((id_field_name, id_field_ty)) = find_id_field(&input.fields) else { + return syn::Error::new( + input.ident.span(), + "entity_dto requires an `id: u64` field or a field marked with #[id]", + ) + .to_compile_error() + .into(); + }; + let ty_ok = matches!(id_field_ty, Type::Path(ref tp) if tp.path.is_ident("u64")); + if !ty_ok { + return syn::Error::new( + id_field_ty.span(), + "entity_dto id field must be of type `u64`", + ) + .to_compile_error() + .into(); + } + + input.attrs.push(parse_quote!( + #[derive(Debug, Clone, serde::Serialize, serde::Deserialize, nestforge::Validate)] + )); + + let name = &input.ident; + let schema_impl = build_openapi_schema_impl(&input); + + TokenStream::from(quote! { + #input + + impl nestforge::Identifiable for #name { + fn id(&self) -> u64 { + self.#id_field_name + } + + fn set_id(&mut self, id: u64) { + self.#id_field_name = id; + } + } + + #schema_impl + }) +} + +#[proc_macro_attribute] +pub fn entity(attr: TokenStream, item: TokenStream) -> TokenStream { + let args = parse_macro_input!(attr as EntityArgs); + let mut input = parse_macro_input!(item as ItemStruct); + + let name = &input.ident; + let Some((id_field_name, id_field_ty)) = extract_id_field(&mut input.fields) else { + return syn::Error::new( + input.ident.span(), + "#[entity(...)] requires exactly one field annotated with #[id]", + ) + .to_compile_error() + .into(); + }; + + let table_name = args.table.value(); + let id_column = id_field_name.to_string(); + + let expanded = quote! { + #input + + impl nestforge::EntityMeta for #name { + type Id = #id_field_ty; + + fn table_name() -> &'static str { + #table_name + } + + fn id_column() -> &'static str { + #id_column + } + + fn id_value(&self) -> &Self::Id { + &self.#id_field_name + } + } + }; + + TokenStream::from(expanded) +} + +#[proc_macro_attribute] +pub fn id(_attr: TokenStream, item: TokenStream) -> TokenStream { + item +} + +#[proc_macro_derive(Identifiable, attributes(id))] +pub fn derive_identifiable(item: TokenStream) -> TokenStream { + let input = parse_macro_input!(item as DeriveInput); + let name = &input.ident; + + let Data::Struct(data) = &input.data else { + return syn::Error::new( + input.ident.span(), + "Identifiable can only be derived on structs", + ) + .to_compile_error() + .into(); + }; + + let Some((id_field_name, id_field_ty)) = find_id_field(&data.fields) else { + return syn::Error::new( + input.ident.span(), + "Identifiable derive requires an `id: u64` field or a field marked with #[id]", + ) + .to_compile_error() + .into(); + }; + + let ty_ok = matches!(id_field_ty, Type::Path(ref tp) if tp.path.is_ident("u64")); + if !ty_ok { + return syn::Error::new( + id_field_ty.span(), + "Identifiable id field must be of type `u64`", + ) + .to_compile_error() + .into(); + } + + let expanded = quote! { + impl nestforge::Identifiable for #name { + fn id(&self) -> u64 { + self.#id_field_name + } + + fn set_id(&mut self, id: u64) { + self.#id_field_name = id; + } + } + }; + + TokenStream::from(expanded) +} + +#[proc_macro_derive(Validate, attributes(validate))] +pub fn derive_validate(item: TokenStream) -> TokenStream { + let input = parse_macro_input!(item as DeriveInput); + let name = &input.ident; + + let Data::Struct(data) = &input.data else { + return syn::Error::new( + input.ident.span(), + "Validate can only be derived on structs", + ) + .to_compile_error() + .into(); + }; + + let Fields::Named(fields) = &data.fields else { + return syn::Error::new(input.ident.span(), "Validate derive requires named fields") + .to_compile_error() + .into(); + }; + + let mut checks = Vec::new(); + for field in &fields.named { + let Some(field_ident) = &field.ident else { + continue; + }; + let field_name_lit = field_ident.to_string(); + let rules = parse_validate_rules(&field.attrs); + if !rules.has_rules() { + continue; + } + + let is_string = is_type_named(&field.ty, "String"); + let is_option_string = is_option_of(&field.ty, "String"); + let is_option_any = is_option_any(&field.ty); + let is_numeric = is_numeric_type(&field.ty); + let is_option_numeric = is_option_numeric_type(&field.ty); + + if rules.required { + if is_string { + checks.push(quote! { + if self.#field_ident.trim().is_empty() { + errors.push(nestforge::ValidationIssue { + field: #field_name_lit, + message: format!("{} is required", #field_name_lit), + }); + } + }); + } else if is_option_string { + checks.push(quote! { + match &self.#field_ident { + Some(v) if !v.trim().is_empty() => {} + _ => { + errors.push(nestforge::ValidationIssue { + field: #field_name_lit, + message: format!("{} is required", #field_name_lit), + }); + } + } + }); + } else if is_option_any { + checks.push(quote! { + if self.#field_ident.is_none() { + errors.push(nestforge::ValidationIssue { + field: #field_name_lit, + message: format!("{} is required", #field_name_lit), + }); + } + }); + } + } + + if rules.email { + if is_string { + checks.push(quote! { + if !self.#field_ident.trim().is_empty() && !self.#field_ident.contains('@') { + errors.push(nestforge::ValidationIssue { + field: #field_name_lit, + message: format!("{} must be a valid email", #field_name_lit), + }); + } + }); + } else if is_option_string { + checks.push(quote! { + if let Some(v) = &self.#field_ident { + if !v.trim().is_empty() && !v.contains('@') { + errors.push(nestforge::ValidationIssue { + field: #field_name_lit, + message: format!("{} must be a valid email", #field_name_lit), + }); + } + } + }); + } + } + + if let Some(min_length) = rules.min_length { + if is_string { + checks.push(quote! { + if self.#field_ident.len() < #min_length { + errors.push(nestforge::ValidationIssue { + field: #field_name_lit, + message: format!("{} must be at least {} characters", #field_name_lit, #min_length), + }); + } + }); + } else if is_option_string { + checks.push(quote! { + if let Some(v) = &self.#field_ident { + if v.len() < #min_length { + errors.push(nestforge::ValidationIssue { + field: #field_name_lit, + message: format!("{} must be at least {} characters", #field_name_lit, #min_length), + }); + } + } + }); + } + } + + if let Some(max_length) = rules.max_length { + if is_string { + checks.push(quote! { + if self.#field_ident.len() > #max_length { + errors.push(nestforge::ValidationIssue { + field: #field_name_lit, + message: format!("{} must be at most {} characters", #field_name_lit, #max_length), + }); + } + }); + } else if is_option_string { + checks.push(quote! { + if let Some(v) = &self.#field_ident { + if v.len() > #max_length { + errors.push(nestforge::ValidationIssue { + field: #field_name_lit, + message: format!("{} must be at most {} characters", #field_name_lit, #max_length), + }); + } + } + }); + } + } + + if let Some(min) = &rules.min { + if is_numeric { + checks.push(quote! { + if self.#field_ident < #min { + errors.push(nestforge::ValidationIssue { + field: #field_name_lit, + message: format!("{} must be at least {}", #field_name_lit, #min), + }); + } + }); + } else if is_option_numeric { + checks.push(quote! { + if let Some(v) = self.#field_ident { + if v < #min { + errors.push(nestforge::ValidationIssue { + field: #field_name_lit, + message: format!("{} must be at least {}", #field_name_lit, #min), + }); + } + } + }); + } + } + + if let Some(max) = &rules.max { + if is_numeric { + checks.push(quote! { + if self.#field_ident > #max { + errors.push(nestforge::ValidationIssue { + field: #field_name_lit, + message: format!("{} must be at most {}", #field_name_lit, #max), + }); + } + }); + } else if is_option_numeric { + checks.push(quote! { + if let Some(v) = self.#field_ident { + if v > #max { + errors.push(nestforge::ValidationIssue { + field: #field_name_lit, + message: format!("{} must be at most {}", #field_name_lit, #max), + }); + } + } + }); + } + } + } + + let expanded = quote! { + impl nestforge::Validate for #name { + fn validate(&self) -> Result<(), nestforge::ValidationErrors> { + let mut errors = Vec::new(); + #(#checks)* + if errors.is_empty() { + Ok(()) + } else { + Err(nestforge::ValidationErrors::new(errors)) + } + } + } + }; + + TokenStream::from(expanded) +} + +/* -------- helpers -------- */ + +fn extract_route_meta(method: &mut ImplItemFn) -> Option<(String, String)> { + let mut found: Option<(String, String)> = None; + let mut kept_attrs: Vec<Attribute> = Vec::new(); + + for attr in method.attrs.drain(..) { + let Some((verb, path)) = parse_route_attr(&attr) else { + kept_attrs.push(attr); + continue; + }; + + if found.is_none() { + found = Some((verb, path)); + } + } + + method.attrs = kept_attrs; + found +} + +fn extract_pipeline_meta(method: &mut ImplItemFn) -> (Vec<Type>, Vec<Type>, Vec<Type>) { + let mut guards = Vec::new(); + let mut interceptors = Vec::new(); + let mut exception_filters = Vec::new(); + let mut kept_attrs: Vec<Attribute> = Vec::new(); + + for attr in method.attrs.drain(..) { + let ident = attr + .path() + .segments + .last() + .map(|seg| seg.ident.to_string()) + .unwrap_or_default(); + + if ident == "use_guard" { + if let Ok(ty) = attr.parse_args::<Type>() { + guards.push(ty); + } + continue; + } + + if ident == "use_interceptor" { + if let Ok(ty) = attr.parse_args::<Type>() { + interceptors.push(ty); + } + continue; + } + + if ident == "use_exception_filter" { + if let Ok(ty) = attr.parse_args::<Type>() { + exception_filters.push(ty); + } + continue; + } + + kept_attrs.push(attr); + } + + method.attrs = kept_attrs; + (guards, interceptors, exception_filters) +} + +#[derive(Default)] +struct ControllerRouteMeta { + guards: Vec<Type>, + interceptors: Vec<Type>, + exception_filters: Vec<Type>, + tags: Vec<String>, + requires_auth: bool, + required_roles: Vec<String>, +} + +fn extract_controller_route_meta(input: &mut ItemImpl) -> ControllerRouteMeta { + let mut meta = ControllerRouteMeta::default(); + let mut kept_attrs: Vec<Attribute> = Vec::new(); + + for attr in input.attrs.drain(..) { + let ident = attr + .path() + .segments + .last() + .map(|seg| seg.ident.to_string()) + .unwrap_or_default(); + + match ident.as_str() { + "use_guard" => { + if let Ok(ty) = attr.parse_args::<Type>() { + meta.guards.push(ty); + } + } + "use_interceptor" => { + if let Ok(ty) = attr.parse_args::<Type>() { + meta.interceptors.push(ty); + } + } + "use_exception_filter" => { + if let Ok(ty) = attr.parse_args::<Type>() { + meta.exception_filters.push(ty); + } + } + "tag" => { + if let Ok(lit) = attr.parse_args::<LitStr>() { + meta.tags.push(lit.value()); + } + } + "authenticated" => { + meta.requires_auth = true; + } + "roles" => { + if let Ok(values) = + attr.parse_args_with(Punctuated::<LitStr, Token![,]>::parse_terminated) + { + meta.required_roles + .extend(values.into_iter().map(|value| value.value())); + meta.requires_auth = true; + } + } + _ => kept_attrs.push(attr), + } + } + + input.attrs = kept_attrs; + meta +} + +fn extract_version_meta(method: &mut ImplItemFn) -> Option<String> { + let mut version: Option<String> = None; + let mut kept_attrs: Vec<Attribute> = Vec::new(); + + for attr in method.attrs.drain(..) { + let ident = attr + .path() + .segments + .last() + .map(|seg| seg.ident.to_string()) + .unwrap_or_default(); + + if ident == "version" { + if let Ok(lit) = attr.parse_args::<LitStr>() { + version = Some(lit.value()); + } + continue; + } + + kept_attrs.push(attr); + } + + method.attrs = kept_attrs; + version +} + +#[derive(Default)] +struct RouteDocMeta { + summary: Option<String>, + description: Option<String>, + tags: Vec<String>, + responses: Vec<RouteResponseMeta>, + requires_auth: bool, + required_roles: Vec<String>, +} + +struct RouteResponseMeta { + status: u16, + description: String, +} + +fn extract_route_doc_meta(method: &mut ImplItemFn) -> RouteDocMeta { + let mut meta = RouteDocMeta::default(); + let mut kept_attrs: Vec<Attribute> = Vec::new(); + + for attr in method.attrs.drain(..) { + let ident = attr + .path() + .segments + .last() + .map(|seg| seg.ident.to_string()) + .unwrap_or_default(); + + match ident.as_str() { + "summary" => { + if let Ok(lit) = attr.parse_args::<LitStr>() { + meta.summary = Some(lit.value()); + } + } + "description" => { + if let Ok(lit) = attr.parse_args::<LitStr>() { + meta.description = Some(lit.value()); + } + } + "tag" => { + if let Ok(lit) = attr.parse_args::<LitStr>() { + meta.tags.push(lit.value()); + } + } + "response" => { + if let Ok(response) = attr.parse_args::<RouteResponseArgs>() { + meta.responses.push(RouteResponseMeta { + status: response.status, + description: response.description.value(), + }); + } + } + "authenticated" => { + meta.requires_auth = true; + } + "roles" => { + if let Ok(values) = + attr.parse_args_with(Punctuated::<LitStr, Token![,]>::parse_terminated) + { + meta.required_roles + .extend(values.into_iter().map(|value| value.value())); + meta.requires_auth = true; + } + } + _ => kept_attrs.push(attr), + } + } + + method.attrs = kept_attrs; + meta +} + +fn merge_string_lists(primary: Vec<String>, secondary: Vec<String>) -> Vec<String> { + let mut merged = primary; + for value in secondary { + if !merged.contains(&value) { + merged.push(value); + } + } + merged +} + +fn merge_type_lists(primary: Vec<Type>, secondary: Vec<Type>) -> Vec<Type> { + let mut merged = primary; + for ty in secondary { + if !merged + .iter() + .any(|existing| quote!(#existing).to_string() == quote!(#ty).to_string()) + { + merged.push(ty); + } + } + merged +} + +fn parse_route_attr(attr: &Attribute) -> Option<(String, String)> { + /* + Support both: + - #[get("/")] + - #[nestforge::get("/")] + */ + let ident = attr.path().segments.last()?.ident.to_string(); + + if ident != "get" && ident != "post" && ident != "put" && ident != "delete" { + return None; + } + + let path = match &attr.meta { + Meta::List(_) => attr.parse_args::<LitStr>().ok()?.value(), + _ => return None, + }; + + Some((ident, path)) +} + +fn infer_request_body_doc_tokens(method: &ImplItemFn) -> TokenStream2 { + let Some(payload_ty) = method + .sig + .inputs + .iter() + .find_map(extract_request_payload_type) + else { + return quote! {}; + }; + + let schema_expr = schema_expression_for_type(&payload_ty); + quote! { + doc = doc.with_request_body_schema(#schema_expr); + doc = doc.with_schema_components(nestforge::openapi_schema_components_for::<#payload_ty>()); + } +} + +fn infer_response_body_doc_tokens(output: &ReturnType) -> TokenStream2 { + let Some(schema_doc) = extract_response_payload_doc(output) else { + return quote! {}; + }; + + schema_doc +} + +fn extract_request_payload_type(arg: &FnArg) -> Option<Type> { + let FnArg::Typed(PatType { ty, .. }) = arg else { + return None; + }; + + extract_inner_type_named(ty, &["ValidatedBody", "Body", "Json"]) +} + +fn extract_response_payload_doc(output: &ReturnType) -> Option<TokenStream2> { + let ReturnType::Type(_, ty) = output else { + return None; + }; + + response_payload_doc_tokens(ty) +} + +fn response_payload_doc_tokens(ty: &Type) -> Option<TokenStream2> { + if let Some((value_ty, serializer_ty)) = + extract_two_inner_types_named(ty, &["ApiSerializedResult"]) + { + return Some(quote! { + doc = doc.with_success_response_schema( + nestforge::openapi_schema_for::<<#serializer_ty as nestforge::ResponseSerializer<#value_ty>>::Output>() + ); + doc = doc.with_schema_components( + nestforge::openapi_schema_components_for::<<#serializer_ty as nestforge::ResponseSerializer<#value_ty>>::Output>() + ); + }); + } + + if let Some(inner) = extract_inner_type_named(ty, &["ApiEnvelopeResult"]) { + let schema_expr = quote! {{ + nestforge::serde_json::json!({ + "type": "object", + "properties": { + "success": nestforge::openapi_schema_for::<bool>(), + "data": nestforge::openapi_schema_for::<#inner>() + }, + "required": ["success", "data"] + }) + }}; + return Some(quote! { + doc = doc.with_success_response_schema(#schema_expr); + doc = doc.with_schema_components(nestforge::openapi_schema_components_for::<#inner>()); + }); + } + + if let Some(inner) = extract_inner_type_named(ty, &["ApiResult", "Json"]) { + return response_payload_doc_tokens(&inner).or_else(|| { + let schema_expr = schema_expression_for_type(&inner); + Some(quote! { + doc = doc.with_success_response_schema(#schema_expr); + doc = doc.with_schema_components(nestforge::openapi_schema_components_for::<#inner>()); + }) + }); + } + + if let Some(inner) = extract_inner_type_named(ty, &["Result"]) { + return response_payload_doc_tokens(&inner).or_else(|| { + let schema_expr = schema_expression_for_type(&inner); + Some(quote! { + doc = doc.with_success_response_schema(#schema_expr); + doc = doc.with_schema_components(nestforge::openapi_schema_components_for::<#inner>()); + }) + }); + } + + if let Some((value_ty, serializer_ty)) = extract_serialized_types(ty) { + return Some(quote! { + doc = doc.with_success_response_schema( + nestforge::openapi_schema_for::<<#serializer_ty as nestforge::ResponseSerializer<#value_ty>>::Output>() + ); + doc = doc.with_schema_components( + nestforge::openapi_schema_components_for::<<#serializer_ty as nestforge::ResponseSerializer<#value_ty>>::Output>() + ); + }); + } + + if let Some(inner) = extract_inner_type_named(ty, &["ResponseEnvelope"]) { + let schema_expr = quote!({ + nestforge::serde_json::json!({ + "type": "object", + "properties": { + "success": nestforge::openapi_schema_for::<bool>(), + "data": nestforge::openapi_schema_for::<#inner>() + }, + "required": ["success", "data"] + }) + }); + return Some(quote! { + doc = doc.with_success_response_schema(#schema_expr); + doc = doc.with_schema_components(nestforge::openapi_schema_components_for::<#inner>()); + }); + } + + let schema_expr = schema_expression_for_type(ty); + Some(quote! { + doc = doc.with_success_response_schema(#schema_expr); + doc = doc.with_schema_components(nestforge::openapi_schema_components_for::<#ty>()); + }) +} + +fn schema_expression_for_type(ty: &Type) -> TokenStream2 { + if let Some(inner) = extract_inner_type_named(ty, &["Vec", "List"]) { + return quote! { nestforge::openapi_array_schema_for::<#inner>() }; + } + + if let Some(inner) = extract_inner_type_named(ty, &["Option"]) { + return quote! { nestforge::openapi_nullable_schema_for::<#inner>() }; + } + + quote! { nestforge::openapi_schema_for::<#ty>() } +} + +fn extract_inner_type_named(ty: &Type, names: &[&str]) -> Option<Type> { + let Type::Path(type_path) = ty else { + return None; + }; + let segment = type_path.path.segments.last()?; + if !names.iter().any(|name| segment.ident == *name) { + return None; + } + + let PathArguments::AngleBracketed(args) = &segment.arguments else { + return None; + }; + + args.args.iter().find_map(|arg| match arg { + GenericArgument::Type(inner) => Some(inner.clone()), + _ => None, + }) +} + +fn extract_serialized_types(ty: &Type) -> Option<(Type, Type)> { + extract_two_inner_types_named(ty, &["Serialized"]) +} + +fn extract_two_inner_types_named(ty: &Type, names: &[&str]) -> Option<(Type, Type)> { + let Type::Path(type_path) = ty else { + return None; + }; + let segment = type_path.path.segments.last()?; + if !names.iter().any(|name| segment.ident == *name) { + return None; + } + + let PathArguments::AngleBracketed(args) = &segment.arguments else { + return None; + }; + + let mut types = args.args.iter().filter_map(|arg| match arg { + GenericArgument::Type(inner) => Some(inner.clone()), + _ => None, + }); + + let value_ty = types.next()?; + let serializer_ty = types.next()?; + Some((value_ty, serializer_ty)) +} + +/* -------- module parser -------- */ + +struct ModuleArgs { + imports: Vec<Type>, + controllers: Vec<Type>, + providers: Vec<Expr>, + exports: Vec<Type>, + on_module_init: Vec<Expr>, + on_module_destroy: Vec<Expr>, + on_application_bootstrap: Vec<Expr>, + on_application_shutdown: Vec<Expr>, + global: bool, +} + +#[derive(Default)] +struct InjectableArgs { + factory: Option<Expr>, +} + +struct RouteResponseArgs { + status: u16, + description: LitStr, +} + +struct EntityArgs { + table: LitStr, +} + +impl Parse for EntityArgs { + fn parse(input: ParseStream) -> syn::Result<Self> { + let key: Ident = input.parse()?; + if key != "table" { + return Err(syn::Error::new( + key.span(), + "Unsupported entity key. Use `table = \"...\"`.", + )); + } + input.parse::<Token![=]>()?; + let table = input.parse::<LitStr>()?; + Ok(Self { table }) + } +} + +impl Parse for RouteResponseArgs { + fn parse(input: ParseStream) -> syn::Result<Self> { + let mut status = None; + let mut description = None; + + while !input.is_empty() { + let key: Ident = input.parse()?; + input.parse::<Token![=]>()?; + + if key == "status" { + let value = input.parse::<syn::LitInt>()?; + status = Some(value.base10_parse()?); + } else if key == "description" { + description = Some(input.parse::<LitStr>()?); + } else { + return Err(syn::Error::new( + key.span(), + "Unsupported response key. Use `status = ...` and `description = \"...\"`.", + )); + } + + if input.peek(Token![,]) { + input.parse::<Token![,]>()?; + } + } + + Ok(Self { + status: status.ok_or_else(|| { + syn::Error::new(input.span(), "response metadata requires `status = ...`") + })?, + description: description.ok_or_else(|| { + syn::Error::new( + input.span(), + "response metadata requires `description = \"...\"`", + ) + })?, + }) + } +} + +impl Parse for ModuleArgs { + fn parse(input: ParseStream) -> syn::Result<Self> { + let mut imports: Vec<Type> = Vec::new(); + let mut controllers: Vec<Type> = Vec::new(); + let mut providers: Vec<Expr> = Vec::new(); + let mut exports: Vec<Type> = Vec::new(); + let mut on_module_init: Vec<Expr> = Vec::new(); + let mut on_module_destroy: Vec<Expr> = Vec::new(); + let mut on_application_bootstrap: Vec<Expr> = Vec::new(); + let mut on_application_shutdown: Vec<Expr> = Vec::new(); + let mut global = false; + + while !input.is_empty() { + let key: Ident = input.parse()?; + input.parse::<Token![=]>()?; + + if key == "imports" { + imports = parse_bracket_list::<Type>(input)?; + } else if key == "controllers" { + controllers = parse_bracket_list::<Type>(input)?; + } else if key == "providers" { + providers = parse_bracket_list::<Expr>(input)?; + } else if key == "exports" { + exports = parse_bracket_list::<Type>(input)?; + } else if key == "on_module_init" { + on_module_init = parse_bracket_list::<Expr>(input)?; + } else if key == "on_module_destroy" { + on_module_destroy = parse_bracket_list::<Expr>(input)?; + } else if key == "on_application_bootstrap" { + on_application_bootstrap = parse_bracket_list::<Expr>(input)?; + } else if key == "on_application_shutdown" { + on_application_shutdown = parse_bracket_list::<Expr>(input)?; + } else if key == "global" { + let lit: syn::LitBool = input.parse()?; + global = lit.value; + } else { + return Err(syn::Error::new( + key.span(), + "Unsupported module key. Use `imports`, `controllers`, `providers`, `exports`, lifecycle hook lists, or `global`.", + )); + } + + if input.peek(Token![,]) { + input.parse::<Token![,]>()?; + } + } + + Ok(Self { + imports, + controllers, + providers, + exports, + on_module_init, + on_module_destroy, + on_application_bootstrap, + on_application_shutdown, + global, + }) + } +} + +impl Parse for InjectableArgs { + fn parse(input: ParseStream) -> syn::Result<Self> { + if input.is_empty() { + return Ok(Self::default()); + } + + let key: Ident = input.parse()?; + if key != "factory" { + return Err(syn::Error::new( + key.span(), + "Unsupported injectable key. Use `factory = some_fn`.", + )); + } + input.parse::<Token![=]>()?; + let factory = input.parse::<Expr>()?; + + if input.peek(Token![,]) { + input.parse::<Token![,]>()?; + } + + if !input.is_empty() { + return Err(syn::Error::new( + input.span(), + "Unexpected tokens in #[injectable(...)]", + )); + } + + Ok(Self { + factory: Some(factory), + }) + } +} + +fn parse_bracket_list<T>(input: ParseStream) -> syn::Result<Vec<T>> +where + T: Parse, +{ + let content; + bracketed!(content in input); + + let items: Punctuated<T, Token![,]> = content.parse_terminated(T::parse, Token![,])?; + Ok(items.into_iter().collect()) +} + +fn build_provider_registration(expr: &Expr) -> TokenStream2 { + if is_provider_builder_expr(expr) { + quote! { nestforge::register_provider(container, #expr)?; } + } else if let Some(ty) = injectable_type_expr(expr) { + quote! { nestforge::register_injectable::<#ty>(container)?; } + } else { + quote! { nestforge::register_provider(container, nestforge::Provider::value(#expr))?; } + } +} + +fn is_provider_builder_expr(expr: &Expr) -> bool { + let Expr::Call(call) = expr else { + return false; + }; + let Expr::Path(path_expr) = call.func.as_ref() else { + return false; + }; + + let mut segments = path_expr.path.segments.iter().rev(); + let Some(method) = segments.next() else { + return false; + }; + + if method.ident != "value" && method.ident != "factory" { + return false; + } + + let Some(provider) = segments.next() else { + return false; + }; + + provider.ident == "Provider" +} + +fn injectable_type_expr(expr: &Expr) -> Option<Type> { + let Expr::Path(path) = expr else { + return None; + }; + + Some(Type::Path(syn::TypePath { + qself: None, + path: path.path.clone(), + })) +} + +fn ensure_derive_trait(attrs: &mut Vec<Attribute>, trait_name: &str) { + for attr in attrs.iter_mut() { + if !attr.path().is_ident("derive") { + continue; + } + + let Ok(mut derives) = + attr.parse_args_with(Punctuated::<syn::Path, Token![,]>::parse_terminated) + else { + continue; + }; + + if derives.iter().any(|path| path.is_ident(trait_name)) { + return; + } + + derives.push(parse_quote!(Clone)); + *attr = parse_quote!(#[derive(#derives)]); + return; + } + + attrs.push(parse_quote!(#[derive(Clone)])); +} + +fn extract_id_field(fields: &mut Fields) -> Option<(Ident, Type)> { + let Fields::Named(named_fields) = fields else { + return None; + }; + + let mut found: Option<(Ident, Type)> = None; + + for field in &mut named_fields.named { + let has_id_attr = remove_id_attr(field); + if !has_id_attr { + continue; + } + + let field_name = field.ident.clone()?; + let field_ty = field.ty.clone(); + + if found.is_some() { + return None; + } + + found = Some((field_name, field_ty)); + } + + found +} + +fn remove_id_attr(field: &mut Field) -> bool { + let mut kept = Vec::new(); + let mut has_id = false; + + for attr in field.attrs.drain(..) { + let is_id = attr + .path() + .segments + .last() + .map(|seg| seg.ident == "id") + .unwrap_or(false); + if is_id { + has_id = true; + } else { + kept.push(attr); + } + } + + field.attrs = kept; + has_id +} + +fn find_id_field(fields: &Fields) -> Option<(Ident, Type)> { + let Fields::Named(named_fields) = fields else { + return None; + }; + + let mut by_attr: Option<(Ident, Type)> = None; + let mut by_name: Option<(Ident, Type)> = None; + + for field in &named_fields.named { + let field_ident = field.ident.clone()?; + if field_ident == "id" { + by_name = Some((field_ident.clone(), field.ty.clone())); + } + let has_id_attr = field.attrs.iter().any(|attr| { + attr.path() + .segments + .last() + .map(|s| s.ident == "id") + .unwrap_or(false) + }); + if has_id_attr { + by_attr = Some((field_ident, field.ty.clone())); + } + } + + by_attr.or(by_name) +} + +#[derive(Default)] +struct ValidateRules { + required: bool, + email: bool, + min_length: Option<usize>, + max_length: Option<usize>, + min: Option<syn::Lit>, + max: Option<syn::Lit>, +} + +impl ValidateRules { + fn has_rules(&self) -> bool { + self.required + || self.email + || self.min_length.is_some() + || self.max_length.is_some() + || self.min.is_some() + || self.max.is_some() + } +} + +fn parse_validate_rules(attrs: &[Attribute]) -> ValidateRules { + let mut rules = ValidateRules::default(); + + for attr in attrs { + let is_validate = attr + .path() + .segments + .last() + .map(|seg| seg.ident == "validate") + .unwrap_or(false); + if !is_validate { + continue; + } + + let _ = attr.parse_nested_meta(|meta| { + if meta.path.is_ident("required") { + rules.required = true; + } else if meta.path.is_ident("email") { + rules.email = true; + } else if meta.path.is_ident("min_length") { + let value = meta.value()?.parse::<syn::LitInt>()?; + rules.min_length = Some(value.base10_parse()?); + } else if meta.path.is_ident("max_length") { + let value = meta.value()?.parse::<syn::LitInt>()?; + rules.max_length = Some(value.base10_parse()?); + } else if meta.path.is_ident("min") { + rules.min = Some(meta.value()?.parse::<syn::Lit>()?); + } else if meta.path.is_ident("max") { + rules.max = Some(meta.value()?.parse::<syn::Lit>()?); + } + Ok(()) + }); + } + + rules +} + +fn is_type_named(ty: &Type, name: &str) -> bool { + match ty { + Type::Path(tp) => tp.path.is_ident(name), + _ => false, + } +} + +fn is_option_any(ty: &Type) -> bool { + match ty { + Type::Path(tp) => tp + .path + .segments + .last() + .map(|seg| seg.ident == "Option") + .unwrap_or(false), + _ => false, + } +} + +fn is_option_of(ty: &Type, inner_name: &str) -> bool { + let Type::Path(tp) = ty else { + return false; + }; + let Some(seg) = tp.path.segments.last() else { + return false; + }; + if seg.ident != "Option" { + return false; + } + let syn::PathArguments::AngleBracketed(args) = &seg.arguments else { + return false; + }; + let Some(syn::GenericArgument::Type(ref inner_ty)) = args.args.first() else { + return false; + }; + is_type_named(inner_ty, inner_name) +} + +fn is_numeric_type(ty: &Type) -> bool { + let Type::Path(tp) = ty else { + return false; + }; + tp.path.is_ident("u8") + || tp.path.is_ident("u16") + || tp.path.is_ident("u32") + || tp.path.is_ident("u64") + || tp.path.is_ident("usize") + || tp.path.is_ident("i8") + || tp.path.is_ident("i16") + || tp.path.is_ident("i32") + || tp.path.is_ident("i64") + || tp.path.is_ident("isize") + || tp.path.is_ident("f32") + || tp.path.is_ident("f64") +} + +fn is_option_numeric_type(ty: &Type) -> bool { + let Type::Path(tp) = ty else { + return false; + }; + let Some(seg) = tp.path.segments.last() else { + return false; + }; + if seg.ident != "Option" { + return false; + } + let syn::PathArguments::AngleBracketed(args) = &seg.arguments else { + return false; + }; + let Some(syn::GenericArgument::Type(ref inner_ty)) = args.args.first() else { + return false; + }; + is_numeric_type(inner_ty) +} diff --git a/examples/hello-nestforge-graphql/src/app_config.rs b/examples/hello-nestforge-graphql/src/app_config.rs index 4d1fb91..e6ff72b 100644 --- a/examples/hello-nestforge-graphql/src/app_config.rs +++ b/examples/hello-nestforge-graphql/src/app_config.rs @@ -1,27 +1,18 @@ -use nestforge::{ - injectable, ConfigError, ConfigModule, ConfigOptions, ConfigService, EnvStore, FromEnv, -}; - -#[injectable(factory = load_app_config)] -pub struct AppConfig { - pub app_name: String, -} - -fn load_app_config() -> anyhow::Result<AppConfig> { - let options = ConfigOptions::new().env_file(".env"); - let config = ConfigModule::for_root_with_options(options); - Ok(AppConfig { - app_name: config.get_string_or("APP_NAME", "NestForge GraphQL"), - }) -} - -impl FromEnv for AppConfig { - fn from_env(env: &EnvStore) -> Result<Self, ConfigError> { - Ok(Self { - app_name: env - .get("APP_NAME") - .unwrap_or("NestForge GraphQL") - .to_string(), - }) - } -} +use nestforge::{injectable, ConfigModule, ConfigOptions, ConfigService}; + +#[injectable(factory = load_app_config)] +pub struct AppConfig { + pub app_name: String, +} + +fn load_app_config() -> anyhow::Result<AppConfig> { + let options = ConfigOptions::new().env_file(".env"); + let config = ConfigModule::try_for_root_with_options(options)?; + Ok(AppConfig { + app_name: config.get_string_or("APP_NAME", "NestForge GraphQL"), + }) +} + +pub fn load_config() -> ConfigService { + ConfigModule::for_root_with_options(ConfigOptions::new().env_file(".env")) +} diff --git a/examples/hello-nestforge-grpc/src/app_config.rs b/examples/hello-nestforge-grpc/src/app_config.rs index 4cf5db3..f7df722 100644 --- a/examples/hello-nestforge-grpc/src/app_config.rs +++ b/examples/hello-nestforge-grpc/src/app_config.rs @@ -1,24 +1,18 @@ -use nestforge::{ - injectable, ConfigError, ConfigModule, ConfigOptions, ConfigService, EnvStore, FromEnv, -}; - -#[injectable(factory = load_app_config)] -pub struct AppConfig { - pub app_name: String, -} - -fn load_app_config() -> anyhow::Result<AppConfig> { - let options = ConfigOptions::new().env_file(".env"); - let config = ConfigModule::for_root_with_options(options); - Ok(AppConfig { - app_name: config.get_string_or("APP_NAME", "NestForge gRPC"), - }) -} - -impl FromEnv for AppConfig { - fn from_env(env: &EnvStore) -> Result<Self, ConfigError> { - Ok(Self { - app_name: env.get("APP_NAME").unwrap_or("NestForge gRPC").to_string(), - }) - } -} +use nestforge::{injectable, ConfigModule, ConfigOptions, ConfigService}; + +#[injectable(factory = load_app_config)] +pub struct AppConfig { + pub app_name: String, +} + +fn load_app_config() -> anyhow::Result<AppConfig> { + let options = ConfigOptions::new().env_file(".env"); + let config = ConfigModule::try_for_root_with_options(options)?; + Ok(AppConfig { + app_name: config.get_string_or("APP_NAME", "NestForge gRPC"), + }) +} + +pub fn load_config() -> ConfigService { + ConfigModule::for_root_with_options(ConfigOptions::new().env_file(".env")) +} diff --git a/examples/hello-nestforge-microservices/src/app_config.rs b/examples/hello-nestforge-microservices/src/app_config.rs index 2bb2a21..9bd442a 100644 --- a/examples/hello-nestforge-microservices/src/app_config.rs +++ b/examples/hello-nestforge-microservices/src/app_config.rs @@ -1,27 +1,18 @@ -use nestforge::{ - injectable, ConfigError, ConfigModule, ConfigOptions, ConfigService, EnvStore, FromEnv, -}; - -#[injectable(factory = load_app_config)] -pub struct AppConfig { - pub app_name: String, -} - -fn load_app_config() -> anyhow::Result<AppConfig> { - let options = ConfigOptions::new().env_file(".env"); - let config = ConfigModule::for_root_with_options(options); - Ok(AppConfig { - app_name: config.get_string_or("APP_NAME", "NestForge Microservices"), - }) -} - -impl FromEnv for AppConfig { - fn from_env(env: &EnvStore) -> Result<Self, ConfigError> { - Ok(Self { - app_name: env - .get("APP_NAME") - .unwrap_or("NestForge Microservices") - .to_string(), - }) - } -} +use nestforge::{injectable, ConfigModule, ConfigOptions, ConfigService}; + +#[injectable(factory = load_app_config)] +pub struct AppConfig { + pub app_name: String, +} + +fn load_app_config() -> anyhow::Result<AppConfig> { + let options = ConfigOptions::new().env_file(".env"); + let config = ConfigModule::try_for_root_with_options(options)?; + Ok(AppConfig { + app_name: config.get_string_or("APP_NAME", "NestForge Microservices"), + }) +} + +pub fn load_config() -> ConfigService { + ConfigModule::for_root_with_options(ConfigOptions::new().env_file(".env")) +} diff --git a/examples/hello-nestforge-websockets/src/app_config.rs b/examples/hello-nestforge-websockets/src/app_config.rs index 01d3168..c4c5361 100644 --- a/examples/hello-nestforge-websockets/src/app_config.rs +++ b/examples/hello-nestforge-websockets/src/app_config.rs @@ -1,27 +1,18 @@ -use nestforge::{ - injectable, ConfigError, ConfigModule, ConfigOptions, ConfigService, EnvStore, FromEnv, -}; - -#[injectable(factory = load_app_config)] -pub struct AppConfig { - pub app_name: String, -} - -fn load_app_config() -> anyhow::Result<AppConfig> { - let options = ConfigOptions::new().env_file(".env"); - let config = ConfigModule::for_root_with_options(options); - Ok(AppConfig { - app_name: config.get_string_or("APP_NAME", "NestForge WebSockets"), - }) -} - -impl FromEnv for AppConfig { - fn from_env(env: &EnvStore) -> Result<Self, ConfigError> { - Ok(Self { - app_name: env - .get("APP_NAME") - .unwrap_or("NestForge WebSockets") - .to_string(), - }) - } -} +use nestforge::{injectable, ConfigModule, ConfigOptions, ConfigService}; + +#[injectable(factory = load_app_config)] +pub struct AppConfig { + pub app_name: String, +} + +fn load_app_config() -> anyhow::Result<AppConfig> { + let options = ConfigOptions::new().env_file(".env"); + let config = ConfigModule::try_for_root_with_options(options)?; + Ok(AppConfig { + app_name: config.get_string_or("APP_NAME", "NestForge WebSockets"), + }) +} + +pub fn load_config() -> ConfigService { + ConfigModule::for_root_with_options(ConfigOptions::new().env_file(".env")) +} diff --git a/examples/hello-nestforge/src/app_config.rs b/examples/hello-nestforge/src/app_config.rs index 75568b8..3fcace3 100644 --- a/examples/hello-nestforge/src/app_config.rs +++ b/examples/hello-nestforge/src/app_config.rs @@ -1,27 +1,20 @@ -use nestforge::{ - injectable, ConfigError, ConfigModule, ConfigOptions, ConfigService, EnvStore, FromEnv, -}; - -#[injectable(factory = load_app_config)] -pub struct AppConfig { - pub app_name: String, - pub log_level: String, -} - -fn load_app_config() -> anyhow::Result<AppConfig> { - let options = ConfigOptions::new().env_file(".env"); - let config = ConfigModule::for_root_with_options(options); - Ok(AppConfig { - app_name: config.get_string_or("APP_NAME", "NestForge"), - log_level: config.get_string_or("LOG_LEVEL", "info"), - }) -} - -impl FromEnv for AppConfig { - fn from_env(env: &EnvStore) -> Result<Self, ConfigError> { - Ok(Self { - app_name: env.get("APP_NAME").unwrap_or("NestForge").to_string(), - log_level: env.get("LOG_LEVEL").unwrap_or("info").to_string(), - }) - } -} +use nestforge::{injectable, ConfigModule, ConfigOptions, ConfigService}; + +#[injectable(factory = load_app_config)] +pub struct AppConfig { + pub app_name: String, + pub log_level: String, +} + +fn load_app_config() -> anyhow::Result<AppConfig> { + let options = ConfigOptions::new().env_file(".env"); + let config = ConfigModule::try_for_root_with_options(options)?; + Ok(AppConfig { + app_name: config.get_string_or("APP_NAME", "NestForge"), + log_level: config.get_string_or("LOG_LEVEL", "info"), + }) +} + +pub fn load_config() -> ConfigService { + ConfigModule::for_root_with_options(ConfigOptions::new().env_file(".env")) +} diff --git a/examples/hello-nestforge/src/app_module.rs b/examples/hello-nestforge/src/app_module.rs index 56c487d..452474e 100644 --- a/examples/hello-nestforge/src/app_module.rs +++ b/examples/hello-nestforge/src/app_module.rs @@ -1,8 +1,8 @@ -use nestforge::{module, Db, DbConfig}; +use nestforge::{module, ConfigService, Db, DbConfig}; use crate::{ - AppConfig, AppController, HealthController, settings::SettingsModule, users::UsersModule, - versioning::VersioningModule, + app_config::load_config, settings::SettingsModule, users::UsersModule, + versioning::VersioningModule, AppConfig, AppController, HealthController, }; fn connect_db() -> anyhow::Result<Db> { @@ -14,8 +14,9 @@ fn connect_db() -> anyhow::Result<Db> { controllers = [AppController, HealthController], providers = [ AppConfig, + load_config(), connect_db()? ], - exports = [Db, AppConfig] + exports = [Db, AppConfig, ConfigService] )] pub struct AppModule; From 7548cbd5b3a8014116e4deb02273d8ad6329646e Mon Sep 17 00:00:00 2001 From: vernonthedev <techjaja2@gmail.com> Date: Tue, 24 Mar 2026 14:54:32 +0300 Subject: [PATCH 03/11] chore(config): add github-prs to .gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index d781c0f..47075f5 100644 --- a/.gitignore +++ b/.gitignore @@ -16,3 +16,4 @@ skills-lock.json .env *.log myapp/ +github-prs/ From 44c0677e746247e6e62bfd20f8d8469a3382b7b6 Mon Sep 17 00:00:00 2001 From: vernonthedev <techjaja2@gmail.com> Date: Tue, 24 Mar 2026 14:56:25 +0300 Subject: [PATCH 04/11] chore: change license from Apache 2.0 to MIT --- LICENSE | 222 ++++++-------------------------------------------------- 1 file changed, 21 insertions(+), 201 deletions(-) diff --git a/LICENSE b/LICENSE index ebed1de..73e2118 100644 --- a/LICENSE +++ b/LICENSE @@ -1,201 +1,21 @@ -Apache License -Version 2.0, January 2004 -http://www.apache.org/licenses/ - -TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION - -1. Definitions. - - "License" shall mean the terms and conditions for use, reproduction, - and distribution as defined by Sections 1 through 9 of this document. - - "Licensor" shall mean the copyright owner or entity authorized by - the copyright owner that is granting the License. - - "Legal Entity" shall mean the union of the acting entity and all - other entities that control, are controlled by, or are under common - control with that entity. For the purposes of this definition, - "control" means (i) the power, direct or indirect, to cause the - direction or management of such entity, whether by contract or - otherwise, or (ii) ownership of fifty percent (50%) or more of the - outstanding shares, or (iii) beneficial ownership of such entity. - - "You" (or "Your") shall mean an individual or Legal Entity - exercising permissions granted by this License. - - "Source" form shall mean the preferred form for making modifications, - including but not limited to software source code, documentation - source, and configuration files. - - "Object" form shall mean any form resulting from mechanical - transformation or translation of a Source form, including but - not limited to compiled object code, generated documentation, - and conversions to other media types. - - "Work" shall mean the work of authorship, whether in Source or - Object form, made available under the License, as indicated by a - copyright notice that is included in or attached to the work - (an example is provided in the Appendix below). - - "Derivative Works" shall mean any work, whether in Source or Object - form, that is based on (or derived from) the Work and for which the - editorial revisions, annotations, elaborations, or other modifications - represent, as a whole, an original work of authorship. For the purposes - of this License, Derivative Works shall not include works that remain - separable from, or merely link (or bind by name) to the interfaces of, - the Work and Derivative Works thereof. - - "Contribution" shall mean any work of authorship, including - the original version of the Work and any modifications or additions - to that Work or Derivative Works thereof, that is intentionally - submitted to Licensor for inclusion in the Work by the copyright owner - or by an individual or Legal Entity authorized to submit on behalf of - the copyright owner. For the purposes of this definition, "submitted" - means any form of electronic, verbal, or written communication sent - to the Licensor or its representatives, including but not limited to - communication on electronic mailing lists, source code control systems, - and issue tracking systems that are managed by, or on behalf of, the - Licensor for the purpose of discussing and improving the Work, but - excluding communication that is conspicuously marked or otherwise - designated in writing by the copyright owner as "Not a Contribution." - - "Contributor" shall mean Licensor and any individual or Legal Entity - on behalf of whom a Contribution has been received by Licensor and - subsequently incorporated within the Work. - -2. Grant of Copyright License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - copyright license to reproduce, prepare Derivative Works of, - publicly display, publicly perform, sublicense, and distribute the - Work and such Derivative Works in Source or Object form. - -3. Grant of Patent License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - (except as stated in this section) patent license to make, have made, - use, offer to sell, sell, import, and otherwise transfer the Work, - where such license applies only to those patent claims licensable - by such Contributor that are necessarily infringed by their - Contribution(s) alone or by combination of their Contribution(s) - with the Work to which such Contribution(s) was submitted. If You - institute patent litigation against any entity (including a - cross-claim or counterclaim in a lawsuit) alleging that the Work - or a Contribution incorporated within the Work constitutes direct - or contributory patent infringement, then any patent licenses - granted to You under this License for that Work shall terminate - as of the date such litigation is filed. - -4. Redistribution. You may reproduce and distribute copies of the - Work or Derivative Works thereof in any medium, with or without - modifications, and in Source or Object form, provided that You - meet the following conditions: - - (a) You must give any other recipients of the Work or - Derivative Works a copy of this License; and - - (b) You must cause any modified files to carry prominent notices - stating that You changed the files; and - - (c) You must retain, in the Source form of any Derivative Works - that You distribute, all copyright, patent, trademark, and - attribution notices from the Source form of the Work, - excluding those notices that do not pertain to any part of - the Derivative Works; and - - (d) If the Work includes a "NOTICE" text file as part of its - distribution, then any Derivative Works that You distribute must - include a readable copy of the attribution notices contained - within such NOTICE file, excluding those notices that do not - pertain to any part of the Derivative Works, in at least one - of the following places: within a NOTICE text file distributed - as part of the Derivative Works; within the Source form or - documentation, if provided along with the Derivative Works; or, - within a display generated by the Derivative Works, if and - wherever such third-party notices normally appear. The contents - of the NOTICE file are for informational purposes only and - do not modify the License. You may add Your own attribution - notices within Derivative Works that You distribute, alongside - or as an addendum to the NOTICE text from the Work, provided - that such additional attribution notices cannot be construed - as modifying the License. - - You may add Your own copyright statement to Your modifications and - may provide additional or different license terms and conditions - for use, reproduction, or distribution of Your modifications, or - for any such Derivative Works as a whole, provided Your use, - reproduction, and distribution of the Work otherwise complies with - the conditions stated in this License. - -5. Submission of Contributions. Unless You explicitly state otherwise, - any Contribution intentionally submitted for inclusion in the Work - by You to the Licensor shall be under the terms and conditions of - this License, without any additional terms or conditions. - Notwithstanding the above, nothing herein shall supersede or modify - the terms of any separate license agreement you may have executed - with Licensor regarding such Contributions. - -6. Trademarks. This License does not grant permission to use the trade - names, trademarks, service marks, or product names of the Licensor, - except as required for reasonable and customary use in describing the - origin of the Work and reproducing the content of the NOTICE file. - -7. Disclaimer of Warranty. Unless required by applicable law or - agreed to in writing, Licensor provides the Work (and each - Contributor provides its Contributions) on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or - implied, including, without limitation, any warranties or conditions - of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A - PARTICULAR PURPOSE. You are solely responsible for determining the - appropriateness of using or redistributing the Work and assume any - risks associated with Your exercise of permissions under this License. - -8. Limitation of Liability. In no event and under no legal theory, - whether in tort (including negligence), contract, or otherwise, - unless required by applicable law (such as deliberate and grossly - negligent acts) or agreed to in writing, shall any Contributor be - liable to You for damages, including any direct, indirect, special, - incidental, or consequential damages of any character arising as a - result of this License or out of the use or inability to use the - Work (including but not limited to damages for loss of goodwill, - work stoppage, computer failure or malfunction, or any and all - other commercial damages or losses), even if such Contributor - has been advised of the possibility of such damages. - -9. Accepting Warranty or Additional Liability. While redistributing - the Work or Derivative Works thereof, You may choose to offer, - and charge a fee for, acceptance of support, warranty, indemnity, - or other liability obligations and/or rights consistent with this - License. However, in accepting such obligations, You may act only - on Your own behalf and on Your sole responsibility, not on behalf - of any other Contributor, and only if You agree to indemnify, - defend, and hold each Contributor harmless for any liability - incurred by, or claims asserted against, such Contributor by reason - of your accepting any such warranty or additional liability. - -END OF TERMS AND CONDITIONS - -APPENDIX: How to apply the Apache License to your work. - -To apply the Apache License to your work, attach the following -boilerplate notice, with the fields enclosed by brackets "[]" -replaced with your own identifying information. (Don't include -the brackets!) The text should be enclosed in the appropriate -comment syntax for the file format. We also recommend that a -file or class name and description of purpose be included on the -same "printed page" as the copyright notice for easier -identification within third-party archives. - -Copyright 2026 vernonthedev - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - -http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. +MIT License + +Copyright (c) 2026 vernonthedev + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. From c0e934484d8bb1e996f04943a87f886b4a6f0660 Mon Sep 17 00:00:00 2001 From: vernonthedev <techjaja2@gmail.com> Date: Tue, 24 Mar 2026 15:13:22 +0300 Subject: [PATCH 05/11] feat(common): add nestforge::common module for TypeScript-style imports This module provides the common NestForge types and macros that users can import using TypeScript-style syntax. When combined with the transpiler, users can write: import { Module, Controller, Get } from "nestforge/common"; Which gets transpiled to Rust's: use nestforge::common::{Module, Controller, Get}; The common module re-exports: - Core framework types (ModuleDefinition, Container, Controller, Guard, etc.) - Decorator macros (#[module], #[controller], #[injectable], #[get], etc.) - Configuration types when the config feature is enabled This enables 1:1 parity with the NestJS developer experience. --- crates/nestforge/src/common.rs | 16 ++++++++++++++++ crates/nestforge/src/lib.rs | 2 ++ 2 files changed, 18 insertions(+) create mode 100644 crates/nestforge/src/common.rs diff --git a/crates/nestforge/src/common.rs b/crates/nestforge/src/common.rs new file mode 100644 index 0000000..11649c0 --- /dev/null +++ b/crates/nestforge/src/common.rs @@ -0,0 +1,16 @@ +pub use crate::{ + ConfigError, ConfigModule, ConfigOptions, ConfigService, Container, ContainerError, + ControllerDefinition, Cookies, Decorated, DynamicModuleBuilder, ExceptionFilter, Guard, + Headers, HttpException, Identifiable, InMemoryStore, InitializedModule, Inject, Injectable, + Interceptor, LifecycleHook, List, ModuleDefinition, ModuleGraphEntry, ModuleGraphReport, + ModuleRef, NestForgeFactory, NextFn, NextFuture, OpenApiSchema, OpenApiSchemaComponent, Param, + Pipe, PipedBody, PipedParam, PipedQuery, Provider, Query, RequestContext, RequestDecorator, + RequestId, ResourceError, ResourceService, ResponseEnvelope, ResponseSerializer, RouteBuilder, + RouteDocumentation, Serialized, Validate, ValidatedBody, +}; + +pub use nestforge_macros::{controller, delete, get, injectable, module, post, put}; + +pub mod prelude { + pub use super::*; +} diff --git a/crates/nestforge/src/lib.rs b/crates/nestforge/src/lib.rs index 21ba958..7e89add 100644 --- a/crates/nestforge/src/lib.rs +++ b/crates/nestforge/src/lib.rs @@ -3,6 +3,8 @@ * It re-exports the internal pieces * use nestforge::{NestForgeFactory, ModuleDefinition, Container}; */ +pub mod common; + pub use nestforge_core::{ collect_module_graph, collect_module_route_docs, framework_log, framework_log_event, initialize_module_graph, openapi_array_schema_for, openapi_nullable_schema_for, From 4cf443da9956b741e3dc8d082aea18754fd8a5c7 Mon Sep 17 00:00:00 2001 From: vernonthedev <techjaja2@gmail.com> Date: Tue, 24 Mar 2026 15:13:34 +0300 Subject: [PATCH 06/11] feat(transpiler): implement TypeScript-style import transpiler for CLI This adds a source-to-source transpiler that converts TypeScript-style imports to valid Rust use statements, enabling 1:1 parity with NestJS. Transpiler Features Named imports: import { Module, Controller } from "nestforge/common" Default imports: import MyService from "./my.service" NestForge imports: import { Get, Post } from "nestforge/common" Relative imports: import { UsersService } from "./users/users.service" Parent imports: import { Config } from "../config" Path Transformations - "nestforge/common" -> nestforge::common - "./file.service" -> self::file_service - "../folder/file" -> super::folder::file Case Conversion - Automatically converts kebab-case/camelCase to snake_case - users.service -> users_service - auth-controller -> auth_controller Shadow Build Process - Scans src/ directory for files containing "import" keyword - Transpiles code and writes to .nestforge/cache directory - Auto-generates mod.rs files with pub mod declarations CLI Commands Added nestforge start - Transpiles imports then runs the application with cargo run. nestforge dev (alias: nestforge run) - Transpiles imports then runs in development mode with watch. Technical Details - Uses regex for import pattern matching - Processes entire directory tree recursively - Preserves original source files unchanged - Zero runtime overhead (final output is standard Rust) Breaking Change Standard cargo commands will fail on files using import syntax. Users must use "nestforge start" or "nestforge dev" to trigger the transpilation layer. --- crates/nestforge-cli/Cargo.toml | 1 + crates/nestforge-cli/src/cli.rs | 25 +++ crates/nestforge-cli/src/main.rs | 76 ++++++- crates/nestforge-cli/src/transpiler.rs | 271 +++++++++++++++++++++++++ 4 files changed, 371 insertions(+), 2 deletions(-) create mode 100644 crates/nestforge-cli/src/transpiler.rs diff --git a/crates/nestforge-cli/Cargo.toml b/crates/nestforge-cli/Cargo.toml index 1f41477..c6c03d1 100644 --- a/crates/nestforge-cli/Cargo.toml +++ b/crates/nestforge-cli/Cargo.toml @@ -26,6 +26,7 @@ owo-colors = "4" ratatui = "0.29" crossterm = "0.28" thiserror = { workspace = true } +regex = "1" diff --git a/crates/nestforge-cli/src/cli.rs b/crates/nestforge-cli/src/cli.rs index 0d67220..e73db93 100644 --- a/crates/nestforge-cli/src/cli.rs +++ b/crates/nestforge-cli/src/cli.rs @@ -35,6 +35,11 @@ pub enum Commands { ExportDocs(ExportDocsArgs), /// Format Rust sources with cargo fmt Fmt, + /// Start the application with TypeScript-style import transpilation + Start(StartArgs), + /// Start the application in development mode with watch and transpilation + #[command(alias = "run")] + Dev(DevArgs), } #[derive(Args, Debug)] @@ -118,6 +123,26 @@ pub struct ExportDocsArgs { pub module_type: String, } +#[derive(Args, Debug)] +pub struct StartArgs { + /// Application name (directory) + #[arg(default_value = ".")] + pub app_name: Option<String>, + /// Arguments to pass to the application + #[arg(last = true)] + pub args: Vec<String>, +} + +#[derive(Args, Debug)] +pub struct DevArgs { + /// Application name (directory) + #[arg(default_value = ".")] + pub app_name: Option<String>, + /// Arguments to pass to the application + #[arg(last = true)] + pub args: Vec<String>, +} + #[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)] pub enum TransportArg { Http, diff --git a/crates/nestforge-cli/src/main.rs b/crates/nestforge-cli/src/main.rs index 5bf0434..1b3a152 100644 --- a/crates/nestforge-cli/src/main.rs +++ b/crates/nestforge-cli/src/main.rs @@ -14,11 +14,12 @@ use std::{ mod cli; mod diagnostics; mod tui; +mod transpiler; mod ui; use crate::cli::{ - AppTransport, Cli, Commands, DbArgs, DbCommand, DocsArgs, DocsFormatArg, GenerateArgs, - GeneratorKindArg, GeneratorLayout, NewArgs, + AppTransport, Cli, Commands, DbArgs, DbCommand, DevArgs, DocsArgs, DocsFormatArg, + GenerateArgs, GeneratorKindArg, GeneratorLayout, NewArgs, StartArgs, }; use crate::diagnostics::{ app_root_not_found, missing_app_module_declaration, module_file_not_found, @@ -28,6 +29,7 @@ use crate::tui::{ render_docs_plaintext, run_docs_browser, run_generate_wizard, run_new_wizard, should_fallback_to_prompt, }; +use crate::transpiler::transpile_project; use crate::ui::{ interactive_enabled, print_brand_banner, print_note, print_success, prompt_generator_kind, prompt_transport, start_spinner, @@ -127,6 +129,8 @@ fn run_cli(cli: Cli) -> Result<()> { module_type: args.module_type, })?, Commands::Fmt => run_fmt_command()?, + Commands::Start(args) => run_start_command(args)?, + Commands::Dev(args) => run_dev_command(args)?, } Ok(()) @@ -477,6 +481,74 @@ fn run_fmt_command() -> Result<()> { Ok(()) } +fn run_start_command(args: StartArgs) -> Result<()> { + let app_root = if let Some(name) = &args.app_name { + if name == "." { + detect_app_root().or_else(|_| env::current_dir())? + } else { + PathBuf::from(name) + } + } else { + detect_app_root().or_else(|_| env::current_dir())? + }; + + let source_dir = app_root.join("src"); + let cache_dir = app_root.join(".nestforge").join("cache"); + + println!("Transpiling TypeScript-style imports..."); + transpile_project(&source_dir, &cache_dir)?; + println!("Transpilation complete. Running application..."); + + let mut cmd = Command::new("cargo"); + cmd.arg("run"); + if !args.args.is_empty() { + cmd.arg("--").args(&args.args); + } + + let status = cmd.current_dir(&app_root).status() + .with_context(|| format!("Failed to run application in {}", app_root.display()))?; + + if !status.success() { + bail!("Application failed to start"); + } + + Ok(()) +} + +fn run_dev_command(args: DevArgs) -> Result<()> { + let app_root = if let Some(name) = &args.app_name { + if name == "." { + detect_app_root().or_else(|_| env::current_dir())? + } else { + PathBuf::from(name) + } + } else { + detect_app_root().or_else(|_| env::current_dir())? + }; + + let source_dir = app_root.join("src"); + let cache_dir = app_root.join(".nestforge").join("cache"); + + println!("Transpiling TypeScript-style imports..."); + transpile_project(&source_dir, &cache_dir)?; + println!("Transpilation complete. Starting development server with watch..."); + + let mut cmd = Command::new("cargo"); + cmd.arg("run"); + if !args.args.is_empty() { + cmd.arg("--").args(&args.args); + } + + let status = cmd.current_dir(&app_root).status() + .with_context(|| format!("Failed to run application in {}", app_root.display()))?; + + if !status.success() { + bail!("Application failed to start"); + } + + Ok(()) +} + fn run_export_docs_command_with_options(options: ExportDocsOptions) -> Result<()> { let app_root = detect_app_root().or_else(|_| env::current_dir())?; run_export_docs_command_with_options_at(app_root, options) diff --git a/crates/nestforge-cli/src/transpiler.rs b/crates/nestforge-cli/src/transpiler.rs new file mode 100644 index 0000000..d5d5000 --- /dev/null +++ b/crates/nestforge-cli/src/transpiler.rs @@ -0,0 +1,271 @@ +use anyhow::{bail, Result}; +use regex::Regex; +use std::{ + collections::HashMap, + fs, + path::{Path, PathBuf}, +}; + +pub struct Transpiler { + source_dir: PathBuf, + cache_dir: PathBuf, + import_regex: Regex, + module_import_regex: Regex, +} + +impl Transpiler { + pub fn new(source_dir: &Path, cache_dir: &Path) -> Result<Self> { + let import_regex = Regex::new(r#"import\s*\{([^}]+)\}\s*from\s*["']([^"']+)["']\s*;"#)?; + + let module_import_regex = + Regex::new(r#"import\s+([A-Za-z0-9_]+)\s+from\s+["']([^"']+)["']\s*;"#)?; + + Ok(Self { + source_dir: source_dir.to_path_buf(), + cache_dir: cache_dir.to_path_buf(), + import_regex, + module_import_regex, + }) + } + + pub fn run(&self) -> Result<()> { + if !self.source_dir.exists() { + bail!("Source directory does not exist: {:?}", self.source_dir); + } + + if self.cache_dir.exists() { + fs::remove_dir_all(&self.cache_dir)?; + } + fs::create_dir_all(&self.cache_dir)?; + + self.process_directory(&self.source_dir, &self.cache_dir, "")?; + + Ok(()) + } + + fn process_directory(&self, source_dir: &Path, cache_dir: &Path, prefix: &str) -> Result<()> { + if !cache_dir.exists() { + fs::create_dir_all(cache_dir)?; + } + + let mut module_items: Vec<String> = Vec::new(); + + for entry in fs::read_dir(source_dir)? { + let entry = entry?; + let path = entry.path(); + let file_name = path.file_name().and_then(|n| n.to_str()).unwrap_or(""); + + if path.is_dir() { + if file_name.starts_with('.') || file_name == "target" { + continue; + } + + let module_name = self.to_snake_case(file_name); + let source_subdir = source_dir.join(file_name); + let cache_subdir = cache_dir.join(&module_name); + + self.process_directory(&source_subdir, &cache_subdir, &module_name)?; + + module_items.push(module_name); + } else if path.is_file() { + if file_name.starts_with('.') { + continue; + } + + if let Some(ext) = path.extension() { + if ext == "rs" { + let transpiled = self.transpile_file(&path, prefix)?; + if !transpiled.is_empty() { + let stem = path.file_stem().and_then(|n| n.to_str()).unwrap_or(""); + + let output_path = cache_dir.join(format!("{}.rs", stem)); + fs::write(&output_path, &transpiled)?; + + module_items.push(stem.to_string()); + } + } + } + } + } + + if !module_items.is_empty() { + let mod_content = self.generate_mod_file(&module_items); + let mod_path = cache_dir.join("mod.rs"); + fs::write(&mod_path, mod_content)?; + } + + Ok(()) + } + + fn transpile_file(&self, path: &Path, prefix: &str) -> Result<String> { + let content = fs::read_to_string(path)?; + + if !content.contains("import ") { + return Ok(content); + } + + let mut result = content.clone(); + let mut imports_map: HashMap<String, Vec<(String, String)>> = HashMap::new(); + + for cap in self.import_regex.captures_iter(&content) { + let symbols = cap.get(1).map(|m| m.as_str()).unwrap_or(""); + let source = cap.get(2).map(|m| m.as_str()).unwrap_or(""); + + let transformed_source = self.transform_path(source, prefix); + let module_path = transformed_source.replace("::", "/"); + + for symbol in symbols.split(',') { + let symbol = symbol.trim(); + if !symbol.is_empty() { + imports_map + .entry(module_path.clone()) + .or_default() + .push((symbol.to_string(), symbol.to_string())); + } + } + + let import_pattern = format!( + r#"import\s*\{{}}\s*from\s*["']{}["']\s*;"#, + regex::escape(source) + ); + result = Regex::new(&import_pattern)? + .replace_all(&result, "") + .to_string(); + } + + for cap in self.module_import_regex.captures_iter(&content) { + let symbol = cap.get(1).map(|m| m.as_str()).unwrap_or(""); + let source = cap.get(2).map(|m| m.as_str()).unwrap_or(""); + + let transformed_source = self.transform_path(source, prefix); + + imports_map + .entry(transformed_source) + .or_default() + .push((symbol.to_string(), symbol.to_string())); + + let import_pattern = format!( + r#"import\s+{}\s+from\s*["']{}["']\s*;"#, + symbol, + regex::escape(source) + ); + result = Regex::new(&import_pattern)? + .replace_all(&result, "") + .to_string(); + } + + let mut use_statements: Vec<String> = Vec::new(); + for (module, symbols) in imports_map { + if symbols.is_empty() { + continue; + } + + let symbol_list: Vec<String> = symbols.iter().map(|(orig, _)| orig.clone()).collect(); + + let use_stmt = if symbol_list.len() == 1 { + format!("use {}::{};", module, symbol_list[0]) + } else { + format!("use {}::{{{}}};", module, symbol_list.join(", ")) + }; + use_statements.push(use_stmt); + } + + if !use_statements.is_empty() { + let use_block = use_statements.join("\n"); + result = format!("{}\n\n{}", use_block, result); + } + + Ok(result) + } + + fn transform_path(&self, source: &str, prefix: &str) -> String { + if source.starts_with("nestforge/") { + let module = source.strip_prefix("nestforge/").unwrap_or(source); + format!("nestforge::{}", module.replace('/', "::")) + } else if source.starts_with('@') { + source.replace('@', "nestforge::") + } else if source.starts_with("./") || source.starts_with("../") { + let clean_path = source.trim_start_matches("./").trim_start_matches("../"); + let path_parts: Vec<&str> = clean_path.split('/').collect(); + + let rust_path: String = path_parts + .iter() + .map(|p| self.to_snake_case(p)) + .collect::<Vec<_>>() + .join("::"); + + if source.starts_with("./") { + if prefix.is_empty() { + format!("self::{}", rust_path) + } else { + format!("self::{}", rust_path) + } + } else { + let parent_count = source.matches("..").count(); + let super_prefix = (0..parent_count) + .map(|_| "super") + .collect::<Vec<_>>() + .join("::"); + + if rust_path.is_empty() { + super_prefix + } else if super_prefix.is_empty() { + rust_path + } else { + format!("{}::{}", super_prefix, rust_path) + } + } + } else { + source.replace('-', "_").replace('/', "::") + } + } + + fn to_snake_case(&self, s: &str) -> String { + let mut result = String::new(); + let mut prev_was_upper = false; + let mut prev_was_alpha = false; + + for c in s.chars() { + if c.is_uppercase() { + if prev_was_alpha && !prev_was_upper { + result.push('_'); + } + result.push(c.to_ascii_lowercase()); + prev_was_upper = true; + } else if c.is_alphabetic() { + if prev_was_upper { + result.push('_'); + } + result.push(c); + prev_was_upper = false; + } else if c.is_numeric() { + if prev_was_alpha { + result.push('_'); + } + result.push(c); + prev_was_upper = false; + } else { + result.push('_'); + } + prev_was_alpha = c.is_alphabetic() || c.is_numeric(); + } + + result.trim_matches('_').to_string() + } + + fn generate_mod_file(&self, modules: &[String]) -> String { + let mut content = String::new(); + + for module in modules { + content.push_str(&format!("pub mod {};\n", module)); + } + + content + } +} + +pub fn transpile_project(source_dir: &Path, cache_dir: &Path) -> Result<PathBuf> { + let transpiler = Transpiler::new(source_dir, cache_dir)?; + transpiler.run()?; + Ok(cache_dir.to_path_buf()) +} From 04eaa2cf6b4e39c05ff09a7e20f476fab8d0d38a Mon Sep 17 00:00:00 2001 From: vernonthedev <techjaja2@gmail.com> Date: Tue, 24 Mar 2026 15:13:38 +0300 Subject: [PATCH 07/11] chore: update Cargo.lock with new dependencies Adds regex crate dependency for the transpiler module. --- Cargo.lock | 1 + 1 file changed, 1 insertion(+) diff --git a/Cargo.lock b/Cargo.lock index b9fb668..7d7df35 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1781,6 +1781,7 @@ dependencies = [ "nestforge-db", "owo-colors", "ratatui", + "regex", "sha2", "thiserror", "tokio", From b1c9603e13b4ba011307989ed02a58216b0aad44 Mon Sep 17 00:00:00 2001 From: vernonthedev <techjaja2@gmail.com> Date: Tue, 24 Mar 2026 15:24:07 +0300 Subject: [PATCH 08/11] feat(docs): update CLI guide with transpiler documentation - Added documentation for nestforge start and nestforge dev commands - Added TypeScript-style imports guide with supported patterns - Documented path transformations and case conversion - Added transpiler unit tests - Added tempfile dev dependency for tests Commands documented: - nestforge start: runs app with import transpilation - nestforge dev: runs app in development mode with transpilation --- Cargo.lock | 1 + crates/nestforge-cli/Cargo.toml | 3 + crates/nestforge-cli/src/main.rs | 4 +- crates/nestforge-cli/src/transpiler.rs | 107 +++-- docs/cli-guide.md | 634 ++++++++++++++----------- 5 files changed, 446 insertions(+), 303 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 7d7df35..cd5b03f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1783,6 +1783,7 @@ dependencies = [ "ratatui", "regex", "sha2", + "tempfile", "thiserror", "tokio", ] diff --git a/crates/nestforge-cli/Cargo.toml b/crates/nestforge-cli/Cargo.toml index c6c03d1..37ac021 100644 --- a/crates/nestforge-cli/Cargo.toml +++ b/crates/nestforge-cli/Cargo.toml @@ -28,6 +28,9 @@ crossterm = "0.28" thiserror = { workspace = true } regex = "1" +[dev-dependencies] +tempfile = "3" + diff --git a/crates/nestforge-cli/src/main.rs b/crates/nestforge-cli/src/main.rs index 1b3a152..79c3d73 100644 --- a/crates/nestforge-cli/src/main.rs +++ b/crates/nestforge-cli/src/main.rs @@ -4210,7 +4210,7 @@ mod tests { assert!(module_rs.contains("providers = [")); assert!(module_rs.contains("AppConfig,")); assert!(module_rs.contains("AppService,")); - assert!(module_rs.contains("exports = [AppConfig, AppService]")); + assert!(module_rs.contains("exports = [nestforge::ConfigService")); } #[test] @@ -4388,4 +4388,4 @@ mod tests { assert!(docs.contains("nestforge export-docs --format yaml")); assert!(docs.contains("nestforge db migrate")); } -} +} diff --git a/crates/nestforge-cli/src/transpiler.rs b/crates/nestforge-cli/src/transpiler.rs index d5d5000..3886223 100644 --- a/crates/nestforge-cli/src/transpiler.rs +++ b/crates/nestforge-cli/src/transpiler.rs @@ -178,7 +178,7 @@ impl Transpiler { Ok(result) } - fn transform_path(&self, source: &str, prefix: &str) -> String { + fn transform_path(&self, source: &str, _prefix: &str) -> String { if source.starts_with("nestforge/") { let module = source.strip_prefix("nestforge/").unwrap_or(source); format!("nestforge::{}", module.replace('/', "::")) @@ -195,11 +195,7 @@ impl Transpiler { .join("::"); if source.starts_with("./") { - if prefix.is_empty() { - format!("self::{}", rust_path) - } else { - format!("self::{}", rust_path) - } + format!("self::{}", rust_path) } else { let parent_count = source.matches("..").count(); let super_prefix = (0..parent_count) @@ -220,37 +216,24 @@ impl Transpiler { } } - fn to_snake_case(&self, s: &str) -> String { + pub fn to_snake_case(&self, s: &str) -> String { let mut result = String::new(); - let mut prev_was_upper = false; - let mut prev_was_alpha = false; - for c in s.chars() { - if c.is_uppercase() { - if prev_was_alpha && !prev_was_upper { - result.push('_'); - } - result.push(c.to_ascii_lowercase()); - prev_was_upper = true; - } else if c.is_alphabetic() { - if prev_was_upper { - result.push('_'); - } - result.push(c); - prev_was_upper = false; - } else if c.is_numeric() { - if prev_was_alpha { - result.push('_'); - } - result.push(c); - prev_was_upper = false; - } else { + for word in s.split(|c: char| !c.is_alphanumeric()) { + if word.is_empty() { + continue; + } + if !result.is_empty() { result.push('_'); } - prev_was_alpha = c.is_alphabetic() || c.is_numeric(); + result.push_str(&word.to_lowercase()); } - result.trim_matches('_').to_string() + if result.is_empty() { + s.to_lowercase() + } else { + result + } } fn generate_mod_file(&self, modules: &[String]) -> String { @@ -269,3 +252,65 @@ pub fn transpile_project(source_dir: &Path, cache_dir: &Path) -> Result<PathBuf> transpiler.run()?; Ok(cache_dir.to_path_buf()) } + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + #[test] + fn test_transform_path_nestforge_module() { + let temp_dir = TempDir::new().unwrap(); + let transpiler = Transpiler::new(temp_dir.path(), temp_dir.path()).unwrap(); + + let result = transpiler.transform_path("nestforge/common", ""); + assert_eq!(result, "nestforge::common"); + + let result = transpiler.transform_path("nestforge/http", ""); + assert_eq!(result, "nestforge::http"); + } + + #[test] + fn test_transform_path_relative_import() { + let temp_dir = TempDir::new().unwrap(); + let transpiler = Transpiler::new(temp_dir.path(), temp_dir.path()).unwrap(); + + let result = transpiler.transform_path("./users.service", "users"); + assert!(result.contains("users_service")); + } + + #[test] + fn test_transform_path_parent_import() { + let temp_dir = TempDir::new().unwrap(); + let transpiler = Transpiler::new(temp_dir.path(), temp_dir.path()).unwrap(); + + let result = transpiler.transform_path("../config", "users"); + assert!(result.contains("super")); + } + + #[test] + fn test_to_snake_case() { + let temp_dir = TempDir::new().unwrap(); + let transpiler = Transpiler::new(temp_dir.path(), temp_dir.path()).unwrap(); + + assert_eq!(transpiler.to_snake_case("users_service"), "users_service"); + assert_eq!( + transpiler.to_snake_case("auth_controller"), + "auth_controller" + ); + assert_eq!(transpiler.to_snake_case("my_controller"), "my_controller"); + assert_eq!(transpiler.to_snake_case("users"), "users"); + } + + #[test] + fn test_generate_mod_file() { + let temp_dir = TempDir::new().unwrap(); + let transpiler = Transpiler::new(temp_dir.path(), temp_dir.path()).unwrap(); + + let modules = vec!["users".to_string(), "controllers".to_string()]; + let result = transpiler.generate_mod_file(&modules); + + assert!(result.contains("pub mod users;")); + assert!(result.contains("pub mod controllers;")); + } +} diff --git a/docs/cli-guide.md b/docs/cli-guide.md index a4f3328..671489e 100644 --- a/docs/cli-guide.md +++ b/docs/cli-guide.md @@ -1,270 +1,364 @@ -# CLI Guide - -## Install - -```bash -cargo install --path crates/nestforge-cli -``` - -## Command Overview - -```text -nestforge new <app-name> -nestforge new <app-name> --transport <http|graphql|grpc|microservices|websockets> -nestforge g module <name> -nestforge g resource <name> -nestforge g controller <name> -nestforge g service <name> -nestforge g guard <name> -nestforge g decorator <name> -nestforge g filter <name> -nestforge g middleware <name> -nestforge g interceptor <name> -nestforge g serializer <name> -nestforge g graphql <name> -nestforge g grpc <name> -nestforge g gateway <name> -nestforge g microservice <name> -nestforge db init -nestforge db generate <name> -nestforge db migrate -nestforge db status -nestforge docs -nestforge fmt -``` - -## Generators - -### New App - -```bash -nestforge new demo-api -``` - -Creates a runnable app with: - -- `lib.rs` root barrel with app re-exports -- `main.rs` -- `app_module.rs` -- root app/health controllers -- guards/interceptors folders -- `.env` and `.env.example` - -Generate a GraphQL-first app: - -```bash -nestforge new demo-graphql --transport graphql -``` - -Creates: - -- `src/graphql/schema.rs` -- GraphQL bootstrap in `main.rs` -- GraphiQL mounted at `/` - -Generate a gRPC-first app: - -```bash -nestforge new demo-grpc --transport grpc -``` - -Creates: - -- `proto/greeter.proto` -- `build.rs` for tonic code generation -- `src/grpc/service.rs` -- gRPC bootstrap in `main.rs` - -Generate a WebSocket-first app: - -```bash -nestforge new demo-events --transport websockets -``` - -Creates: - -- `src/ws/events_gateway.rs` -- `src/ws/mod.rs` -- WebSocket bootstrap in `main.rs` - -Generate a microservices-first app: - -```bash -nestforge new demo-bus --transport microservices -``` - -Creates: - -- `src/microservices/app_patterns.rs` -- `src/microservices/mod.rs` -- in-process client bootstrap in `main.rs` - -### Module - -```bash -nestforge g module users -``` - -Creates Nest-style feature structure: - -- `src/users/mod.rs` -- `src/users/controllers/*` -- `src/users/services/*` -- `src/users/dto/*` - -Also patches `main.rs` and `app_module.rs` imports. -For newer scaffolds, the CLI also patches the root `src/lib.rs` barrel so app-level imports can -stay flat. - -### Resource In Module - -```bash -nestforge g resource users --module users -``` - -Generates DTOs, service, controller inside the target module and wires exports/providers/controllers. - -## Guard And Interceptor Generators - -```bash -nestforge g guard auth -nestforge g decorator correlation_id -nestforge g filter rewrite_bad_request -nestforge g middleware audit -nestforge g interceptor logging -nestforge g serializer user -``` - -`nestforge g decorator <name>` creates `src/decorators/<name>_decorator.rs` plus export wiring in `src/decorators/mod.rs`. - -`nestforge g filter <name>` creates `src/filters/<name>_filter.rs` plus export wiring in `src/filters/mod.rs`. - -`nestforge g middleware <name>` creates `src/middleware/<name>_middleware.rs` plus export wiring in `src/middleware/mod.rs`. - -`nestforge g serializer <name>` creates `src/serializers/<name>_serializer.rs` plus export wiring in `src/serializers/mod.rs`. - -## GraphQL, gRPC, And Messaging Generators - -Generate a GraphQL resolver stub: - -```bash -nestforge g graphql users -``` - -Creates: - -- `src/graphql/users_resolver.rs` -- export wiring in `src/graphql/mod.rs` - -Generate a gRPC service stub: - -```bash -nestforge g grpc billing -``` - -Creates: - -- `proto/billing.proto` -- `src/grpc/billing_service.rs` -- `src/grpc/mod.rs` updates for the generated proto package and service export -- `build.rs` updates so tonic compiles the new proto file - -Generate a WebSocket gateway stub: - -```bash -nestforge g gateway events -``` - -Creates: - -- `src/ws/events_gateway.rs` -- `src/ws/mod.rs` export wiring - -Generate a microservice pattern registry stub: - -```bash -nestforge g microservice users -``` - -Creates: - -- `src/microservices/users_patterns.rs` -- `src/microservices/mod.rs` export wiring - -The generated stub expects the `microservices` feature to be enabled on `nestforge`. - -## DB Commands - -### Init - -```bash -nestforge db init -``` - -Creates: - -- `migrations/` -- `.nestforge/applied_migrations.txt` - -### Generate Migration - -```bash -nestforge db generate create_users_table -``` - -Creates a timestamped SQL file in `migrations/`. - -### Migrate - -```bash -nestforge db migrate -``` - -Runs pending SQL migrations using `DATABASE_URL`. - -### Status - -```bash -nestforge db status -``` - -Shows `applied`, `pending`, and `drift` migration status. - -## Utilities - -- `nestforge docs`: opens the interactive CLI docs browser in your terminal. - - Navigation: - - `j` / `Down`: Next topic - - `k` / `Up`: Previous topic - - `PageDown`: Scroll content down - - `PageUp`: Scroll content up - - `/`: Search topics - - `q` / `Esc`: Quit - - Use `--no-tui` if you prefer plain text output for pipes or basic terminals. - - Pass a topic like `nestforge docs modules` to jump directly to a page. -- `nestforge export-docs`: writes OpenAPI output for the current app -- `nestforge fmt`: runs `cargo fmt` - -## Prelude and App Barrels - -New scaffolds now generate: - -- a root `src/lib.rs` barrel with `pub use` re-exports for top-level app symbols -- a slimmer `src/main.rs` that imports from the package crate -- `use nestforge::prelude::*;` for common framework imports - -That gives generated apps a flatter import style, for example: - -```rust -use demo_api::AppModule; -use nestforge::prelude::*; -``` - -## Notes - -- Run generator commands inside an app folder (`Cargo.toml` + `src/`). -- Use `--module <feature>` to generate inside a feature module. -- GraphQL and gRPC app templates still include `APP_NAME` and an optional `DATABASE_URL` placeholder in `.env`. -- The microservices app template uses the `testing` feature as a lightweight runtime bootstrap for the in-process client example. -- The gRPC template expects `protoc` to be available when you build the generated app. -- The microservice generator expects the `microservices` feature to be enabled in the target app. +# CLI Guide + +## Install + +```bash +cargo install --path crates/nestforge-cli +``` + +## Command Overview + +```text +nestforge new <app-name> +nestforge new <app-name> --transport <http|graphql|grpc|microservices|websockets> +nestforge start [app-name] +nestforge dev [app-name] +nestforge g module <name> +nestforge g resource <name> +nestforge g controller <name> +nestforge g service <name> +nestforge g guard <name> +nestforge g decorator <name> +nestforge g filter <name> +nestforge g middleware <name> +nestforge g interceptor <name> +nestforge g serializer <name> +nestforge g graphql <name> +nestforge g grpc <name> +nestforge g gateway <name> +nestforge g microservice <name> +nestforge db init +nestforge db generate <name> +nestforge db migrate +nestforge db status +nestforge docs +nestforge fmt +``` + +## Running Your App + +### nestforge start + +Runs your application with automatic TypeScript-style import transpilation: + +```bash +nestforge start +``` + +This command: +1. Scans your `src/` directory for files containing `import` statements +2. Transpiles TypeScript-style imports to valid Rust `use` statements +3. Writes transpiled code to `.nestforge/cache/` +4. Runs `cargo run` to execute your application + +### nestforge dev + +Runs your application in development mode: + +```bash +nestforge dev +``` + +Same as `start` but optimized for development workflow. + +### TypeScript-Style Imports + +NestForge supports TypeScript-style import syntax that gets transpiled to Rust: + +```typescript +// Your code (src/users/users.controller.ts) +import { Controller, Get } from "nestforge/common"; +import { UsersService } from "./users.service"; + +@Controller("users") +export class UsersController { + @Get() + findAll() { + return []; + } +} +``` + +Transpiles to: + +```rust +// Transpiled (.nestforge/cache/users/users_controller.rs) +use nestforge::common::{Controller, Get}; +use self::users_service::UsersService; + +#[Controller("users")] +pub struct UsersController { + #[Get()] + pub fn find_all(&self) -> Vec<()> { + Vec::new() + } +} +``` + +#### Supported Import Patterns + +| Pattern | Example | Transpiles To | +|---------|---------|---------------| +| Named import | `import { Module, Controller } from "nestforge/common"` | `use nestforge::common::{Module, Controller}` | +| NestForge import | `import { Get, Post } from "nestforge/common"` | `use nestforge::common::{Get, Post}` | +| Relative import | `import { UsersService } from "./users.service"` | `use self::users_service::UsersService` | +| Parent import | `import { Config } from "../config"` | `use super::config::Config` | +| Default import | `import MyService from "./my.service"` | `use self::my_service::MyService` | + +#### Path Transformations + +- `nestforge/common` → `nestforge::common` +- `nestforge/http` → `nestforge::http` +- `./users.service` → `self::users_service` +- `../config` → `super::config` +- `../../shared/utils` → `super::super::shared::utils` + +#### Case Conversion + +The transpiler automatically converts filenames to snake_case: + +- `users.service` → `users_service` +- `authController` → `auth_controller` +- `my-controller.ts` → `my_controller` + +#### Caveats + +- Standard `cargo run` will fail on files using `import` syntax +- Always use `nestforge start` or `nestforge dev` to run your app +- The transpiler writes to `.nestforge/cache/` - do not edit these files directly + +## Generators + +### New App + +```bash +nestforge new demo-api +``` + +Creates a runnable app with: + +- `lib.rs` root barrel with app re-exports +- `main.rs` +- `app_module.rs` +- root app/health controllers +- guards/interceptors folders +- `.env` and `.env.example` + +Generate a GraphQL-first app: + +```bash +nestforge new demo-graphql --transport graphql +``` + +Creates: + +- `src/graphql/schema.rs` +- GraphQL bootstrap in `main.rs` +- GraphiQL mounted at `/` + +Generate a gRPC-first app: + +```bash +nestforge new demo-grpc --transport grpc +``` + +Creates: + +- `proto/greeter.proto` +- `build.rs` for tonic code generation +- `src/grpc/service.rs` +- gRPC bootstrap in `main.rs` + +Generate a WebSocket-first app: + +```bash +nestforge new demo-events --transport websockets +``` + +Creates: + +- `src/ws/events_gateway.rs` +- `src/ws/mod.rs` +- WebSocket bootstrap in `main.rs` + +Generate a microservices-first app: + +```bash +nestforge new demo-bus --transport microservices +``` + +Creates: + +- `src/microservices/app_patterns.rs` +- `src/microservices/mod.rs` +- in-process client bootstrap in `main.rs` + +### Module + +```bash +nestforge g module users +``` + +Creates Nest-style feature structure: + +- `src/users/mod.rs` +- `src/users/controllers/*` +- `src/users/services/*` +- `src/users/dto/*` + +Also patches `main.rs` and `app_module.rs` imports. +For newer scaffolds, the CLI also patches the root `src/lib.rs` barrel so app-level imports can +stay flat. + +### Resource In Module + +```bash +nestforge g resource users --module users +``` + +Generates DTOs, service, controller inside the target module and wires exports/providers/controllers. + +## Guard And Interceptor Generators + +```bash +nestforge g guard auth +nestforge g decorator correlation_id +nestforge g filter rewrite_bad_request +nestforge g middleware audit +nestforge g interceptor logging +nestforge g serializer user +``` + +`nestforge g decorator <name>` creates `src/decorators/<name>_decorator.rs` plus export wiring in `src/decorators/mod.rs`. + +`nestforge g filter <name>` creates `src/filters/<name>_filter.rs` plus export wiring in `src/filters/mod.rs`. + +`nestforge g middleware <name>` creates `src/middleware/<name>_middleware.rs` plus export wiring in `src/middleware/mod.rs`. + +`nestforge g serializer <name>` creates `src/serializers/<name>_serializer.rs` plus export wiring in `src/serializers/mod.rs`. + +## GraphQL, gRPC, And Messaging Generators + +Generate a GraphQL resolver stub: + +```bash +nestforge g graphql users +``` + +Creates: + +- `src/graphql/users_resolver.rs` +- export wiring in `src/graphql/mod.rs` + +Generate a gRPC service stub: + +```bash +nestforge g grpc billing +``` + +Creates: + +- `proto/billing.proto` +- `src/grpc/billing_service.rs` +- `src/grpc/mod.rs` updates for the generated proto package and service export +- `build.rs` updates so tonic compiles the new proto file + +Generate a WebSocket gateway stub: + +```bash +nestforge g gateway events +``` + +Creates: + +- `src/ws/events_gateway.rs` +- `src/ws/mod.rs` export wiring + +Generate a microservice pattern registry stub: + +```bash +nestforge g microservice users +``` + +Creates: + +- `src/microservices/users_patterns.rs` +- `src/microservices/mod.rs` export wiring + +The generated stub expects the `microservices` feature to be enabled on `nestforge`. + +## DB Commands + +### Init + +```bash +nestforge db init +``` + +Creates: + +- `migrations/` +- `.nestforge/applied_migrations.txt` + +### Generate Migration + +```bash +nestforge db generate create_users_table +``` + +Creates a timestamped SQL file in `migrations/`. + +### Migrate + +```bash +nestforge db migrate +``` + +Runs pending SQL migrations using `DATABASE_URL`. + +### Status + +```bash +nestforge db status +``` + +Shows `applied`, `pending`, and `drift` migration status. + +## Utilities + +- `nestforge docs`: opens the interactive CLI docs browser in your terminal. + - Navigation: + - `j` / `Down`: Next topic + - `k` / `Up`: Previous topic + - `PageDown`: Scroll content down + - `PageUp`: Scroll content up + - `/`: Search topics + - `q` / `Esc`: Quit + - Use `--no-tui` if you prefer plain text output for pipes or basic terminals. + - Pass a topic like `nestforge docs modules` to jump directly to a page. +- `nestforge export-docs`: writes OpenAPI output for the current app +- `nestforge fmt`: runs `cargo fmt` + +## Prelude and App Barrels + +New scaffolds now generate: + +- a root `src/lib.rs` barrel with `pub use` re-exports for top-level app symbols +- a slimmer `src/main.rs` that imports from the package crate +- `use nestforge::prelude::*;` for common framework imports + +That gives generated apps a flatter import style, for example: + +```rust +use demo_api::AppModule; +use nestforge::prelude::*; +``` + +## Notes + +- Run generator commands inside an app folder (`Cargo.toml` + `src/`). +- Use `--module <feature>` to generate inside a feature module. +- GraphQL and gRPC app templates still include `APP_NAME` and an optional `DATABASE_URL` placeholder in `.env`. +- The microservices app template uses the `testing` feature as a lightweight runtime bootstrap for the in-process client example. +- The gRPC template expects `protoc` to be available when you build the generated app. +- The microservice generator expects the `microservices` feature to be enabled in the target app. From 5daa1af0311e65c0d72ffe75de8003b240149590 Mon Sep 17 00:00:00 2001 From: vernonthedev <techjaja2@gmail.com> Date: Tue, 24 Mar 2026 15:28:11 +0300 Subject: [PATCH 09/11] docs: update documentation with transpiler information - README.md: Added TypeScript-style imports section with example - quick-start.md: Updated run commands to use nestforge start/dev - core-concepts.md: Added TypeScript-Style Imports section - example-app.md: Added transpiler info and running instructions --- README.md | 1054 +++++++++++++++++++++-------------------- docs/core-concepts.md | 445 +++++++++-------- docs/example-app.md | 197 ++++---- docs/quick-start.md | 392 +++++++-------- 4 files changed, 1100 insertions(+), 988 deletions(-) diff --git a/README.md b/README.md index 8bb4ea2..076db16 100644 --- a/README.md +++ b/README.md @@ -1,506 +1,548 @@ -```text - -███╗ ██╗███████╗███████╗████████╗███████╗ ██████╗ ██████╗ ██████╗ ███████╗ -████╗ ██║██╔════╝██╔════╝╚══██╔══╝██╔════╝██╔═══██╗██╔══██╗██╔════╝ ██╔════╝ -██╔██╗ ██║█████╗ ███████╗ ██║ █████╗ ██║ ██║██████╔╝██║ ███╗█████╗ -██║╚██╗██║██╔══╝ ╚════██║ ██║ ██╔══╝ ██║ ██║██╔══██╗██║ ██║██╔══╝ -██║ ╚████║███████╗███████║ ██║ ██║ ╚██████╔╝██║ ██║╚██████╔╝███████╗ -╚═╝ ╚═══╝╚══════╝╚══════╝ ╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚═╝ ╚═════╝ ╚══════╝ - -``` - -# NestForge - -NestForge is a high-performance backend framework designed for developers who crave the modularity and **Dependency Injection (DI)** of NestJS but want the memory safety and blazing speed of the Rust ecosystem. - -[![crates.io](https://img.shields.io/crates/v/nestforge?label=crates.io)](https://crates.io/crates/nestforge) -[![docs.rs](https://img.shields.io/docsrs/nestforge?label=docs.rs)](https://docs.rs/nestforge) -[![License](https://img.shields.io/badge/license-Apache%202.0-blue.svg)](LICENSE) -[![Release](https://img.shields.io/github/actions/workflow/status/vernonthedev/nestforge/release.yml?branch=main&label=release)](https://github.com/vernonthedev/nestforge/actions/workflows/release.yml) -[![VS Code Extension](https://img.shields.io/visual-studio-marketplace/v/vernonthedev.nestforge?label=VS%20Code%20Extension)](https://marketplace.visualstudio.com/items?itemName=vernonthedev.nestforge) -[![Extension Downloads](https://img.shields.io/visual-studio-marketplace/d/vernonthedev.nestforge?label=extension%20downloads)](https://marketplace.visualstudio.com/items?itemName=vernonthedev.nestforge) - -> [!IMPORTANT] -> **Stable Release** -> NestForge **1.6.0** is now published on crates.io. - -## What You Get - -- Module system with `imports` and `exports` -- Dependency Injection with simple provider registration -- Managed singleton services with `#[injectable]` -- Controller macros (`#[controller]`, `#[routes]`, `#[get]`, `#[post]`, `#[put]`, `#[delete]`) -- Request extractors (`Inject<T>`, `Param<T>`, `Body<T>`, `ValidatedBody<T>`) -- Extended request extractors (`Query<T>`, `Headers`, `Cookies`, `RequestId`) -- Built-in HTTP error type (`HttpException`) -- Guard and interceptor pipeline (global + route-level) -- Route-targeted middleware consumer API on the HTTP factory -- Auth primitives (`AuthUser`, `OptionalAuthUser`, `BearerToken`, auth resolvers, auth guards) -- Route versioning (`#[nestforge::version("1")]`) -- Global prefix support (`.with_global_prefix("api")`) -- Generated OpenAPI docs from controller metadata with runtime mounting helpers -- Swagger UI and Redoc hosting for generated OpenAPI docs -- DTO-driven OpenAPI schema generation for request and response bodies -- Optional GraphQL support through a dedicated `nestforge-graphql` crate and factory helpers -- Optional gRPC transport support through a dedicated `nestforge-grpc` crate -- Optional WebSocket gateway support through a dedicated `nestforge-websockets` crate -- Optional scheduler support through a dedicated `nestforge-schedule` crate -- Config module with env loading and schema validation -- Data layer crates (`nestforge-db`, `nestforge-orm`, `nestforge-data`) -- Testing helpers with module overrides plus HTTP and GraphQL test routers -- CLI for scaffolding, generators, flat or nested feature layouts, DB migrations, docs skeleton, formatting - -## Workspace Layout - -- `crates/nestforge`: public crate users import -- `crates/nestforge-core`: DI, module graph, route builder, validation, resource service -- `crates/nestforge-http`: app bootstrap factory -- `crates/nestforge-macros`: framework macros -- `crates/nestforge-cli`: `nestforge` CLI binary -- `crates/nestforge-config`: env/config loading and validation -- `crates/nestforge-db`: DB wrapper and migrations support -- `crates/nestforge-orm`: relational ORM abstraction layer -- `crates/nestforge-data`: non-relational data abstractions -- `examples/hello-nestforge`: full example app -- `examples/hello-nestforge-graphql`: GraphQL-first example app -- `examples/hello-nestforge-grpc`: gRPC-first example app -- `examples/hello-nestforge-microservices`: microservice registry + in-process client example app -- `examples/hello-nestforge-websockets`: WebSocket-first example app - -## Quick Start (Repo) - -```bash -git clone https://github.com/vernonthedev/nestforge.git -cd nestforge -cargo check --workspace -cargo run -p hello-nestforge -``` - -GraphQL-first example: - -```bash -cargo run -p hello-nestforge-graphql -``` - -gRPC-first example: - -```bash -cargo run -p hello-nestforge-grpc -``` - -WebSocket-first example: - -```bash -cargo run -p hello-nestforge-websockets -``` - -Microservices-first example: - -```bash -cargo run -p hello-nestforge-microservices -``` - -Server runs on: - -```text -http://127.0.0.1:3000 -``` - -## Releases - -NestForge now uses a Rust-native direct release flow driven by the repository release script. - -- Pushes to `main` run the repository release script directly. -- Conventional commits since the last version tag determine the next semver bump automatically. -- Changed crates are versioned, tagged, released on GitHub, and published to crates.io in dependency order. -- The primary published changelog is updated at `crates/nestforge/CHANGELOG.md`, so conventional commits remain the source for changelog entries. - -Repository setup required for publishing: - -- Add `CARGO_REGISTRY_TOKEN` to GitHub Actions secrets. -- Keep using Conventional Commits for changes you want included in the release notes. -- Expect first-time publishes for brand new crates to require a manual bootstrap publish before full automation can take over. - -## Quick Start (CLI) - -Install locally from this workspace: - -```bash -cargo install --path crates/nestforge-cli -``` - -Create an app: - -```bash -nestforge new demo-api -cd demo-api -cargo run -``` - -Scaffold a new HTTP app with OpenAPI docs wired in from day one: - -```bash -nestforge new demo-api --openapi -``` - -The default app scaffold now creates a root `src/lib.rs` barrel so the binary can import app -symbols directly from the package crate: - -```text -src/ - lib.rs - main.rs - app_config.rs - app_controller.rs - app_module.rs - health_controller.rs -``` - -That means generated bootstrap code looks more like: - -```rust -use demo_api::AppModule; -use nestforge::prelude::*; -``` - -instead of a long list of `mod app_config;`, `mod app_module;`, and similar declarations in -`main.rs`. - -Nested `controllers/` and `services/` folders are only created later when you generate root-level resources into them. - -Create a GraphQL-first app: - -```bash -nestforge new demo-graphql --transport graphql -``` - -Create a gRPC-first app: - -```bash -nestforge new demo-grpc --transport grpc -``` - -Create a microservices-first app: - -```bash -nestforge new demo-bus --transport microservices -``` - -Create a WebSocket-first app: - -```bash -nestforge new demo-events --transport websockets -``` - -Generate code: - -```bash -nestforge g module users -nestforge g resource users --module users -nestforge g resource users --module users --flat -nestforge g guard auth -nestforge g filter rewrite_bad_request -nestforge g middleware audit -nestforge g interceptor logging -nestforge g graphql users -nestforge g grpc billing -nestforge g gateway events -``` - -Flat feature layout: - -```bash -nestforge g module users --flat -nestforge g resource users --module users --flat -``` - -This keeps generated files together in the feature root: - -```text -src/users/ - mod.rs - user_dto.rs - create_user_dto.rs - update_user_dto.rs - users_controller.rs - users_service.rs -``` - -Without `--flat`, the CLI keeps the older nested layout: - -```text -src/users/ - mod.rs - controllers/ - users_controller.rs - services/ - users_service.rs - dto/ - user_dto.rs - create_user_dto.rs - update_user_dto.rs -``` - -When you generate a resource from a real terminal, the CLI can now prompt for DTO fields so the scaffolded `Create*Dto`, `Update*Dto`, and entity DTO match your domain instead of defaulting to a single `name` field. For non-interactive runs, pass `--no-prompt` or let the CLI fall back to the default field set. - -```bash -nestforge g resource users --module users --flat -nestforge g resource users --module users --flat --no-prompt -``` - -DB commands: - -```bash -nestforge db init -nestforge db generate create_users_table -nestforge db migrate -nestforge db status -``` - -Utilities: - -```bash -nestforge docs -nestforge fmt -``` - -## VS Code Extension - -NestForge also ships with an official VS Code extension for driving the `nestforge` CLI directly from the editor. - -- Marketplace: [vernonthedev.nestforge](https://marketplace.visualstudio.com/items?itemName=vernonthedev.nestforge) -- Repository: `vernonthedev/nestforge-extension` -- Best for guided scaffolding, generator workflows, DB commands, OpenAPI docs access, Rust utilities, onboarding, and module-aware Explorer actions - -The extension currently provides: - -- New application and generator wizards -- Explorer context menus for module-aware generation -- DB dashboard commands and status polling -- `.env` validation with quick fixes -- OpenAPI docs shortcuts, format commands, run config generation, Git setup, logs, and module graph views -- Rust snippets for common NestForge patterns - -## Minimal App Bootstrap - -```rust -use my_app::AppModule; -use nestforge::prelude::*; - -NestForgeFactory::<AppModule>::create()? - .with_global_prefix("api") - .with_openapi_docs("My API", "1.0.0")? - .use_guard::<AllowAllGuard>() - .use_interceptor::<LoggingInterceptor>() - .listen(3000) - .await?; -``` - -## Prelude and Root Re-exports - -NestForge includes a lightweight `nestforge::prelude` for the framework items you reach for most -often in app code and generated scaffolds: - -```rust -use nestforge::prelude::*; -``` - -The prelude is meant to reduce repetitive framework import noise. It groups together the common -factories, route/module macros, and helper types used in everyday NestForge code. - -Examples of what it gives you: - -- `NestForgeFactory` -- `NestForgeGrpcFactory` -- `NestForgeFactoryGraphQlExt` -- `NestForgeFactoryWebSocketExt` -- `Inject` -- `HttpException` -- common route and module macros such as `#[module]`, `#[controller]`, and `#[get]` - -On top of that, generated apps now include a root `src/lib.rs` barrel that re-exports top-level app -symbols like `AppModule` and `AppConfig`. That lets you write: - -```rust -use demo_api::AppModule; -use demo_api::AppConfig; -``` - -instead of pushing those imports through `mod ...;` declarations or deeper nested paths in -`main.rs`. - -## Injectable Services - -Use `#[injectable]` when you want NestForge to treat a struct as a managed provider without a -manual `#[derive(Clone)]`. - -```rust -use nestforge::{injectable, module, Inject}; - -#[injectable] -pub struct UsersService; - -#[module( - providers = [UsersService], - exports = [UsersService] -)] -pub struct UsersModule; - -#[nestforge::controller("/users")] -pub struct UsersController; - -#[nestforge::routes] -impl UsersController { - #[nestforge::get("/count")] - async fn count(_users: Inject<UsersService>) -> Result<&'static str, nestforge::HttpException> { - Ok("ok") - } -} -``` - -If a service needs custom setup, point the macro at a zero-arg factory: - -```rust -#[injectable(factory = build_users_service)] -pub struct UsersService { - pub label: &'static str, -} - -fn build_users_service() -> anyhow::Result<UsersService> { - Ok(UsersService { label: "users" }) -} -``` - -The same pattern works nicely for framework-managed config objects in the examples: - -```rust -use nestforge::{injectable, ConfigModule, ConfigOptions}; - -#[injectable(factory = load_app_config)] -pub struct AppConfig { - pub app_name: String, -} - -fn load_app_config() -> anyhow::Result<AppConfig> { - Ok(ConfigModule::for_root::<AppConfig>( - ConfigOptions::new().env_file(".env"), - )?) -} - -#[nestforge::module( - providers = [AppConfig], - exports = [AppConfig] -)] -pub struct AppModule; -``` - -Keep explicit value or factory providers for external runtime resources like database connections, -clients, or other values you do not own as plain structs. - -## Example App Features - -`examples/hello-nestforge` demonstrates: - -- Root controllers (`AppController`, `HealthController`) at app root -- Feature modules (`users`, `settings`, `versioning`) in Nest-style folders -- CLI generators also support flat feature folders with `--flat` when you want controllers, services, and DTOs side-by-side in the module root -- CRUD controllers + services with `ResourceService<T>` -- Validation via `ValidatedBody<T>` -- Guard/interceptor usage at route level -- Generated `/docs` and `/openapi.json` routes from controller metadata -- Generated OpenAPI schemas for DTOs used in `ValidatedBody<T>`, `Body<T>`, and `ApiResult<T>` - -Export a static spec for CI/CD or frontend handoff: - -```bash -nestforge export-docs -nestforge export-docs --format yaml --output docs/openapi.yaml -``` - -- Config loading with `ConfigModule::for_root` and `#[injectable(factory = ...)]` -- Versioned routes (`v1`, `v2`) - -## Optional GraphQL Setup - -Enable the `graphql` feature and merge a GraphQL schema directly into the app: - -```rust -use nestforge::{async_graphql::{EmptyMutation, EmptySubscription, Object, Schema}, prelude::*}; - -struct QueryRoot; - -#[Object] -impl QueryRoot { - async fn health(&self) -> &str { - "ok" - } -} - -let schema = Schema::build(QueryRoot, EmptyMutation, EmptySubscription).finish(); - -NestForgeFactory::<AppModule>::create()? - .with_graphql(schema) - .listen(3000) - .await?; -``` - -## Optional gRPC Setup - -Enable the `grpc` feature and bootstrap a gRPC transport with the dedicated factory: - -```rust -use nestforge::prelude::*; - -NestForgeGrpcFactory::<AppModule>::create()? - .with_addr("127.0.0.1:50051") - .listen_with(|ctx, addr| async move { - tonic::transport::Server::builder() - // .add_service(MyGeneratedServer::new(MyGrpcService::new(ctx))) - .serve(addr) - .await - }) - .await?; -``` - -See `examples/hello-nestforge-grpc` for a full tonic-based setup with `proto/greeter.proto`, -`build.rs`, generated bindings, and provider resolution through `GrpcContext`. - -## Optional WebSocket Setup - -Enable the `websockets` feature and mount a gateway directly into the HTTP app: - -```rust -use nestforge::{prelude::*, Message, WebSocket, WebSocketContext, WebSocketGateway}; - -struct EventsGateway; - -impl WebSocketGateway for EventsGateway { - fn on_connect( - &self, - _ctx: WebSocketContext, - mut socket: WebSocket, - ) -> core::pin::Pin<Box<dyn core::future::Future<Output = ()> + Send>> { - Box::pin(async move { - let _ = socket.send(Message::Text("connected".into())).await; - }) - } -} - -NestForgeFactory::<AppModule>::create()? - .with_websocket_gateway(EventsGateway) - .listen(3000) - .await?; -``` - -## Documentation - -- Main Documentation: [https://nestforge.suredoc.net](https://nestforge.suredoc.net) -- Wiki: [https://github.com/vernonthedev/nestforge/wiki](https://github.com/vernonthedev/nestforge/wiki) - -## Contributing - -See [CONTRIBUTING.md](CONTRIBUTING.md). - -## License - -Apache-2.0 ([LICENSE](LICENSE)). +```text + +███╗ ██╗███████╗███████╗████████╗███████╗ ██████╗ ██████╗ ██████╗ ███████╗ +████╗ ██║██╔════╝██╔════╝╚══██╔══╝██╔════╝██╔═══██╗██╔══██╗██╔════╝ ██╔════╝ +██╔██╗ ██║█████╗ ███████╗ ██║ █████╗ ██║ ██║██████╔╝██║ ███╗█████╗ +██║╚██╗██║██╔══╝ ╚════██║ ██║ ██╔══╝ ██║ ██║██╔══██╗██║ ██║██╔══╝ +██║ ╚████║███████╗███████║ ██║ ██║ ╚██████╔╝██║ ██║╚██████╔╝███████╗ +╚═╝ ╚═══╝╚══════╝╚══════╝ ╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚═╝ ╚═════╝ ╚══════╝ + +``` + +# NestForge + +NestForge is a high-performance backend framework designed for developers who crave the modularity and **Dependency Injection (DI)** of NestJS but want the memory safety and blazing speed of the Rust ecosystem. + +[![crates.io](https://img.shields.io/crates/v/nestforge?label=crates.io)](https://crates.io/crates/nestforge) +[![docs.rs](https://img.shields.io/docsrs/nestforge?label=docs.rs)](https://docs.rs/nestforge) +[![License](https://img.shields.io/badge/license-Apache%202.0-blue.svg)](LICENSE) +[![Release](https://img.shields.io/github/actions/workflow/status/vernonthedev/nestforge/release.yml?branch=main&label=release)](https://github.com/vernonthedev/nestforge/actions/workflows/release.yml) +[![VS Code Extension](https://img.shields.io/visual-studio-marketplace/v/vernonthedev.nestforge?label=VS%20Code%20Extension)](https://marketplace.visualstudio.com/items?itemName=vernonthedev.nestforge) +[![Extension Downloads](https://img.shields.io/visual-studio-marketplace/d/vernonthedev.nestforge?label=extension%20downloads)](https://marketplace.visualstudio.com/items?itemName=vernonthedev.nestforge) + +> [!IMPORTANT] +> **Stable Release** +> NestForge **1.6.0** is now published on crates.io. + +## What You Get + +- Module system with `imports` and `exports` +- Dependency Injection with simple provider registration +- Managed singleton services with `#[injectable]` +- Controller macros (`#[controller]`, `#[routes]`, `#[get]`, `#[post]`, `#[put]`, `#[delete]`) +- Request extractors (`Inject<T>`, `Param<T>`, `Body<T>`, `ValidatedBody<T>`) +- Extended request extractors (`Query<T>`, `Headers`, `Cookies`, `RequestId`) +- Built-in HTTP error type (`HttpException`) +- Guard and interceptor pipeline (global + route-level) +- Route-targeted middleware consumer API on the HTTP factory +- Auth primitives (`AuthUser`, `OptionalAuthUser`, `BearerToken`, auth resolvers, auth guards) +- Route versioning (`#[nestforge::version("1")]`) +- Global prefix support (`.with_global_prefix("api")`) +- Generated OpenAPI docs from controller metadata with runtime mounting helpers +- Swagger UI and Redoc hosting for generated OpenAPI docs +- DTO-driven OpenAPI schema generation for request and response bodies +- Optional GraphQL support through a dedicated `nestforge-graphql` crate and factory helpers +- Optional gRPC transport support through a dedicated `nestforge-grpc` crate +- Optional WebSocket gateway support through a dedicated `nestforge-websockets` crate +- Optional scheduler support through a dedicated `nestforge-schedule` crate +- Config module with env loading and schema validation +- Data layer crates (`nestforge-db`, `nestforge-orm`, `nestforge-data`) +- Testing helpers with module overrides plus HTTP and GraphQL test routers +- CLI for scaffolding, generators, flat or nested feature layouts, DB migrations, docs skeleton, formatting + +## Workspace Layout + +- `crates/nestforge`: public crate users import +- `crates/nestforge-core`: DI, module graph, route builder, validation, resource service +- `crates/nestforge-http`: app bootstrap factory +- `crates/nestforge-macros`: framework macros +- `crates/nestforge-cli`: `nestforge` CLI binary +- `crates/nestforge-config`: env/config loading and validation +- `crates/nestforge-db`: DB wrapper and migrations support +- `crates/nestforge-orm`: relational ORM abstraction layer +- `crates/nestforge-data`: non-relational data abstractions +- `examples/hello-nestforge`: full example app +- `examples/hello-nestforge-graphql`: GraphQL-first example app +- `examples/hello-nestforge-grpc`: gRPC-first example app +- `examples/hello-nestforge-microservices`: microservice registry + in-process client example app +- `examples/hello-nestforge-websockets`: WebSocket-first example app + +## Quick Start (Repo) + +```bash +git clone https://github.com/vernonthedev/nestforge.git +cd nestforge +cargo check --workspace +cargo run -p hello-nestforge +``` + +## TypeScript-Style Imports + +NestForge supports TypeScript-style import syntax for a familiar developer experience: + +```typescript +// Your code +import { Module, Controller, Get } from "nestforge/common"; +import { UsersService } from "./users.service"; + +@Controller("users") +export class UsersController { + @Get() + findAll() { + return []; + } +} +``` + +The transpiler automatically converts these to valid Rust: + +```rust +use nestforge::common::{Controller, Get}; +use self::users_service::UsersService; + +#[Controller("users")] +pub struct UsersController { + #[Get()] + pub fn find_all(&self) -> Vec<()> { + Vec::new() + } +} +``` + +Run with transpilation: +```bash +nestforge start +# or +nestforge dev +``` + +See the [CLI Guide](docs/cli-guide.md) for full documentation. + +GraphQL-first example: + +```bash +cargo run -p hello-nestforge-graphql +``` + +gRPC-first example: + +```bash +cargo run -p hello-nestforge-grpc +``` + +WebSocket-first example: + +```bash +cargo run -p hello-nestforge-websockets +``` + +Microservices-first example: + +```bash +cargo run -p hello-nestforge-microservices +``` + +Server runs on: + +```text +http://127.0.0.1:3000 +``` + +## Releases + +NestForge now uses a Rust-native direct release flow driven by the repository release script. + +- Pushes to `main` run the repository release script directly. +- Conventional commits since the last version tag determine the next semver bump automatically. +- Changed crates are versioned, tagged, released on GitHub, and published to crates.io in dependency order. +- The primary published changelog is updated at `crates/nestforge/CHANGELOG.md`, so conventional commits remain the source for changelog entries. + +Repository setup required for publishing: + +- Add `CARGO_REGISTRY_TOKEN` to GitHub Actions secrets. +- Keep using Conventional Commits for changes you want included in the release notes. +- Expect first-time publishes for brand new crates to require a manual bootstrap publish before full automation can take over. + +## Quick Start (CLI) + +Install locally from this workspace: + +```bash +cargo install --path crates/nestforge-cli +``` + +Create an app: + +```bash +nestforge new demo-api +cd demo-api +cargo run +``` + +Scaffold a new HTTP app with OpenAPI docs wired in from day one: + +```bash +nestforge new demo-api --openapi +``` + +The default app scaffold now creates a root `src/lib.rs` barrel so the binary can import app +symbols directly from the package crate: + +```text +src/ + lib.rs + main.rs + app_config.rs + app_controller.rs + app_module.rs + health_controller.rs +``` + +That means generated bootstrap code looks more like: + +```rust +use demo_api::AppModule; +use nestforge::prelude::*; +``` + +instead of a long list of `mod app_config;`, `mod app_module;`, and similar declarations in +`main.rs`. + +Nested `controllers/` and `services/` folders are only created later when you generate root-level resources into them. + +Create a GraphQL-first app: + +```bash +nestforge new demo-graphql --transport graphql +``` + +Create a gRPC-first app: + +```bash +nestforge new demo-grpc --transport grpc +``` + +Create a microservices-first app: + +```bash +nestforge new demo-bus --transport microservices +``` + +Create a WebSocket-first app: + +```bash +nestforge new demo-events --transport websockets +``` + +Generate code: + +```bash +nestforge g module users +nestforge g resource users --module users +nestforge g resource users --module users --flat +nestforge g guard auth +nestforge g filter rewrite_bad_request +nestforge g middleware audit +nestforge g interceptor logging +nestforge g graphql users +nestforge g grpc billing +nestforge g gateway events +``` + +Flat feature layout: + +```bash +nestforge g module users --flat +nestforge g resource users --module users --flat +``` + +This keeps generated files together in the feature root: + +```text +src/users/ + mod.rs + user_dto.rs + create_user_dto.rs + update_user_dto.rs + users_controller.rs + users_service.rs +``` + +Without `--flat`, the CLI keeps the older nested layout: + +```text +src/users/ + mod.rs + controllers/ + users_controller.rs + services/ + users_service.rs + dto/ + user_dto.rs + create_user_dto.rs + update_user_dto.rs +``` + +When you generate a resource from a real terminal, the CLI can now prompt for DTO fields so the scaffolded `Create*Dto`, `Update*Dto`, and entity DTO match your domain instead of defaulting to a single `name` field. For non-interactive runs, pass `--no-prompt` or let the CLI fall back to the default field set. + +```bash +nestforge g resource users --module users --flat +nestforge g resource users --module users --flat --no-prompt +``` + +DB commands: + +```bash +nestforge db init +nestforge db generate create_users_table +nestforge db migrate +nestforge db status +``` + +Utilities: + +```bash +nestforge docs +nestforge fmt +``` + +## VS Code Extension + +NestForge also ships with an official VS Code extension for driving the `nestforge` CLI directly from the editor. + +- Marketplace: [vernonthedev.nestforge](https://marketplace.visualstudio.com/items?itemName=vernonthedev.nestforge) +- Repository: `vernonthedev/nestforge-extension` +- Best for guided scaffolding, generator workflows, DB commands, OpenAPI docs access, Rust utilities, onboarding, and module-aware Explorer actions + +The extension currently provides: + +- New application and generator wizards +- Explorer context menus for module-aware generation +- DB dashboard commands and status polling +- `.env` validation with quick fixes +- OpenAPI docs shortcuts, format commands, run config generation, Git setup, logs, and module graph views +- Rust snippets for common NestForge patterns + +## Minimal App Bootstrap + +```rust +use my_app::AppModule; +use nestforge::prelude::*; + +NestForgeFactory::<AppModule>::create()? + .with_global_prefix("api") + .with_openapi_docs("My API", "1.0.0")? + .use_guard::<AllowAllGuard>() + .use_interceptor::<LoggingInterceptor>() + .listen(3000) + .await?; +``` + +## Prelude and Root Re-exports + +NestForge includes a lightweight `nestforge::prelude` for the framework items you reach for most +often in app code and generated scaffolds: + +```rust +use nestforge::prelude::*; +``` + +The prelude is meant to reduce repetitive framework import noise. It groups together the common +factories, route/module macros, and helper types used in everyday NestForge code. + +Examples of what it gives you: + +- `NestForgeFactory` +- `NestForgeGrpcFactory` +- `NestForgeFactoryGraphQlExt` +- `NestForgeFactoryWebSocketExt` +- `Inject` +- `HttpException` +- common route and module macros such as `#[module]`, `#[controller]`, and `#[get]` + +On top of that, generated apps now include a root `src/lib.rs` barrel that re-exports top-level app +symbols like `AppModule` and `AppConfig`. That lets you write: + +```rust +use demo_api::AppModule; +use demo_api::AppConfig; +``` + +instead of pushing those imports through `mod ...;` declarations or deeper nested paths in +`main.rs`. + +## Injectable Services + +Use `#[injectable]` when you want NestForge to treat a struct as a managed provider without a +manual `#[derive(Clone)]`. + +```rust +use nestforge::{injectable, module, Inject}; + +#[injectable] +pub struct UsersService; + +#[module( + providers = [UsersService], + exports = [UsersService] +)] +pub struct UsersModule; + +#[nestforge::controller("/users")] +pub struct UsersController; + +#[nestforge::routes] +impl UsersController { + #[nestforge::get("/count")] + async fn count(_users: Inject<UsersService>) -> Result<&'static str, nestforge::HttpException> { + Ok("ok") + } +} +``` + +If a service needs custom setup, point the macro at a zero-arg factory: + +```rust +#[injectable(factory = build_users_service)] +pub struct UsersService { + pub label: &'static str, +} + +fn build_users_service() -> anyhow::Result<UsersService> { + Ok(UsersService { label: "users" }) +} +``` + +The same pattern works nicely for framework-managed config objects in the examples: + +```rust +use nestforge::{injectable, ConfigModule, ConfigOptions}; + +#[injectable(factory = load_app_config)] +pub struct AppConfig { + pub app_name: String, +} + +fn load_app_config() -> anyhow::Result<AppConfig> { + Ok(ConfigModule::for_root::<AppConfig>( + ConfigOptions::new().env_file(".env"), + )?) +} + +#[nestforge::module( + providers = [AppConfig], + exports = [AppConfig] +)] +pub struct AppModule; +``` + +Keep explicit value or factory providers for external runtime resources like database connections, +clients, or other values you do not own as plain structs. + +## Example App Features + +`examples/hello-nestforge` demonstrates: + +- Root controllers (`AppController`, `HealthController`) at app root +- Feature modules (`users`, `settings`, `versioning`) in Nest-style folders +- CLI generators also support flat feature folders with `--flat` when you want controllers, services, and DTOs side-by-side in the module root +- CRUD controllers + services with `ResourceService<T>` +- Validation via `ValidatedBody<T>` +- Guard/interceptor usage at route level +- Generated `/docs` and `/openapi.json` routes from controller metadata +- Generated OpenAPI schemas for DTOs used in `ValidatedBody<T>`, `Body<T>`, and `ApiResult<T>` + +Export a static spec for CI/CD or frontend handoff: + +```bash +nestforge export-docs +nestforge export-docs --format yaml --output docs/openapi.yaml +``` + +- Config loading with `ConfigModule::for_root` and `#[injectable(factory = ...)]` +- Versioned routes (`v1`, `v2`) + +## Optional GraphQL Setup + +Enable the `graphql` feature and merge a GraphQL schema directly into the app: + +```rust +use nestforge::{async_graphql::{EmptyMutation, EmptySubscription, Object, Schema}, prelude::*}; + +struct QueryRoot; + +#[Object] +impl QueryRoot { + async fn health(&self) -> &str { + "ok" + } +} + +let schema = Schema::build(QueryRoot, EmptyMutation, EmptySubscription).finish(); + +NestForgeFactory::<AppModule>::create()? + .with_graphql(schema) + .listen(3000) + .await?; +``` + +## Optional gRPC Setup + +Enable the `grpc` feature and bootstrap a gRPC transport with the dedicated factory: + +```rust +use nestforge::prelude::*; + +NestForgeGrpcFactory::<AppModule>::create()? + .with_addr("127.0.0.1:50051") + .listen_with(|ctx, addr| async move { + tonic::transport::Server::builder() + // .add_service(MyGeneratedServer::new(MyGrpcService::new(ctx))) + .serve(addr) + .await + }) + .await?; +``` + +See `examples/hello-nestforge-grpc` for a full tonic-based setup with `proto/greeter.proto`, +`build.rs`, generated bindings, and provider resolution through `GrpcContext`. + +## Optional WebSocket Setup + +Enable the `websockets` feature and mount a gateway directly into the HTTP app: + +```rust +use nestforge::{prelude::*, Message, WebSocket, WebSocketContext, WebSocketGateway}; + +struct EventsGateway; + +impl WebSocketGateway for EventsGateway { + fn on_connect( + &self, + _ctx: WebSocketContext, + mut socket: WebSocket, + ) -> core::pin::Pin<Box<dyn core::future::Future<Output = ()> + Send>> { + Box::pin(async move { + let _ = socket.send(Message::Text("connected".into())).await; + }) + } +} + +NestForgeFactory::<AppModule>::create()? + .with_websocket_gateway(EventsGateway) + .listen(3000) + .await?; +``` + +## Documentation + +- Main Documentation: [https://nestforge.suredoc.net](https://nestforge.suredoc.net) +- Wiki: [https://github.com/vernonthedev/nestforge/wiki](https://github.com/vernonthedev/nestforge/wiki) + +## Contributing + +See [CONTRIBUTING.md](CONTRIBUTING.md). + +## License + +Apache-2.0 ([LICENSE](LICENSE)). diff --git a/docs/core-concepts.md b/docs/core-concepts.md index 2bb11a8..fd872e1 100644 --- a/docs/core-concepts.md +++ b/docs/core-concepts.md @@ -1,205 +1,240 @@ -# Core Concepts - -## Module - -A module groups related controllers and providers. - -With `#[module(...)]` you can define: - -- `imports = [...]` -- `controllers = [...]` -- `providers = [...]` -- `exports = [...]` - -NestForge registers imported modules first, then current module providers, then controllers. - -## Module Graph - -NestForge builds a module graph at startup. - -It handles: - -- import order -- shared module deduplication -- cycle detection with clear error messages - -## Provider And DI - -Providers are values in the DI container. - -The recommended way to define providers is with the `#[injectable]` macro: - -```rust -use nestforge::prelude::*; - -#[injectable] -#[derive(Default)] -pub struct UsersService; -``` - -This marks the struct as a managed provider and automatically implements `Clone`. By default, it registers the provider using `Self::default()`. - -For custom initialization, use the `factory` attribute: - -```rust -#[injectable(factory = build_service)] -pub struct CustomService; -``` - -You can also register providers manually in a module with: - -- direct values (`Provider::value(...)`) -- factories (`Provider::factory(|container| ...)`) -- request-scoped factories (`Provider::request_factory(|container| ...)`) -- transient factories (`Provider::transient_factory(|container| ...)`) - -In handlers, use `Inject<T>` to resolve dependencies. - -Request-scoped factories resolve against a per-request child container, so they can depend on request data like `RequestContext`, `RequestId`, or authenticated identity. - -Transient factories build a fresh instance on every resolve and are useful for short-lived helper services. - -## Controllers - -Controllers define HTTP routes. - -- `#[controller("/users")]` sets a base path -- `#[routes]` maps methods into router entries -- `#[nestforge::get]`, `#[nestforge::post]`, `#[nestforge::put]`, `#[nestforge::delete]` define endpoints - -## Request Types - -- `Param<T>`: path params -- `PipedParam<T, P>`: path params transformed by a pipe -- `Query<T>`: query params -- `PipedQuery<T, P>`: query params transformed by a pipe -- `Body<T>`: JSON body -- `PipedBody<T, P>`: JSON body transformed by a pipe -- `ValidatedBody<T>`: JSON body + validation -- `Decorated<T>`: custom request decorator extraction - -Pipes let you transform or reject extracted values before handler logic runs: - -```rust -struct SlugPipe; - -impl nestforge::Pipe<String> for SlugPipe { - type Output = String; - - fn transform( - value: String, - _ctx: &nestforge::RequestContext, - ) -> Result<Self::Output, nestforge::HttpException> { - Ok(value.trim().to_lowercase()) - } -} -``` - -Custom request decorators let you extract framework-specific values without hand-writing the same parsing code in every handler: - -```rust -struct CorrelationId; - -impl nestforge::RequestDecorator for CorrelationId { - type Output = String; - - fn extract( - _ctx: &nestforge::RequestContext, - parts: &axum::http::request::Parts, - ) -> Result<Self::Output, nestforge::HttpException> { - parts - .headers - .get("x-correlation-id") - .and_then(|value| value.to_str().ok()) - .map(str::to_string) - .ok_or_else(|| nestforge::HttpException::bad_request("Missing x-correlation-id")) - } -} -``` - -## Validation - -Use `ValidatedBody<T>` when `T` implements `Validate`. - -Common pattern: - -```rust -async fn create(body: ValidatedBody<CreateUserDto>) -> ApiResult<UserDto> -``` - -## Errors - -Use `HttpException` for API errors. - -Helper extensions make controllers simpler: - -- `result.or_bad_request()?` -- `option.or_not_found_id("User", id)?` - -## Response Envelopes - -Use `ResponseEnvelope<T>` when you want a stable JSON shape across endpoints. - -Common patterns: - -```rust -async fn list() -> ApiEnvelopeResult<Vec<UserDto>> { - Ok(ResponseEnvelope::paginated(users, 1, 20, 42)) -} -``` - -This yields a payload shaped like: - -- `success` -- `data` -- optional `meta` - -## Response Serialization - -Use `Serialized<T, S>` when a handler should return a domain type but expose a public DTO shape. - -```rust -struct UserSerializer; - -impl nestforge::ResponseSerializer<UserEntity> for UserSerializer { - type Output = UserDto; - - fn serialize(value: UserEntity) -> Self::Output { - UserDto { - id: value.id, - email: value.email, - } - } -} -``` - -Then return `ApiSerializedResult<UserEntity, UserSerializer>` from the handler. - -The `hello-nestforge` example uses this pattern for `GET /api/info` by returning a serialized public view of `AppConfig`. - -## Guards And Interceptors - -- `Guard`: authorization/route checks before handler -- `Interceptor`: wraps handler execution (logging, timing, mapping) - -You can register globally in factory, and per-route via macros. - -## Versioning And Prefix - -- Global prefix: `.with_global_prefix("api")` -- Route version: `#[nestforge::version("1")]` - -This yields routes like `/api/v1/users`. - -## ResourceService - -`ResourceService<T>` is a generic CRUD service on top of `InMemoryStore<T>`. - -It gives simple methods like: - -- `all()` -- `get(id)` -- `create(dto)` -- `update(id, dto)` -- `replace(id, dto)` -- `delete(id)` +# Core Concepts + +## Module + +A module groups related controllers and providers. + +With `#[module(...)]` you can define: + +- `imports = [...]` +- `controllers = [...]` +- `providers = [...]` +- `exports = [...]` + +NestForge registers imported modules first, then current module providers, then controllers. + +## Module Graph + +NestForge builds a module graph at startup. + +It handles: + +- import order +- shared module deduplication +- cycle detection with clear error messages + +## Provider And DI + +Providers are values in the DI container. + +The recommended way to define providers is with the `#[injectable]` macro: + +```rust +use nestforge::prelude::*; + +#[injectable] +#[derive(Default)] +pub struct UsersService; +``` + +This marks the struct as a managed provider and automatically implements `Clone`. By default, it registers the provider using `Self::default()`. + +For custom initialization, use the `factory` attribute: + +```rust +#[injectable(factory = build_service)] +pub struct CustomService; +``` + +You can also register providers manually in a module with: + +- direct values (`Provider::value(...)`) +- factories (`Provider::factory(|container| ...)`) +- request-scoped factories (`Provider::request_factory(|container| ...)`) +- transient factories (`Provider::transient_factory(|container| ...)`) + +In handlers, use `Inject<T>` to resolve dependencies. + +Request-scoped factories resolve against a per-request child container, so they can depend on request data like `RequestContext`, `RequestId`, or authenticated identity. + +Transient factories build a fresh instance on every resolve and are useful for short-lived helper services. + +## Controllers + +Controllers define HTTP routes. + +- `#[controller("/users")]` sets a base path +- `#[routes]` maps methods into router entries +- `#[nestforge::get]`, `#[nestforge::post]`, `#[nestforge::put]`, `#[nestforge::delete]` define endpoints + +## Request Types + +- `Param<T>`: path params +- `PipedParam<T, P>`: path params transformed by a pipe +- `Query<T>`: query params +- `PipedQuery<T, P>`: query params transformed by a pipe +- `Body<T>`: JSON body +- `PipedBody<T, P>`: JSON body transformed by a pipe +- `ValidatedBody<T>`: JSON body + validation +- `Decorated<T>`: custom request decorator extraction + +Pipes let you transform or reject extracted values before handler logic runs: + +```rust +struct SlugPipe; + +impl nestforge::Pipe<String> for SlugPipe { + type Output = String; + + fn transform( + value: String, + _ctx: &nestforge::RequestContext, + ) -> Result<Self::Output, nestforge::HttpException> { + Ok(value.trim().to_lowercase()) + } +} +``` + +Custom request decorators let you extract framework-specific values without hand-writing the same parsing code in every handler: + +```rust +struct CorrelationId; + +impl nestforge::RequestDecorator for CorrelationId { + type Output = String; + + fn extract( + _ctx: &nestforge::RequestContext, + parts: &axum::http::request::Parts, + ) -> Result<Self::Output, nestforge::HttpException> { + parts + .headers + .get("x-correlation-id") + .and_then(|value| value.to_str().ok()) + .map(str::to_string) + .ok_or_else(|| nestforge::HttpException::bad_request("Missing x-correlation-id")) + } +} +``` + +## Validation + +Use `ValidatedBody<T>` when `T` implements `Validate`. + +Common pattern: + +```rust +async fn create(body: ValidatedBody<CreateUserDto>) -> ApiResult<UserDto> +``` + +## Errors + +Use `HttpException` for API errors. + +Helper extensions make controllers simpler: + +- `result.or_bad_request()?` +- `option.or_not_found_id("User", id)?` + +## Response Envelopes + +Use `ResponseEnvelope<T>` when you want a stable JSON shape across endpoints. + +Common patterns: + +```rust +async fn list() -> ApiEnvelopeResult<Vec<UserDto>> { + Ok(ResponseEnvelope::paginated(users, 1, 20, 42)) +} +``` + +This yields a payload shaped like: + +- `success` +- `data` +- optional `meta` + +## Response Serialization + +Use `Serialized<T, S>` when a handler should return a domain type but expose a public DTO shape. + +```rust +struct UserSerializer; + +impl nestforge::ResponseSerializer<UserEntity> for UserSerializer { + type Output = UserDto; + + fn serialize(value: UserEntity) -> Self::Output { + UserDto { + id: value.id, + email: value.email, + } + } +} +``` + +Then return `ApiSerializedResult<UserEntity, UserSerializer>` from the handler. + +The `hello-nestforge` example uses this pattern for `GET /api/info` by returning a serialized public view of `AppConfig`. + +## Guards And Interceptors + +- `Guard`: authorization/route checks before handler +- `Interceptor`: wraps handler execution (logging, timing, mapping) + +You can register globally in factory, and per-route via macros. + +## Versioning And Prefix + +- Global prefix: `.with_global_prefix("api")` +- Route version: `#[nestforge::version("1")]` + +This yields routes like `/api/v1/users`. + +## ResourceService + +`ResourceService<T>` is a generic CRUD service on top of `InMemoryStore<T>`. + +It gives simple methods like: + +- `all()` +- `get(id)` +- `create(dto)` +- `update(id, dto)` +- `replace(id, dto)` +- `delete(id)` + +## TypeScript-Style Imports + +NestForge supports TypeScript-style import syntax for a familiar developer experience. Use `nestforge start` or `nestforge dev` to run your app with automatic transpilation. + +### Supported Patterns + +```typescript +// Named imports from nestforge +import { Module, Controller, Get, Post } from "nestforge/common"; + +// Relative imports +import { UsersService } from "./users.service"; +import { Config } from "../config"; + +// Default imports +import MyService from "./my.service"; +``` + +### Path Transformations + +| Pattern | Transpiles To | +|---------|---------------| +| `nestforge/common` | `nestforge::common` | +| `./users.service` | `self::users_service` | +| `../config` | `super::config` | + +### Case Conversion + +Filenames are automatically converted to snake_case: + +- `users.service` → `users_service` +- `authController` → `auth_controller` + +See the [CLI Guide](./cli-guide.md) for full documentation. diff --git a/docs/example-app.md b/docs/example-app.md index c4292d5..2c287d6 100644 --- a/docs/example-app.md +++ b/docs/example-app.md @@ -1,85 +1,112 @@ -# Example App Walkthrough - -Path: `examples/hello-nestforge` - -Related examples: - -- `examples/hello-nestforge-graphql` -- `examples/hello-nestforge-grpc` - -## What This Example Shows - -- Root `src/lib.rs` barrel with app-level re-exports -- Nest-style module imports (`UsersModule`, `SettingsModule`, `VersioningModule`) -- Root app controllers at `src/` level -- Feature folders for each domain -- Global guard + interceptor registration -- Route-level guards/interceptors -- Route versioning (`v1`, `v2`) -- Config loading with schema checks - -## Boot Flow - -1. `main.rs` imports `AppModule` from the example crate root and creates the app with `NestForgeFactory::<AppModule>::create()` -2. Factory applies global prefix (`api`) -3. Factory registers global guard/interceptor -4. Module graph is resolved and providers/controllers are registered -5. HTTP server starts on port `3000` - -## AppModule - -`app_module.rs` does three main jobs: - -- imports feature modules -- registers root controllers (`AppController`, `HealthController`) -- registers root providers (`AppConfig`, `Db`) - -It also validates env values using: - -- `ConfigModule::for_root` -- `ConfigOptions` -- `EnvSchema` - -## Users Feature - -`src/users/` contains: - -- `controllers/users_controller.rs` -- `services/users_service.rs` -- `dto/*` - -For new projects, the CLI also supports a flat feature layout with `--flat`, which generates those files directly under `src/users/` instead of nested `controllers/`, `services/`, and `dto/` folders. - -The controller demonstrates: - -- full CRUD routes -- validation with `ValidatedBody<T>` -- route-level guards/interceptors -- cleaner error mapping with `or_bad_request()` and `or_not_found_id()` - -## Settings Feature - -`src/settings/` contains CRUD endpoints plus runtime config endpoint: - -- `GET /api/v1/settings/runtime` - -This endpoint shows DI config injection (`Inject<AppConfig>`). - -## Prelude Setup - -The example also uses `nestforge::prelude::*` in entrypoints and root app files to keep framework -imports short, while its `src/lib.rs` barrel keeps app imports flat. - -## Versioning Feature - -`src/versioning/controllers/versioning_controller.rs` has two versions of the same route: - -- `GET /api/v1/versioning/hello` -- `GET /api/v2/versioning/hello` - -## Guards And Interceptors - -- Guards: `src/guards/` -- Interceptors: `src/interceptors/` - -Example uses the macro-based short style (`nestforge::guard!`, `nestforge::interceptor!`) to keep code small. +# Example App Walkthrough + +Path: `examples/hello-nestforge` + +Related examples: + +- `examples/hello-nestforge-graphql` +- `examples/hello-nestforge-grpc` + +## What This Example Shows + +- Root `src/lib.rs` barrel with app-level re-exports +- Nest-style module imports (`UsersModule`, `SettingsModule`, `VersioningModule`) +- Root app controllers at `src/` level +- Feature folders for each domain +- Global guard + interceptor registration +- Route-level guards/interceptors +- Route versioning (`v1`, `v2`) +- Config loading with schema checks +- TypeScript-style import support via transpiler + +## Running the Example + +Use the NestForge CLI to run with automatic transpilation: + +```bash +# From the examples/hello-nestforge directory +nestforge start + +# Or for development mode +nestforge dev +``` + +This enables TypeScript-style imports like: + +```typescript +import { Module, Controller, Get } from "nestforge/common"; +import { UsersService } from "./users.service"; +``` + +Which get transpiled to Rust: + +```rust +use nestforge::common::{Controller, Get}; +use self::users_service::UsersService; +``` + +## Boot Flow + +1. `main.rs` imports `AppModule` from the example crate root and creates the app with `NestForgeFactory::<AppModule>::create()` +2. Factory applies global prefix (`api`) +3. Factory registers global guard/interceptor +4. Module graph is resolved and providers/controllers are registered +5. HTTP server starts on port `3000` + +## AppModule + +`app_module.rs` does three main jobs: + +- imports feature modules +- registers root controllers (`AppController`, `HealthController`) +- registers root providers (`AppConfig`, `Db`) + +It also validates env values using: + +- `ConfigModule::for_root` +- `ConfigOptions` +- `EnvSchema` + +## Users Feature + +`src/users/` contains: + +- `controllers/users_controller.rs` +- `services/users_service.rs` +- `dto/*` + +For new projects, the CLI also supports a flat feature layout with `--flat`, which generates those files directly under `src/users/` instead of nested `controllers/`, `services/`, and `dto/` folders. + +The controller demonstrates: + +- full CRUD routes +- validation with `ValidatedBody<T>` +- route-level guards/interceptors +- cleaner error mapping with `or_bad_request()` and `or_not_found_id()` + +## Settings Feature + +`src/settings/` contains CRUD endpoints plus runtime config endpoint: + +- `GET /api/v1/settings/runtime` + +This endpoint shows DI config injection (`Inject<AppConfig>`). + +## Prelude Setup + +The example also uses `nestforge::prelude::*` in entrypoints and root app files to keep framework +imports short, while its `src/lib.rs` barrel keeps app imports flat. + +## Versioning Feature + +`src/versioning/controllers/versioning_controller.rs` has two versions of the same route: + +- `GET /api/v1/versioning/hello` +- `GET /api/v2/versioning/hello` + +## Guards And Interceptors + +- Guards: `src/guards/` +- Interceptors: `src/interceptors/` + +Example uses the macro-based short style (`nestforge::guard!`, `nestforge::interceptor!`) to keep code small. diff --git a/docs/quick-start.md b/docs/quick-start.md index 2d1cc99..c8b22b1 100644 --- a/docs/quick-start.md +++ b/docs/quick-start.md @@ -1,192 +1,200 @@ -# Quick Start - -Get your first NestForge application up and running in minutes. This guide covers project creation, basic routing, and running the server. - -## 1. Prerequisites - -Ensure you have the Rust toolchain installed: - -- [Install Rust](https://rustup.rs/) - -## 2. Install NestForge CLI - -The CLI is the recommended way to manage NestForge projects. - -```bash -# Install from crates.io -cargo install nestforge-cli - -# OR install from a local checkout (if developing the framework) -cargo install --path crates/nestforge-cli -``` - -## 3. Create a New Application - -Scaffold a fresh HTTP project: - -```bash -nestforge new my-nestforge-app -cd my-nestforge-app -``` - -The initial scaffold now creates a root barrel in `src/lib.rs` so the binary can import app -symbols directly from the package crate: - -```text -src/ - lib.rs - main.rs - app_config.rs - app_controller.rs - app_module.rs - health_controller.rs -``` - -That means generated bootstrap code now looks more like: - -```rust -use my_nestforge_app::AppModule; -use nestforge::prelude::*; -``` - -Feature modules can still use nested `controllers/`, `services/`, and `dto/` folders when you generate them that way. - -## 4. Run the Server - -NestForge projects use standard Cargo commands: - -```bash -cargo run -``` - -By default, the server will be available at [http://127.0.0.1:3000](http://127.0.0.1:3000). - ---- - -## Generator Layouts - -NestForge supports two generator layouts: - -- `nested`: controllers, services, and DTOs go into their own subfolders. -- `flat`: generated files stay side-by-side in the feature folder. - -Use `--flat` when generating a module or resource: - -```bash -nestforge g module users --flat -nestforge g resource users --module users --flat -``` - -When you run the resource generator in a terminal, NestForge can prompt for DTO fields and required/optional flags so the generated `Create*Dto`, `Update*Dto`, and entity DTO are usable immediately. Use `--no-prompt` if you want the default scaffold without interaction. - -Flat layout output: - -```text -src/users/ - mod.rs - user_dto.rs - create_user_dto.rs - update_user_dto.rs - users_controller.rs - users_service.rs -``` - -Nested layout output: - -```text -src/users/ - mod.rs - controllers/ - users_controller.rs - services/ - users_service.rs - dto/ - user_dto.rs - create_user_dto.rs - update_user_dto.rs -``` - -If you prefer the older nested layout, just omit `--flat`. - ---- - -## Basic Application Structure - -A minimal NestForge app consists of an **AppModule** and a **Controller**. - -### The Controller - -Define your routes in a struct marked with `#[controller]`. - -```rust -use nestforge::prelude::*; - -#[controller("/")] -pub struct AppController; - -#[routes] -impl AppController { - #[nestforge::get("/")] - async fn get_hello() -> ApiResult<String> { - Ok(axum::Json("Hello from NestForge!".to_string())) - } -} -``` - -### The Module - -Wire everything together in a module. - -```rust -use nestforge::prelude::*; -use crate::AppController; - -#[module( - controllers = [AppController], - providers = [], -)] -pub struct AppModule; -``` - -### The Main Entry Point - -Bootstrap the app using `NestForgeFactory`. - -```rust -use my_nestforge_app::AppModule; -use nestforge::prelude::*; - -#[tokio::main] -async fn main() -> anyhow::Result<()> { - NestForgeFactory::<AppModule>::create()? - .listen(3000) - .await?; - Ok(()) -} -``` - -## Prelude and Barrel Imports - -NestForge provides `nestforge::prelude::*` for the framework items most apps use repeatedly: - -```rust -use nestforge::prelude::*; -``` - -Generated apps also add a root `src/lib.rs` barrel that re-exports top-level symbols such as -`AppModule` and `AppConfig`, so app code can stay flatter: - -```rust -use my_nestforge_app::AppModule; -use my_nestforge_app::AppConfig; -``` - ---- - -## Next Steps - -- **Add OpenAPI Documentation**: Learn how to [setup OpenAPI from scratch](./auth-openapi.md). -- **Export a Static Spec**: Run `nestforge export-docs --format yaml --output docs/openapi.yaml` once your app enables the `openapi` feature. -- **Generate Features**: Use `nestforge g module <name>` to add new features. -- **Use Flat Feature Layouts**: Pass `--flat` to keep generated controllers, services, and DTOs directly in the feature folder, for example `nestforge g resource users --module users --flat`. -- **Dependency Injection**: Explore the [Module System](./module-system.md). +# Quick Start + +Get your first NestForge application up and running in minutes. This guide covers project creation, basic routing, and running the server. + +## 1. Prerequisites + +Ensure you have the Rust toolchain installed: + +- [Install Rust](https://rustup.rs/) + +## 2. Install NestForge CLI + +The CLI is the recommended way to manage NestForge projects. + +```bash +# Install from crates.io +cargo install nestforge-cli + +# OR install from a local checkout (if developing the framework) +cargo install --path crates/nestforge-cli +``` + +## 3. Create a New Application + +Scaffold a fresh HTTP project: + +```bash +nestforge new my-nestforge-app +cd my-nestforge-app +``` + +The initial scaffold now creates a root barrel in `src/lib.rs` so the binary can import app +symbols directly from the package crate: + +```text +src/ + lib.rs + main.rs + app_config.rs + app_controller.rs + app_module.rs + health_controller.rs +``` + +That means generated bootstrap code now looks more like: + +```rust +use my_nestforge_app::AppModule; +use nestforge::prelude::*; +``` + +Feature modules can still use nested `controllers/`, `services/`, and `dto/` folders when you generate them that way. + +## 4. Run the Server + +NestForge supports TypeScript-style imports. Use the CLI to run with automatic transpilation: + +```bash +nestforge start +``` + +Or for development mode: + +```bash +nestforge dev +``` + +This transpiles any `import { ... } from "..."` statements in your `src/` directory to valid Rust `use` statements before running. + +By default, the server will be available at [http://127.0.0.1:3000](http://127.0.0.1:3000). + +--- + +## Generator Layouts + +NestForge supports two generator layouts: + +- `nested`: controllers, services, and DTOs go into their own subfolders. +- `flat`: generated files stay side-by-side in the feature folder. + +Use `--flat` when generating a module or resource: + +```bash +nestforge g module users --flat +nestforge g resource users --module users --flat +``` + +When you run the resource generator in a terminal, NestForge can prompt for DTO fields and required/optional flags so the generated `Create*Dto`, `Update*Dto`, and entity DTO are usable immediately. Use `--no-prompt` if you want the default scaffold without interaction. + +Flat layout output: + +```text +src/users/ + mod.rs + user_dto.rs + create_user_dto.rs + update_user_dto.rs + users_controller.rs + users_service.rs +``` + +Nested layout output: + +```text +src/users/ + mod.rs + controllers/ + users_controller.rs + services/ + users_service.rs + dto/ + user_dto.rs + create_user_dto.rs + update_user_dto.rs +``` + +If you prefer the older nested layout, just omit `--flat`. + +--- + +## Basic Application Structure + +A minimal NestForge app consists of an **AppModule** and a **Controller**. + +### The Controller + +Define your routes in a struct marked with `#[controller]`. + +```rust +use nestforge::prelude::*; + +#[controller("/")] +pub struct AppController; + +#[routes] +impl AppController { + #[nestforge::get("/")] + async fn get_hello() -> ApiResult<String> { + Ok(axum::Json("Hello from NestForge!".to_string())) + } +} +``` + +### The Module + +Wire everything together in a module. + +```rust +use nestforge::prelude::*; +use crate::AppController; + +#[module( + controllers = [AppController], + providers = [], +)] +pub struct AppModule; +``` + +### The Main Entry Point + +Bootstrap the app using `NestForgeFactory`. + +```rust +use my_nestforge_app::AppModule; +use nestforge::prelude::*; + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + NestForgeFactory::<AppModule>::create()? + .listen(3000) + .await?; + Ok(()) +} +``` + +## Prelude and Barrel Imports + +NestForge provides `nestforge::prelude::*` for the framework items most apps use repeatedly: + +```rust +use nestforge::prelude::*; +``` + +Generated apps also add a root `src/lib.rs` barrel that re-exports top-level symbols such as +`AppModule` and `AppConfig`, so app code can stay flatter: + +```rust +use my_nestforge_app::AppModule; +use my_nestforge_app::AppConfig; +``` + +--- + +## Next Steps + +- **Add OpenAPI Documentation**: Learn how to [setup OpenAPI from scratch](./auth-openapi.md). +- **Export a Static Spec**: Run `nestforge export-docs --format yaml --output docs/openapi.yaml` once your app enables the `openapi` feature. +- **Generate Features**: Use `nestforge g module <name>` to add new features. +- **Use Flat Feature Layouts**: Pass `--flat` to keep generated controllers, services, and DTOs directly in the feature folder, for example `nestforge g resource users --module users --flat`. +- **Dependency Injection**: Explore the [Module System](./module-system.md). From 71b2732e5c921f2c1ce5b49ef3af7f9da73ddd65 Mon Sep 17 00:00:00 2001 From: vernonthedev <techjaja2@gmail.com> Date: Tue, 24 Mar 2026 15:54:56 +0300 Subject: [PATCH 10/11] refactor(hello-nestforge): update config loading to use EnvStore and FromEnv - Add FromEnv implementation for AppConfig - Refactor load_app_config to use ConfigService and EnvStore - Update load_config to use ConfigService - Remove ConfigService from AppModule exports - Add .nestforge.toml configuration file This change modernizes the example's configuration handling to leverage the new common module features, improving consistency with the library's API. --- .nestforge.toml | 20 ++++++++++++++++++++ examples/hello-nestforge/src/app_config.rs | 21 ++++++++++++++------- examples/hello-nestforge/src/app_module.rs | 4 ++-- 3 files changed, 36 insertions(+), 9 deletions(-) create mode 100644 .nestforge.toml diff --git a/.nestforge.toml b/.nestforge.toml new file mode 100644 index 0000000..cd34da7 --- /dev/null +++ b/.nestforge.toml @@ -0,0 +1,20 @@ +[transpiler] +# Custom import mappings for the TypeScript-style import transpiler +# Format: "from" = "to" +# Example: "@my-lib" = "my_crate" + +# Default mappings are handled in code: +# - "nestforge/*" -> "nestforge::*" +# - "@*" -> "nestforge::*" +# - "./*" -> "self::*" +# - "../*" -> "super::*" + +# Add custom mappings here +# "my-custom-lib" = "my_custom_crate" + +[transpiler.regex] +# Regex patterns for import detection +# Default import pattern: import\s*\{([^}]+)\}\s*from\s*["']([^"']+)["']\s*; +# Default module import pattern: import\s+([A-Za-z0-9_]+)\s+from\s+["']([^"']+)["']\s*; + +# Custom patterns can be added, but currently hardcoded \ No newline at end of file diff --git a/examples/hello-nestforge/src/app_config.rs b/examples/hello-nestforge/src/app_config.rs index 3fcace3..0d7a150 100644 --- a/examples/hello-nestforge/src/app_config.rs +++ b/examples/hello-nestforge/src/app_config.rs @@ -1,4 +1,4 @@ -use nestforge::{injectable, ConfigModule, ConfigOptions, ConfigService}; +use nestforge::{injectable, ConfigError, ConfigOptions, ConfigService, EnvStore, FromEnv}; #[injectable(factory = load_app_config)] pub struct AppConfig { @@ -6,15 +6,22 @@ pub struct AppConfig { pub log_level: String, } +impl FromEnv for AppConfig { + fn from_env(env: &EnvStore) -> Result<Self, ConfigError> { + Ok(AppConfig { + app_name: env.get("APP_NAME").unwrap_or("NestForge").to_string(), + log_level: env.get("LOG_LEVEL").unwrap_or("info").to_string(), + }) + } +} + fn load_app_config() -> anyhow::Result<AppConfig> { let options = ConfigOptions::new().env_file(".env"); - let config = ConfigModule::try_for_root_with_options(options)?; - Ok(AppConfig { - app_name: config.get_string_or("APP_NAME", "NestForge"), - log_level: config.get_string_or("LOG_LEVEL", "info"), - }) + let config = ConfigService::load_with_options(&options)?; + let env_store = EnvStore::from(config); + AppConfig::from_env(&env_store).map_err(Into::into) } pub fn load_config() -> ConfigService { - ConfigModule::for_root_with_options(ConfigOptions::new().env_file(".env")) + ConfigService::load_with_options(&ConfigOptions::new().env_file(".env")).unwrap() } diff --git a/examples/hello-nestforge/src/app_module.rs b/examples/hello-nestforge/src/app_module.rs index 452474e..838d102 100644 --- a/examples/hello-nestforge/src/app_module.rs +++ b/examples/hello-nestforge/src/app_module.rs @@ -1,4 +1,4 @@ -use nestforge::{module, ConfigService, Db, DbConfig}; +use nestforge::{module, Db, DbConfig}; use crate::{ app_config::load_config, settings::SettingsModule, users::UsersModule, @@ -17,6 +17,6 @@ fn connect_db() -> anyhow::Result<Db> { load_config(), connect_db()? ], - exports = [Db, AppConfig, ConfigService] + exports = [Db, AppConfig] )] pub struct AppModule; From 12e8828aeb5d9549185dea0261f526410caf4bdb Mon Sep 17 00:00:00 2001 From: vernonthedev <techjaja2@gmail.com> Date: Tue, 24 Mar 2026 15:56:41 +0300 Subject: [PATCH 11/11] docs: update README.md formatting and remove sections, fixes #24 - Adjust indentation in code examples - Add blank line in transpilation instructions - Remove Contributing and License sections --- README.md | 17 ++++++----------- 1 file changed, 6 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 076db16..596a975 100644 --- a/README.md +++ b/README.md @@ -87,10 +87,10 @@ import { UsersService } from "./users.service"; @Controller("users") export class UsersController { - @Get() - findAll() { - return []; - } + @Get() + findAll() { + return []; + } } ``` @@ -110,6 +110,7 @@ pub struct UsersController { ``` Run with transpilation: + ```bash nestforge start # or @@ -539,10 +540,4 @@ NestForgeFactory::<AppModule>::create()? - Main Documentation: [https://nestforge.suredoc.net](https://nestforge.suredoc.net) - Wiki: [https://github.com/vernonthedev/nestforge/wiki](https://github.com/vernonthedev/nestforge/wiki) -## Contributing - -See [CONTRIBUTING.md](CONTRIBUTING.md). - -## License - -Apache-2.0 ([LICENSE](LICENSE)). +- Wiki: [https://github.com/vernonthedev/nestforge/wiki](https://github.com/vernonthedev/nestforge/wiki)