diff --git a/rapina-cli/src/commands/new.rs b/rapina-cli/src/commands/new.rs index 435cc91e..2dd74471 100644 --- a/rapina-cli/src/commands/new.rs +++ b/rapina-cli/src/commands/new.rs @@ -5,12 +5,19 @@ use std::fs; use std::path::Path; use super::templates; +use super::templates::DatabaseType; /// Execute the `new` command to create a new Rapina project. /// /// `template` is `None` for the default starter and `Some("crud")` / `Some("auth")` /// for the optional starter templates. -pub fn execute(name: &str, template: Option<&str>, no_ai: bool) -> Result<(), String> { +/// `db_type` specifies the database to configure for the project. +pub fn execute( + name: &str, + template: Option<&str>, + db_type: Option<&DatabaseType>, + no_ai: bool, +) -> Result<(), String> { validate_project_name(name)?; let project_path = Path::new(name); @@ -24,25 +31,33 @@ pub fn execute(name: &str, template: Option<&str>, no_ai: bool) -> Result<(), St "Creating new Rapina project:".bright_cyan(), name.bold() ); + if let Some(ref db) = db_type { + println!( + " {} Database: {}", + "📦".bright_cyan(), + db.to_string().bold() + ); + } println!(); let src_path = project_path.join("src"); fs::create_dir_all(&src_path).map_err(|e| format!("Failed to create directory: {}", e))?; match template { - None | Some("rest-api") => templates::rest_api::generate(name, project_path, &src_path)?, - Some("crud") => templates::crud::generate(name, project_path, &src_path)?, - Some("auth") => templates::auth::generate(name, project_path, &src_path)?, - Some(other) => { - return Err(format!( - "Unknown template '{}'. Available: rest-api, crud, auth", - other - )); + None | Some("rest-api") => { + templates::rest_api::generate(name, project_path, &src_path, db_type)? } + Some("crud") => { + // Clap validation ensures --db is present for crud template + // Safe to unwrap because it has been validated in clap + templates::crud::generate(name, project_path, &src_path, db_type.unwrap())? + } + Some("auth") => templates::auth::generate(name, project_path, &src_path, db_type)?, + _ => unreachable!(), } // Create README.md - let readme = generate_readme(name); + let readme = generate_readme(name, db_type); fs::write(project_path.join("README.md"), readme) .map_err(|e| format!("Failed to write README.md: {}", e))?; println!(" {} Created {}", "✓".green(), "README.md".cyan()); @@ -72,6 +87,10 @@ pub fn execute(name: &str, template: Option<&str>, no_ai: bool) -> Result<(), St println!(); println!(" {}:", "Next steps".bright_yellow()); println!(" cd {}", name.cyan()); + if db_type.is_some() { + println!(" # Configure your database URL in .env or source"); + println!(" export DATABASE_URL=\"your-database-url\""); + } println!(" rapina dev"); println!(); @@ -80,9 +99,76 @@ pub fn execute(name: &str, template: Option<&str>, no_ai: bool) -> Result<(), St // ── README ─────────────────────────────────────────────────────────────────── -fn generate_readme(name: &str) -> String { +fn generate_readme(name: &str, db_type: Option<&DatabaseType>) -> String { + let db_section = if let Some(db) = db_type { + match db { + DatabaseType::Sqlite => { + r#" +## Database + +This project uses **SQLite** for data persistence. The database file is created automatically at `app.db`. + +To configure a different SQLite database or adjust connection pool settings, edit `src/main.rs`: + +```rust +.with_database(DatabaseConfig::new("sqlite://app.db?mode=rwc")) +``` + +Run migrations: +```bash +rapina migrate new create_your_table +``` +"# + } + DatabaseType::Postgres => { + r#" +## Database + +This project is configured for **PostgreSQL**. Set the `DATABASE_URL` environment variable before running: + +```bash +export DATABASE_URL="postgres://user:password@localhost:5432/dbname" +``` + +Or create a `.env` file: +```env +DATABASE_URL=postgres://user:password@localhost:5432/dbname +``` + +Run migrations: +```bash +rapina migrate new create_your_table +``` +"# + } + DatabaseType::Mysql => { + r#" +## Database + +This project is configured for **MySQL**. Set the `DATABASE_URL` environment variable before running: + +```bash +export DATABASE_URL="mysql://user:password@localhost:3306/dbname" +``` + +Or create a `.env` file: +```env +DATABASE_URL=mysql://user:password@localhost:3306/dbname +``` + +Run migrations: +```bash +rapina migrate new create_your_table +``` +"# + } + } + } else { + "" + }; + format!( - "# {name}\n\nA web application built with Rapina.\n\n## Getting started\n\n```bash\nrapina dev\n```\n\n## Routes\n\n- `GET /` — Hello world\n- `GET /__rapina/health` — Health check (built-in)\n" + "# {name}\n\nA web application built with Rapina.\n\n## Getting started\n\n```bash\nrapina dev\n```\n\n## Routes\n\n- `GET /` — Hello world\n- `GET /__rapina/health` — Health check (built-in)\n{db_section}" ) } @@ -471,4 +557,38 @@ mod tests { assert!(content.contains("DocumentedError")); assert!(content.contains("rapina add resource")); } + + #[test] + fn test_generate_readme_without_db() { + let content = generate_readme("test-app", None); + assert!(content.contains("# test-app")); + assert!(content.contains("rapina dev")); + assert!(!content.contains("## Database")); + } + + #[test] + fn test_generate_readme_with_sqlite() { + let content = generate_readme("test-app", Some(&DatabaseType::Sqlite)); + assert!(content.contains("## Database")); + assert!(content.contains("**SQLite**")); + assert!(content.contains("app.db")); + } + + #[test] + fn test_generate_readme_with_postgres() { + let content = generate_readme("test-app", Some(&DatabaseType::Postgres)); + assert!(content.contains("## Database")); + assert!(content.contains("**PostgreSQL**")); + assert!(content.contains("DATABASE_URL")); + assert!(content.contains("postgres://")); + } + + #[test] + fn test_generate_readme_with_mysql() { + let content = generate_readme("test-app", Some(&DatabaseType::Mysql)); + assert!(content.contains("## Database")); + assert!(content.contains("**MySQL**")); + assert!(content.contains("DATABASE_URL")); + assert!(content.contains("mysql://")); + } } diff --git a/rapina-cli/src/commands/templates/auth.rs b/rapina-cli/src/commands/templates/auth.rs index 5bfa2fc9..081432b0 100644 --- a/rapina-cli/src/commands/templates/auth.rs +++ b/rapina-cli/src/commands/templates/auth.rs @@ -1,10 +1,19 @@ use std::path::Path; -use super::{generate_cargo_toml, generate_gitignore, write_file}; - -pub fn generate(name: &str, project_path: &Path, src_path: &Path) -> Result<(), String> { +use super::{ + DatabaseType, generate_cargo_toml, generate_database_config, generate_db_import, + generate_env_content, generate_gitignore, generate_gitignore_extras, generate_rapina_dep, + generate_with_database_line, write_file, +}; + +pub fn generate( + name: &str, + project_path: &Path, + src_path: &Path, + db_type: Option<&DatabaseType>, +) -> Result<(), String> { let version = env!("CARGO_PKG_VERSION"); - let rapina_dep = format!("\"{}\"", version); + let rapina_dep = generate_rapina_dep(version, db_type); write_file( &project_path.join("Cargo.toml"), @@ -13,7 +22,7 @@ pub fn generate(name: &str, project_path: &Path, src_path: &Path) -> Result<(), )?; write_file( &src_path.join("main.rs"), - &generate_main_rs(), + &generate_main_rs(db_type), "src/main.rs", )?; write_file( @@ -23,31 +32,41 @@ pub fn generate(name: &str, project_path: &Path, src_path: &Path) -> Result<(), )?; write_file( &project_path.join(".gitignore"), - &generate_gitignore(&[".env"]), + &generate_gitignore(&generate_gitignore_extras(db_type)), ".gitignore", )?; write_file( - &project_path.join(".env.example"), - &generate_env_example(), - ".env.example", + &project_path.join(".env"), + &generate_auth_env_content(db_type), + ".env", )?; Ok(()) } -fn generate_main_rs() -> String { - r#"mod auth; +fn generate_auth_env_content(db_type: Option<&DatabaseType>) -> String { + const AUTH_VARS: &str = "# Authentication Configuration\n# Replace JWT_SECRET with a long, random string (at least 32 characters).\nJWT_SECRET=change-me-to-a-long-random-secret-change-me\nJWT_EXPIRATION=3600"; + generate_env_content(db_type, Some(AUTH_VARS)) +} + +fn generate_main_rs(db_type: Option<&DatabaseType>) -> String { + let db_import = generate_db_import(db_type); + let db_setup = generate_database_config(db_type); + let with_database_line = generate_with_database_line(db_type); + + format!( + r#"mod auth; use rapina::prelude::*; use rapina::middleware::RequestLogMiddleware; - +{} #[get("/me")] -async fn me(user: CurrentUser) -> Json { - Json(serde_json::json!({ "id": user.id })) -} +async fn me(user: CurrentUser) -> Json {{ + Json(serde_json::json!({{ "id": user.id }})) +}} #[tokio::main] -async fn main() -> std::io::Result<()> { +async fn main() -> std::io::Result<()> {{ load_dotenv(); let auth_config = AuthConfig::from_env().expect("JWT_SECRET is required"); @@ -57,6 +76,7 @@ async fn main() -> std::io::Result<()> { .post("/auth/login", auth::login) .get("/me", me); + {} Rapina::new() .with_tracing(TracingConfig::new()) .middleware(RequestLogMiddleware::new()) @@ -65,12 +85,13 @@ async fn main() -> std::io::Result<()> { .public_route("POST", "/auth/register") .public_route("POST", "/auth/login") .state(auth_config) - .router(router) +{} .router(router) .listen("127.0.0.1:3000") .await -} -"# - .to_string() +}} +"#, + db_import, db_setup, with_database_line + ) } fn generate_auth_rs() -> String { @@ -99,8 +120,8 @@ pub async fn register(body: Json) -> Result #[public] #[post("/auth/login")] pub async fn login( - body: Json, auth: State, + body: Json, ) -> Result> { // TODO: validate credentials against database if body.username == "admin" && body.password == "password" { @@ -114,20 +135,13 @@ pub async fn login( .to_string() } -fn generate_env_example() -> String { - r#"JWT_SECRET=change-me-to-a-long-random-secret -JWT_EXPIRATION=3600 -"# - .to_string() -} - #[cfg(test)] mod tests { use super::*; #[test] fn test_generate_main_rs_has_auth_routes() { - let content = generate_main_rs(); + let content = generate_main_rs(None); assert!(content.contains(".post(\"/auth/register\", auth::register)")); assert!(content.contains(".post(\"/auth/login\", auth::login)")); assert!(content.contains(".get(\"/me\", me)")); @@ -137,7 +151,7 @@ mod tests { #[test] fn test_generate_main_rs_marks_public_routes() { - let content = generate_main_rs(); + let content = generate_main_rs(None); assert!(content.contains("with_health_check(true)")); assert!(content.contains("public_route(\"POST\", \"/auth/register\")")); assert!(content.contains("public_route(\"POST\", \"/auth/login\")")); @@ -155,10 +169,19 @@ mod tests { } #[test] - fn test_generate_env_example() { - let content = generate_env_example(); + fn test_generate_env_file() { + let content = generate_auth_env_content(None); assert!(content.contains("JWT_SECRET=")); assert!(content.contains("JWT_EXPIRATION=")); + assert!(content.contains("Replace")); + } + + #[test] + fn test_generate_env_file_with_database() { + let content = generate_auth_env_content(Some(&DatabaseType::Sqlite)); + assert!(content.contains("JWT_SECRET=")); + assert!(content.contains("DATABASE_URL=")); + assert!(content.contains("Replace")); } #[test] @@ -168,4 +191,20 @@ mod tests { assert!(content.contains("Cargo.lock")); assert!(content.contains(".env")); } + + #[test] + fn test_generate_main_rs_without_database() { + let content = generate_main_rs(None); + assert!(!content.contains("with_database")); + assert!(!content.contains("db_config")); + assert!(!content.contains("DatabaseConfig")); + } + + #[test] + fn test_generate_main_rs_with_database() { + let content = generate_main_rs(Some(&DatabaseType::Sqlite)); + assert!(content.contains("with_database(db_config)")); + assert!(content.contains("let db_config")); + assert!(content.contains("use rapina::database::DatabaseConfig")); + } } diff --git a/rapina-cli/src/commands/templates/crud.rs b/rapina-cli/src/commands/templates/crud.rs index c8e40a0b..01461d09 100644 --- a/rapina-cli/src/commands/templates/crud.rs +++ b/rapina-cli/src/commands/templates/crud.rs @@ -1,11 +1,19 @@ use std::fs; use std::path::Path; -use super::{generate_cargo_toml, generate_gitignore, write_file}; +use super::{ + DatabaseType, generate_cargo_toml, generate_env_content, generate_gitignore, + generate_gitignore_extras, generate_rapina_dep, write_file, +}; -pub fn generate(name: &str, project_path: &Path, src_path: &Path) -> Result<(), String> { +pub fn generate( + name: &str, + project_path: &Path, + src_path: &Path, + db_type: &DatabaseType, +) -> Result<(), String> { let version = env!("CARGO_PKG_VERSION"); - let rapina_dep = format!("{{ version = \"{version}\", features = [\"sqlite\"] }}"); + let rapina_dep = generate_rapina_dep(version, Some(db_type)); write_file( &project_path.join("Cargo.toml"), @@ -24,7 +32,7 @@ pub fn generate(name: &str, project_path: &Path, src_path: &Path) -> Result<(), )?; write_file( &project_path.join(".gitignore"), - &generate_gitignore(&["*.db"]), + &generate_gitignore(&generate_gitignore_extras(Some(db_type))), ".gitignore", )?; @@ -42,6 +50,13 @@ pub fn generate(name: &str, project_path: &Path, src_path: &Path) -> Result<(), "src/migrations/m20240101_000001_create_items.rs", )?; + // Generate .env file + write_file( + &project_path.join(".env"), + &generate_env_content(Some(db_type), None), + ".env", + )?; + Ok(()) } @@ -55,11 +70,15 @@ use rapina::middleware::RequestLogMiddleware; #[tokio::main] async fn main() -> std::io::Result<()> { + load_dotenv(); + + let db_config = DatabaseConfig::from_env().expect("Failed to configure database"); + Rapina::new() .with_tracing(TracingConfig::new()) .middleware(RequestLogMiddleware::new()) .with_health_check(true) - .with_database(DatabaseConfig::new("sqlite://app.db?mode=rwc")) + .with_database(db_config) .await? .run_migrations::() .await? @@ -205,8 +224,10 @@ mod tests { #[test] fn test_generate_main_rs_uses_database_config() { let content = generate_main_rs(); - assert!(content.contains("DatabaseConfig::new(")); - assert!(content.contains(".with_database(")); + assert!(content.contains("load_dotenv()")); + assert!(content.contains("let db_config =")); + assert!(content.contains(".with_database(db_config)")); + assert!(content.contains(".await?")); assert!(content.contains(".run_migrations::()")); assert!(!content.contains("rapina::database::connect")); } @@ -221,6 +242,12 @@ mod tests { assert!(content.contains(".delete(\"/items/:id\", items::delete)")); } + #[test] + fn test_generate_main_rs_postgres_uses_from_env() { + let content = generate_main_rs(); + assert!(content.contains("DatabaseConfig::from_env()")); + } + #[test] fn test_generate_items_rs_has_all_handlers() { let content = generate_items_rs(); @@ -255,10 +282,31 @@ mod tests { } #[test] - fn test_gitignore_includes_db_files() { + fn test_gitignore_includes_db_files_for_sqlite() { let content = generate_gitignore(&["*.db"]); assert!(content.contains("/target")); assert!(content.contains("Cargo.lock")); assert!(content.contains("*.db")); } + + #[test] + fn test_generate_env_file_sqlite() { + let content = generate_env_content(Some(&DatabaseType::Sqlite), None); + assert!(content.contains("DATABASE_URL=sqlite://")); + assert!(content.contains("Replace")); + } + + #[test] + fn test_generate_env_file_postgres() { + let content = generate_env_content(Some(&DatabaseType::Postgres), None); + assert!(content.contains("DATABASE_URL=postgres://")); + assert!(content.contains("Replace")); + } + + #[test] + fn test_generate_env_file_mysql() { + let content = generate_env_content(Some(&DatabaseType::Mysql), None); + assert!(content.contains("DATABASE_URL=mysql://")); + assert!(content.contains("Replace")); + } } diff --git a/rapina-cli/src/commands/templates/mod.rs b/rapina-cli/src/commands/templates/mod.rs index aebc0bc7..80a78f56 100644 --- a/rapina-cli/src/commands/templates/mod.rs +++ b/rapina-cli/src/commands/templates/mod.rs @@ -6,6 +6,40 @@ use colored::Colorize; use std::fs; use std::path::Path; +/// Database types supported for project generation. +#[derive(Clone, PartialEq)] +pub enum DatabaseType { + Sqlite, + Postgres, + Mysql, +} + +impl std::str::FromStr for DatabaseType { + type Err = String; + + fn from_str(s: &str) -> Result { + match s.to_lowercase().as_str() { + "sqlite" => Ok(DatabaseType::Sqlite), + "postgres" | "postgresql" => Ok(DatabaseType::Postgres), + "mysql" => Ok(DatabaseType::Mysql), + _ => Err(format!( + "Unknown database type '{}'. Available: sqlite, postgres, mysql", + s + )), + } + } +} + +impl std::fmt::Display for DatabaseType { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + DatabaseType::Sqlite => write!(f, "sqlite"), + DatabaseType::Postgres => write!(f, "postgres"), + DatabaseType::Mysql => write!(f, "mysql"), + } + } +} + /// Write `content` to `path`, printing a confirmation line on success. pub fn write_file(path: &Path, content: &str, display_name: &str) -> Result<(), String> { fs::write(path, content).map_err(|e| format!("Failed to write {display_name}: {e}"))?; @@ -30,7 +64,6 @@ rapina = {rapina_dep} tokio = {{ version = "1", features = ["full"] }} serde = {{ version = "1", features = ["derive"] }} serde_json = "1" -hyper = "1" "# ) } @@ -44,3 +77,217 @@ pub fn generate_gitignore(extras: &[&str]) -> String { } content } + +/// Generate the rapina dependency line for Cargo.toml. +/// +/// Returns the full right-hand side of the `rapina = …` entry. +/// - Without database: `"\"0.1.0\""` +/// - With database: `"{ version = \"0.1.0\", features = [\"sqlite\"] }"` +pub fn generate_rapina_dep(version: &str, db_type: Option<&DatabaseType>) -> String { + if let Some(db) = db_type { + let feature = match db { + DatabaseType::Sqlite => "sqlite", + DatabaseType::Postgres => "postgres", + DatabaseType::Mysql => "mysql", + }; + format!( + "{{ version = \"{}\", features = [\"{}\"] }}", + version, feature + ) + } else { + format!("\"{}\"", version) + } +} + +/// Generate the `use rapina::database::DatabaseConfig;` import line. +/// Returns an empty string if no database is configured. +pub fn generate_db_import(db_type: Option<&DatabaseType>) -> &'static str { + db_type.map_or("", |_| "use rapina::database::DatabaseConfig;\n") +} + +/// Generate the `let db_config = ...` line for database configuration. +/// Uses `DatabaseConfig::from_env()` for all database types since `.env` is auto-generated. +/// Returns an empty string if no database is configured. +pub fn generate_database_config(db_type: Option<&DatabaseType>) -> &'static str { + db_type.map_or("", |_| "let db_config = DatabaseConfig::from_env().expect(\"Failed to configure database\");\n") +} + +/// Generate the `.with_database(db_config)` builder line. +/// Returns an empty string if no database is configured. +pub fn generate_with_database_line(db_type: Option<&DatabaseType>) -> &'static str { + db_type.map_or( + "", + |_| " .with_database(db_config)\n .await?\n", + ) +} + +/// Generate `.env` file content with optional extra variables. +/// Includes comments with instructions to replace with real values. +/// +/// `db_type` is `None` when no database is configured (e.g., auth-only projects). +/// `extra_vars` can be used to add additional environment variables (e.g., JWT_SECRET for auth). +pub fn generate_env_content(db_type: Option<&DatabaseType>, extra_vars: Option<&str>) -> String { + let db_section = match db_type { + Some(DatabaseType::Sqlite) => "DATABASE_URL=sqlite://app.db?mode=rwc", + Some(DatabaseType::Postgres) => { + "DATABASE_URL=postgres://username:password@localhost:5432/myapp" + } + Some(DatabaseType::Mysql) => "DATABASE_URL=mysql://username:password@localhost:3306/myapp", + None => "", + }; + + let extra_section = extra_vars + .filter(|v| !v.is_empty()) + .map(|v| format!("\n{}\n", v)) + .unwrap_or_default(); + + format!( + "# ⚠️ Replace the values below with your actual configuration.\n# Do not commit this file with real credentials in production!\n\n{}{}", + db_section, extra_section + ) +} + +/// Generate the `.gitignore` extras for a given database type. +/// Always includes `.env`. Adds `*.db` for SQLite. +pub fn generate_gitignore_extras(db_type: Option<&DatabaseType>) -> Vec<&'static str> { + let mut extra = vec![".env"]; + if let Some(DatabaseType::Sqlite) = db_type { + extra.push("*.db"); + } + extra +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_generate_rapina_dep_without_db() { + let dep = generate_rapina_dep("0.1.0", None); + assert_eq!(dep, "\"0.1.0\""); + } + + #[test] + fn test_generate_rapina_dep_with_sqlite() { + let dep = generate_rapina_dep("0.1.0", Some(&DatabaseType::Sqlite)); + assert!(dep.contains("sqlite")); + assert!(dep.contains("0.1.0")); + } + + #[test] + fn test_generate_rapina_dep_with_postgres() { + let dep = generate_rapina_dep("0.1.0", Some(&DatabaseType::Postgres)); + assert!(dep.contains("postgres")); + assert!(dep.contains("0.1.0")); + } + + #[test] + fn test_generate_rapina_dep_with_mysql() { + let dep = generate_rapina_dep("0.1.0", Some(&DatabaseType::Mysql)); + assert!(dep.contains("mysql")); + assert!(dep.contains("0.1.0")); + } + + #[test] + fn test_generate_db_import_with_db() { + let import = generate_db_import(Some(&DatabaseType::Sqlite)); + assert!(import.contains("use rapina::database::DatabaseConfig")); + } + + #[test] + fn test_generate_db_import_without_db() { + let import = generate_db_import(None); + assert!(import.is_empty()); + } + + #[test] + fn test_generate_database_config_with_db() { + let config = generate_database_config(Some(&DatabaseType::Postgres)); + assert!(config.contains("let db_config =")); + assert!( + config.contains("DatabaseConfig::from_env().expect(\"Failed to configure database\");") + ); + } + + #[test] + fn test_generate_database_config_without_db() { + let config = generate_database_config(None); + assert!(config.is_empty()); + } + + #[test] + fn test_generate_env_content_sqlite() { + let content = generate_env_content(Some(&DatabaseType::Sqlite), None); + assert!(content.contains("sqlite://")); + assert!(content.contains("Replace")); + } + + #[test] + fn test_generate_env_content_postgres() { + let content = generate_env_content(Some(&DatabaseType::Postgres), None); + assert!(content.contains("postgres://")); + assert!(content.contains("Replace")); + } + + #[test] + fn test_generate_env_content_mysql() { + let content = generate_env_content(Some(&DatabaseType::Mysql), None); + assert!(content.contains("mysql://")); + assert!(content.contains("Replace")); + } + + #[test] + fn test_generate_env_content_with_extra_vars() { + let content = generate_env_content(Some(&DatabaseType::Sqlite), Some("EXTRA_VAR=test")); + assert!(content.contains("sqlite://")); + assert!(content.contains("EXTRA_VAR=test")); + } + + #[test] + fn test_generate_env_content_no_database() { + let content = generate_env_content(None, Some("JWT_SECRET=test")); + assert!(!content.contains("DATABASE_URL")); + assert!(content.contains("JWT_SECRET=test")); + assert!(content.contains("Replace")); + } + + #[test] + fn test_generate_gitignore_extras_sqlite() { + let extras = generate_gitignore_extras(Some(&DatabaseType::Sqlite)); + assert!(extras.contains(&"*.db")); + assert!(extras.contains(&".env")); + } + + #[test] + fn test_generate_gitignore_extras_postgres() { + let extras = generate_gitignore_extras(Some(&DatabaseType::Postgres)); + assert!(extras.contains(&".env")); + assert!(!extras.contains(&"*.db")); + } + + #[test] + fn test_generate_gitignore_extras_mysql() { + let extras = generate_gitignore_extras(Some(&DatabaseType::Mysql)); + assert!(extras.contains(&".env")); + assert!(!extras.contains(&"*.db")); + } + + #[test] + fn test_generate_gitignore_extras_none() { + let extras = generate_gitignore_extras(None); + assert!(extras.contains(&".env")); + assert!(!extras.contains(&"*.db")); + } + + #[test] + fn test_generate_with_database_line_with_db() { + let line = generate_with_database_line(Some(&DatabaseType::Sqlite)); + assert!(line.contains(".with_database(db_config)")); + } + + #[test] + fn test_generate_with_database_line_without_db() { + let line = generate_with_database_line(None); + assert!(line.is_empty()); + } +} diff --git a/rapina-cli/src/commands/templates/rest_api.rs b/rapina-cli/src/commands/templates/rest_api.rs index 2c7fca7d..8681267f 100644 --- a/rapina-cli/src/commands/templates/rest_api.rs +++ b/rapina-cli/src/commands/templates/rest_api.rs @@ -1,10 +1,19 @@ use std::path::Path; -use super::{generate_cargo_toml, generate_gitignore, write_file}; +use super::{ + generate_cargo_toml, generate_database_config, generate_db_import, generate_env_content, + generate_gitignore, generate_gitignore_extras, generate_rapina_dep, + generate_with_database_line, write_file, +}; -pub fn generate(name: &str, project_path: &Path, src_path: &Path) -> Result<(), String> { +pub fn generate( + name: &str, + project_path: &Path, + src_path: &Path, + db_type: Option<&super::DatabaseType>, +) -> Result<(), String> { let version = env!("CARGO_PKG_VERSION"); - let rapina_dep = format!("\"{}\"", version); + let rapina_dep = generate_rapina_dep(version, db_type); write_file( &project_path.join("Cargo.toml"), @@ -13,50 +22,68 @@ pub fn generate(name: &str, project_path: &Path, src_path: &Path) -> Result<(), )?; write_file( &src_path.join("main.rs"), - &generate_main_rs(), + &generate_main_rs(db_type), "src/main.rs", )?; write_file( &project_path.join(".gitignore"), - &generate_gitignore(&[]), + &generate_gitignore(&generate_gitignore_extras(db_type)), ".gitignore", )?; + // Generate .env file if database is configured + if let Some(db) = db_type { + write_file( + &project_path.join(".env"), + &generate_env_content(Some(db), None), + ".env", + )?; + } + Ok(()) } -fn generate_main_rs() -> String { - r#"use rapina::prelude::*; +fn generate_main_rs(db_type: Option<&super::DatabaseType>) -> String { + let db_import = generate_db_import(db_type); + let db_config = generate_database_config(db_type); + let with_database_line = generate_with_database_line(db_type); + + // Include load_dotenv() when database is configured + let load_dotenv_line = db_type.map_or("", |_| "load_dotenv();\n"); + + format!( + r#"use rapina::prelude::*; use rapina::middleware::RequestLogMiddleware; use rapina::schemars; - +{db_import} #[derive(Serialize, JsonSchema)] -struct MessageResponse { +struct MessageResponse {{ message: String, -} +}} #[get("/")] -async fn hello() -> Json { - Json(MessageResponse { +async fn hello() -> Json {{ + Json(MessageResponse {{ message: "Hello from Rapina!".to_string(), - }) -} + }}) +}} #[tokio::main] -async fn main() -> std::io::Result<()> { +async fn main() -> std::io::Result<()> {{ + {load_dotenv_line} let router = Router::new() .get("/", hello); - + {db_config} Rapina::new() .with_tracing(TracingConfig::new()) .middleware(RequestLogMiddleware::new()) .with_health_check(true) - .router(router) +{with_database_line} .router(router) .listen("127.0.0.1:3000") .await -} +}} "# - .to_string() + ) } #[cfg(test)] @@ -65,10 +92,27 @@ mod tests { #[test] fn test_generate_main_rs_has_hello_route() { - let content = generate_main_rs(); + let content = generate_main_rs(None); assert!(content.contains("#[get(\"/\")]")); assert!(content.contains("async fn hello()")); assert!(content.contains("with_health_check(true)")); assert!(content.contains("Rapina::new()")); } + + #[test] + fn test_generate_main_rs_without_database() { + let content = generate_main_rs(None); + assert!(!content.contains("with_database")); + assert!(!content.contains("db_config")); + assert!(!content.contains("DatabaseConfig")); + } + + #[test] + fn test_generate_main_rs_with_database() { + let content = generate_main_rs(Some(&crate::commands::templates::DatabaseType::Sqlite)); + assert!(content.contains("load_dotenv();")); + assert!(content.contains("with_database(db_config)")); + assert!(content.contains("let db_config")); + assert!(content.contains("use rapina::database::DatabaseConfig")); + } } diff --git a/rapina-cli/src/main.rs b/rapina-cli/src/main.rs index cdab5d94..ae82bf55 100644 --- a/rapina-cli/src/main.rs +++ b/rapina-cli/src/main.rs @@ -7,6 +7,8 @@ mod common; use clap::{Parser, Subcommand}; use colored::Colorize; +use crate::commands::templates::DatabaseType; + #[derive(Parser)] #[command(name = "rapina")] #[command(author, version, about = "CLI tool for the Rapina web framework", long_about = None)] @@ -23,9 +25,13 @@ enum Commands { New { /// Name of the project to create name: String, - /// Starter template (crud, auth). Defaults to a REST API scaffold when omitted. - #[arg(long)] + /// Starter template (rest-api, crud, auth). Defaults to a REST API scaffold when omitted. + /// Requires `--db` when using `crud` template. + #[arg(long, value_parser = ["rest-api", "crud", "auth"], requires_if("crud", "db"))] template: Option, + /// Database type (sqlite, postgres, mysql). Required when using `--template crud`. + #[arg(long, value_name = "DB")] + db: Option, /// Skip generating AI assistant config files (AGENT.md, .claude/, .cursor/) #[arg(long)] no_ai: bool, @@ -255,9 +261,10 @@ fn main() { Some(Commands::New { name, template, + db, no_ai, }) => { - if let Err(e) = commands::new::execute(&name, template.as_deref(), no_ai) { + if let Err(e) = commands::new::execute(&name, template.as_deref(), db.as_ref(), no_ai) { eprintln!("{} {}", "Error:".red().bold(), e); std::process::exit(1); }