diff --git a/Cargo.lock b/Cargo.lock index 2a00251..05a305d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -350,6 +350,7 @@ dependencies = [ "console 0.16.2", "dialoguer", "indicatif", + "predicates", "reqwest", "serde", "serde_json", @@ -358,7 +359,7 @@ dependencies = [ "thiserror 2.0.17", "tokio", "toml", - "toml_edit", + "toml_edit 0.22.27", "tracing", "tracing-subscriber", "walkdir", @@ -2024,6 +2025,12 @@ dependencies = [ "tempfile", ] +[[package]] +name = "normalize-line-endings" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61807f77802ff30975e01f4f071c8ba10c022052f98b3294119f3e615d13e5be" + [[package]] name = "normpath" version = "1.5.0" @@ -2519,7 +2526,10 @@ checksum = "a5d19ee57562043d37e82899fade9a22ebab7be9cef5026b07fda9cdd4293573" dependencies = [ "anstyle", "difflib", + "float-cmp", + "normalize-line-endings", "predicates-core", + "regex", ] [[package]] @@ -2538,6 +2548,15 @@ dependencies = [ "termtree", ] +[[package]] +name = "proc-macro-crate" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "219cb19e96be00ab2e37d6e299658a0cfa83e52429179969b0f0121b4ac46983" +dependencies = [ + "toml_edit 0.23.10+spec-1.0.0", +] + [[package]] name = "proc-macro-error" version = "1.0.4" @@ -3142,6 +3161,7 @@ dependencies = [ name = "rustapi-macros" version = "0.1.233" dependencies = [ + "proc-macro-crate", "proc-macro2", "quote", "serde_json", @@ -4330,8 +4350,8 @@ checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" dependencies = [ "serde", "serde_spanned", - "toml_datetime", - "toml_edit", + "toml_datetime 0.6.11", + "toml_edit 0.22.27", ] [[package]] @@ -4343,6 +4363,15 @@ dependencies = [ "serde", ] +[[package]] +name = "toml_datetime" +version = "0.7.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +dependencies = [ + "serde_core", +] + [[package]] name = "toml_edit" version = "0.22.27" @@ -4352,11 +4381,32 @@ dependencies = [ "indexmap 2.12.1", "serde", "serde_spanned", - "toml_datetime", + "toml_datetime 0.6.11", "toml_write", "winnow", ] +[[package]] +name = "toml_edit" +version = "0.23.10+spec-1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84c8b9f757e028cee9fa244aea147aab2a9ec09d5325a9b01e0a49730c2b5269" +dependencies = [ + "indexmap 2.12.1", + "toml_datetime 0.7.5+spec-1.1.0", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.0.6+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3198b4b0a8e11f09dd03e133c0280504d0801269e9afa46362ffde1cbeebf44" +dependencies = [ + "winnow", +] + [[package]] name = "toml_write" version = "0.1.2" diff --git a/crates/cargo-rustapi/Cargo.toml b/crates/cargo-rustapi/Cargo.toml index b424ca6..e636615 100644 --- a/crates/cargo-rustapi/Cargo.toml +++ b/crates/cargo-rustapi/Cargo.toml @@ -48,6 +48,7 @@ anyhow = "1.0" [dev-dependencies] tempfile = "3.10" assert_cmd = "2.0" +predicates = "3.1" [features] default = ["remote-spec"] diff --git a/crates/cargo-rustapi/src/cli.rs b/crates/cargo-rustapi/src/cli.rs index 2cacbf0..52c778c 100644 --- a/crates/cargo-rustapi/src/cli.rs +++ b/crates/cargo-rustapi/src/cli.rs @@ -1,7 +1,8 @@ //! CLI argument parsing use crate::commands::{ - self, AddArgs, ClientArgs, DeployArgs, DoctorArgs, GenerateArgs, NewArgs, RunArgs, WatchArgs, + self, AddArgs, ClientArgs, DeployArgs, DoctorArgs, GenerateArgs, MigrateArgs, NewArgs, RunArgs, + WatchArgs, }; use clap::{Parser, Subcommand}; @@ -36,6 +37,10 @@ enum Commands { #[command(subcommand)] Generate(GenerateArgs), + /// Database migration commands + #[command(subcommand)] + Migrate(MigrateArgs), + /// Open API documentation in browser Docs { /// Port to check for running server @@ -61,6 +66,7 @@ impl Cli { Commands::Add(args) => commands::add(args).await, Commands::Doctor(args) => commands::doctor(args).await, Commands::Generate(args) => commands::generate(args).await, + Commands::Migrate(args) => commands::migrate(args).await, Commands::Docs { port } => commands::open_docs(port).await, Commands::Client(args) => commands::client(args).await, Commands::Deploy(args) => commands::deploy(args).await, diff --git a/crates/cargo-rustapi/src/commands/migrate.rs b/crates/cargo-rustapi/src/commands/migrate.rs new file mode 100644 index 0000000..3d99548 --- /dev/null +++ b/crates/cargo-rustapi/src/commands/migrate.rs @@ -0,0 +1,527 @@ +//! Database migration commands +//! +//! Provides a wrapper around sqlx-cli for database migrations. +//! Supports creating, running, reverting, and checking migration status. + +use anyhow::{Context, Result}; +use clap::{Args, Subcommand}; +use console::{style, Emoji}; +use indicatif::{ProgressBar, ProgressStyle}; +use std::path::Path; +use std::time::Duration; +use tokio::fs; +use tokio::process::Command; + +static CHECK: Emoji<'_, '_> = Emoji("✅ ", "+ "); +static WARN: Emoji<'_, '_> = Emoji("⚠️ ", "! "); +static ERROR: Emoji<'_, '_> = Emoji("❌ ", "x "); +static DB: Emoji<'_, '_> = Emoji("🗄️ ", "# "); +static ARROW: Emoji<'_, '_> = Emoji("➡️ ", "-> "); + +/// Database migration commands +#[derive(Subcommand, Debug)] +pub enum MigrateArgs { + /// Run all pending migrations + Run(MigrateRunArgs), + + /// Revert the last migration (or N migrations) + Revert(MigrateRevertArgs), + + /// Show migration status + Status(MigrateStatusArgs), + + /// Create a new migration + Create(MigrateCreateArgs), + + /// Reset database (drop, create, run all migrations) + Reset(MigrateResetArgs), +} + +#[derive(Args, Debug)] +pub struct MigrateRunArgs { + /// Database URL (overrides DATABASE_URL env var) + #[arg(long)] + pub database_url: Option, + + /// Run in dry-run mode (don't actually apply) + #[arg(long)] + pub dry_run: bool, + + /// Migrations directory + #[arg(long, default_value = "migrations")] + pub source: String, +} + +#[derive(Args, Debug)] +pub struct MigrateRevertArgs { + /// Database URL (overrides DATABASE_URL env var) + #[arg(long)] + pub database_url: Option, + + /// Number of migrations to revert + #[arg(short, long, default_value = "1")] + pub count: u32, + + /// Run in dry-run mode + #[arg(long)] + pub dry_run: bool, + + /// Migrations directory + #[arg(long, default_value = "migrations")] + pub source: String, +} + +#[derive(Args, Debug)] +pub struct MigrateStatusArgs { + /// Database URL (overrides DATABASE_URL env var) + #[arg(long)] + pub database_url: Option, + + /// Migrations directory + #[arg(long, default_value = "migrations")] + pub source: String, +} + +#[derive(Args, Debug)] +pub struct MigrateCreateArgs { + /// Migration name (e.g., "create_users_table") + pub name: String, + + /// Create reversible migration (with up.sql and down.sql) + #[arg(short, long)] + pub reversible: bool, + + /// Migrations directory + #[arg(long, default_value = "migrations")] + pub source: String, + + /// Create migration with timestamp prefix instead of sequential + #[arg(long)] + pub timestamp: bool, +} + +#[derive(Args, Debug)] +pub struct MigrateResetArgs { + /// Database URL (overrides DATABASE_URL env var) + #[arg(long)] + pub database_url: Option, + + /// Migrations directory + #[arg(long, default_value = "migrations")] + pub source: String, + + /// Skip confirmation prompt + #[arg(short, long)] + pub yes: bool, +} + +/// Execute migration commands +pub async fn migrate(args: MigrateArgs) -> Result<()> { + match args { + MigrateArgs::Run(args) => { + ensure_sqlx_installed().await?; + run_migrations(args).await + } + MigrateArgs::Revert(args) => { + ensure_sqlx_installed().await?; + revert_migrations(args).await + } + MigrateArgs::Status(args) => { + ensure_sqlx_installed().await?; + show_status(args).await + } + MigrateArgs::Create(args) => create_migration(args).await, // No sqlx needed + MigrateArgs::Reset(args) => { + ensure_sqlx_installed().await?; + reset_database(args).await + } + } +} + +/// Ensure sqlx-cli is installed +async fn ensure_sqlx_installed() -> Result<()> { + let output = Command::new("sqlx").arg("--version").output().await; + + match output { + Ok(out) if out.status.success() => Ok(()), + _ => { + println!( + "{}", + style("sqlx-cli is not installed. Installing...").yellow() + ); + println!("{}", style("This may take a few minutes...").dim()); + + let status = Command::new("cargo") + .args([ + "install", + "sqlx-cli", + "--no-default-features", + "--features", + "postgres,mysql,sqlite", + ]) + .status() + .await + .context("Failed to run cargo install")?; + + if !status.success() { + anyhow::bail!( + "Failed to install sqlx-cli. Please install it manually:\n\ + cargo install sqlx-cli --features postgres,mysql,sqlite" + ); + } + + println!("{} sqlx-cli installed successfully!", CHECK); + Ok(()) + } + } +} + +/// Run pending migrations +async fn run_migrations(args: MigrateRunArgs) -> Result<()> { + println!( + "{} {} Running migrations...", + DB, + style("migrate run").cyan().bold() + ); + println!(); + + // Ensure migrations directory exists + if !Path::new(&args.source).exists() { + println!( + "{} {} No migrations directory found at '{}'", + WARN, + style("Warning:").yellow(), + args.source + ); + println!( + "{}", + style("Create one with: cargo rustapi migrate create ").dim() + ); + return Ok(()); + } + + let mut cmd = Command::new("sqlx"); + cmd.args(["migrate", "run"]); + + if let Some(url) = &args.database_url { + cmd.arg("--database-url").arg(url); + } + + cmd.arg("--source").arg(&args.source); + + if args.dry_run { + cmd.arg("--dry-run"); + println!("{} Running in dry-run mode", style("Note:").yellow()); + } + + let status = cmd.status().await.context("Failed to run sqlx migrate")?; + + if status.success() { + println!(); + println!("{} Migrations applied successfully!", CHECK); + } else { + anyhow::bail!("Migration failed"); + } + + Ok(()) +} + +/// Revert migrations +async fn revert_migrations(args: MigrateRevertArgs) -> Result<()> { + println!( + "{} {} Reverting {} migration(s)...", + DB, + style("migrate revert").cyan().bold(), + args.count + ); + println!(); + + let mut cmd = Command::new("sqlx"); + cmd.args(["migrate", "revert"]); + + if let Some(url) = &args.database_url { + cmd.arg("--database-url").arg(url); + } + + cmd.arg("--source").arg(&args.source); + + if args.dry_run { + cmd.arg("--dry-run"); + println!("{} Running in dry-run mode", style("Note:").yellow()); + } + + // Revert N times + for i in 0..args.count { + println!("{} Reverting migration {}...", ARROW, i + 1); + let status = cmd + .status() + .await + .context("Failed to run sqlx migrate revert")?; + if !status.success() { + anyhow::bail!("Failed to revert migration {}", i + 1); + } + } + + println!(); + println!("{} {} migration(s) reverted!", CHECK, args.count); + + Ok(()) +} + +/// Show migration status +async fn show_status(args: MigrateStatusArgs) -> Result<()> { + println!( + "{} {} Checking migration status...", + DB, + style("migrate status").cyan().bold() + ); + println!(); + + // Check if migrations directory exists + if !Path::new(&args.source).exists() { + println!( + "{} {} No migrations directory found", + WARN, + style("Warning:").yellow() + ); + return Ok(()); + } + + // List local migrations + let mut local_migrations = Vec::new(); + let mut entries = fs::read_dir(&args.source).await?; + while let Some(entry) = entries.next_entry().await? { + let path = entry.path(); + if path.is_dir() { + if let Some(name) = path.file_name() { + local_migrations.push(name.to_string_lossy().to_string()); + } + } + } + local_migrations.sort(); + + if local_migrations.is_empty() { + println!("{} No migrations found in '{}'", WARN, args.source); + return Ok(()); + } + + println!("{}", style("Local migrations:").bold()); + for migration in &local_migrations { + println!(" {} {}", ARROW, migration); + } + println!(); + + // Run sqlx migrate info if database is available + if args.database_url.is_some() || std::env::var("DATABASE_URL").is_ok() { + let mut cmd = Command::new("sqlx"); + cmd.args(["migrate", "info"]); + + if let Some(url) = &args.database_url { + cmd.arg("--database-url").arg(url); + } + + cmd.arg("--source").arg(&args.source); + + println!("{}", style("Database migration status:").bold()); + let _ = cmd.status().await; + } else { + println!( + "{} {} Set DATABASE_URL to see applied migrations", + WARN, + style("Tip:").yellow() + ); + } + + Ok(()) +} + +/// Create a new migration +async fn create_migration(args: MigrateCreateArgs) -> Result<()> { + println!( + "{} {} Creating migration '{}'...", + DB, + style("migrate create").cyan().bold(), + args.name + ); + println!(); + + // Create migrations directory if it doesn't exist + if !Path::new(&args.source).exists() { + fs::create_dir_all(&args.source).await?; + println!( + "{} Created migrations directory: {}", + CHECK, + style(&args.source).cyan() + ); + } + + // Generate timestamp or sequential prefix + let timestamp = chrono_timestamp(); + let migration_dir = format!("{}/{}_{}", args.source, timestamp, args.name); + + fs::create_dir_all(&migration_dir).await?; + + if args.reversible { + // Create up.sql and down.sql + let up_content = format!( + "-- Migration: {}\n-- Created at: {}\n\n-- Write your UP migration here\n", + args.name, timestamp + ); + let down_content = format!( + "-- Migration: {} (revert)\n-- Created at: {}\n\n-- Write your DOWN migration here\n", + args.name, timestamp + ); + + fs::write(format!("{}/up.sql", migration_dir), up_content).await?; + fs::write(format!("{}/down.sql", migration_dir), down_content).await?; + + println!("{} Created reversible migration:", CHECK); + println!(" {} {}/up.sql", ARROW, style(&migration_dir).cyan()); + println!(" {} {}/down.sql", ARROW, style(&migration_dir).cyan()); + } else { + // Create single migration file + let content = format!( + "-- Migration: {}\n-- Created at: {}\n\n-- Write your migration here\n", + args.name, timestamp + ); + + // For simple migrations, sqlx expects just a .sql file in the migrations dir + let migration_file = format!("{}/{}_{}.sql", args.source, timestamp, args.name); + + // Remove the directory we created and use file instead + fs::remove_dir(&migration_dir).await.ok(); + fs::write(&migration_file, content).await?; + + println!("{} Created migration:", CHECK); + println!(" {} {}", ARROW, style(&migration_file).cyan()); + } + + println!(); + println!("{}", style("Edit the migration file(s), then run:").dim()); + println!(" cargo rustapi migrate run"); + + Ok(()) +} + +/// Reset database +async fn reset_database(args: MigrateResetArgs) -> Result<()> { + println!( + "{} {} This will DROP and recreate your database!", + ERROR, + style("WARNING:").red().bold() + ); + println!(); + + if !args.yes { + use dialoguer::{theme::ColorfulTheme, Confirm}; + + if !Confirm::with_theme(&ColorfulTheme::default()) + .with_prompt("Are you sure you want to reset the database?") + .default(false) + .interact()? + { + println!("{}", style("Aborted").yellow()); + return Ok(()); + } + } + + let pb = ProgressBar::new_spinner(); + pb.set_style( + ProgressStyle::default_spinner() + .template("{spinner:.red} {msg}") + .unwrap(), + ); + pb.enable_steady_tick(Duration::from_millis(80)); + + // Drop database + pb.set_message("Dropping database..."); + let mut drop_cmd = Command::new("sqlx"); + drop_cmd.args(["database", "drop", "-y"]); + if let Some(url) = &args.database_url { + drop_cmd.arg("--database-url").arg(url); + } + let _ = drop_cmd.status().await; // Ignore error if DB doesn't exist + + // Create database + pb.set_message("Creating database..."); + let mut create_cmd = Command::new("sqlx"); + create_cmd.args(["database", "create"]); + if let Some(url) = &args.database_url { + create_cmd.arg("--database-url").arg(url); + } + create_cmd + .status() + .await + .context("Failed to create database")?; + + // Run migrations + pb.set_message("Running migrations..."); + let mut migrate_cmd = Command::new("sqlx"); + migrate_cmd.args(["migrate", "run"]); + if let Some(url) = &args.database_url { + migrate_cmd.arg("--database-url").arg(url); + } + migrate_cmd.arg("--source").arg(&args.source); + migrate_cmd + .status() + .await + .context("Failed to run migrations")?; + + pb.finish_and_clear(); + + println!(); + println!("{} Database reset complete!", CHECK); + + Ok(()) +} + +/// Generate a timestamp for migration names +fn chrono_timestamp() -> String { + use std::time::{SystemTime, UNIX_EPOCH}; + + let duration = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("Time went backwards"); + + // Format: YYYYMMDDHHMMSS + let secs = duration.as_secs(); + + // Simple conversion (not timezone aware, but good enough for migration ordering) + let days = secs / 86400; + let _years_since_1970 = days / 365; + + // For simplicity, just use the unix timestamp + // This ensures unique, sortable names + format!("{}", secs) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_timestamp_generation() { + let ts1 = chrono_timestamp(); + let ts2 = chrono_timestamp(); + + // Timestamps should be numeric + assert!(ts1.chars().all(|c| c.is_ascii_digit())); + + // Should be reasonably close (within same second usually) + let diff: i64 = ts2.parse::().unwrap() - ts1.parse::().unwrap(); + assert!(diff.abs() <= 1); + } + + #[test] + fn test_migrate_create_args() { + let args = MigrateCreateArgs { + name: "create_users".to_string(), + reversible: true, + source: "migrations".to_string(), + timestamp: false, + }; + + assert_eq!(args.name, "create_users"); + assert!(args.reversible); + } +} diff --git a/crates/cargo-rustapi/src/commands/mod.rs b/crates/cargo-rustapi/src/commands/mod.rs index 4374ac7..f246e80 100644 --- a/crates/cargo-rustapi/src/commands/mod.rs +++ b/crates/cargo-rustapi/src/commands/mod.rs @@ -6,6 +6,7 @@ mod deploy; mod docs; mod doctor; mod generate; +mod migrate; mod new; mod run; mod watch; @@ -16,6 +17,7 @@ pub use deploy::{deploy, DeployArgs}; pub use docs::open_docs; pub use doctor::{doctor, DoctorArgs}; pub use generate::{generate, GenerateArgs}; +pub use migrate::{migrate, MigrateArgs}; pub use new::{new_project, NewArgs}; pub use run::{run_dev, RunArgs}; pub use watch::{watch, WatchArgs}; diff --git a/crates/cargo-rustapi/src/commands/new.rs b/crates/cargo-rustapi/src/commands/new.rs index 8a9489f..af5bbcf 100644 --- a/crates/cargo-rustapi/src/commands/new.rs +++ b/crates/cargo-rustapi/src/commands/new.rs @@ -14,7 +14,7 @@ use crate::templates::{self, ProjectTemplate}; /// Arguments for the `new` command #[derive(Args, Debug)] pub struct NewArgs { - /// Project name + /// Project name (positional argument) pub name: Option, /// Project template diff --git a/crates/cargo-rustapi/src/commands/run.rs b/crates/cargo-rustapi/src/commands/run.rs index 6dc0582..18d4dc1 100644 --- a/crates/cargo-rustapi/src/commands/run.rs +++ b/crates/cargo-rustapi/src/commands/run.rs @@ -21,8 +21,8 @@ pub struct RunArgs { #[arg(long)] pub release: bool, - /// Watch for changes and auto-reload - #[arg(short, long)] + /// Watch for changes and auto-reload (like FastAPI's --reload) + #[arg(short, long, visible_alias = "reload", alias = "hot")] pub watch: bool, } @@ -32,13 +32,26 @@ pub async fn run_dev(args: RunArgs) -> Result<()> { std::env::set_var("PORT", args.port.to_string()); std::env::set_var("RUSTAPI_ENV", "development"); - println!("{}", style("Starting RustAPI development server...").bold()); - println!(); - if args.watch { + println!( + "{}", + style("🔄 Starting RustAPI in hot-reload mode...") + .bold() + .cyan() + ); + println!( + "{}", + style(" Changes to source files will trigger automatic rebuild").dim() + ); + println!(); // Use cargo-watch if available run_with_watch(&args).await } else { + println!( + "{}", + style("🚀 Starting RustAPI development server...").bold() + ); + println!(); run_cargo(&args).await } } @@ -93,7 +106,7 @@ async fn run_with_watch(args: &RunArgs) -> Result<()> { } let mut cmd = Command::new("cargo"); - cmd.args(["watch", "-x"]); + cmd.arg("watch"); // Ignore heavy directories for better performance cmd.args([ @@ -107,6 +120,7 @@ async fn run_with_watch(args: &RunArgs) -> Result<()> { "assets", ]); + // Build the run command string let mut run_cmd = String::from("run"); if args.release { run_cmd.push_str(" --release"); @@ -115,7 +129,9 @@ async fn run_with_watch(args: &RunArgs) -> Result<()> { run_cmd.push_str(&format!(" --features {}", features.join(","))); } - cmd.arg(run_cmd); + // Pass the command with -x flag + cmd.arg("-x").arg(&run_cmd); + cmd.stdout(Stdio::inherit()) .stderr(Stdio::inherit()) .stdin(Stdio::inherit()); diff --git a/crates/cargo-rustapi/src/commands/watch.rs b/crates/cargo-rustapi/src/commands/watch.rs index 2512821..d8386ba 100644 --- a/crates/cargo-rustapi/src/commands/watch.rs +++ b/crates/cargo-rustapi/src/commands/watch.rs @@ -1,54 +1,233 @@ -//! Watch command for development +//! Watch command for development with hot-reload use anyhow::Result; use clap::Args; -use console::style; +use console::{style, Emoji}; +use indicatif::{ProgressBar, ProgressStyle}; +use std::time::Duration; use tokio::process::Command; +static WATCH: Emoji<'_, '_> = Emoji("👀 ", "* "); +static ROCKET: Emoji<'_, '_> = Emoji("🚀 ", "> "); +static GEAR: Emoji<'_, '_> = Emoji("⚙️ ", "# "); + #[derive(Args, Debug)] pub struct WatchArgs { /// Command to run (default: "run") - #[arg(short, long, default_value = "run")] + #[arg(short = 'x', long, default_value = "run")] pub command: String, /// Clear screen before each run #[arg(short = 'c', long)] pub clear: bool, + + /// File extensions to watch (comma-separated) + #[arg(short, long, default_value = "rs,toml,html,css,sql")] + pub extensions: String, + + /// Paths to watch (can be specified multiple times) + #[arg(short = 'w', long = "watch-path", default_values_t = vec!["src".to_string(), "templates".to_string(), "migrations".to_string()])] + pub watch_paths: Vec, + + /// Paths to ignore (can be specified multiple times) + #[arg(short = 'i', long = "ignore", default_values_t = vec![".git".to_string(), "target".to_string(), "node_modules".to_string()])] + pub ignore_paths: Vec, + + /// Delay before restarting (in milliseconds) + #[arg(short, long, default_value = "500")] + pub delay: u32, + + /// Enable quiet mode (less output) + #[arg(short, long)] + pub quiet: bool, + + /// Don't restart if build fails + #[arg(long)] + pub no_restart_on_fail: bool, + + /// Poll for changes instead of using filesystem events + #[arg(long)] + pub poll: bool, } pub async fn watch(args: WatchArgs) -> Result<()> { - println!("{}", style("Starting watch mode...").bold()); + // Print banner unless quiet + if !args.quiet { + println!(); + println!( + "{}", + style("╔════════════════════════════════════════╗") + .cyan() + .bold() + ); + println!( + "{}", + style("║ RustAPI Watch Mode ║") + .cyan() + .bold() + ); + println!( + "{}", + style("╚════════════════════════════════════════╝") + .cyan() + .bold() + ); + println!(); + } // Check if cargo-watch is installed + let pb = ProgressBar::new_spinner(); + pb.set_style( + ProgressStyle::default_spinner() + .template("{spinner:.green} {msg}") + .unwrap(), + ); + pb.enable_steady_tick(Duration::from_millis(80)); + pb.set_message("Checking for cargo-watch..."); + let version_check = Command::new("cargo") .args(["watch", "--version"]) .output() .await; if version_check.is_err() || !version_check.unwrap().status.success() { + pb.set_message("Installing cargo-watch..."); println!( - "{}", + "\n{}", style("cargo-watch is not installed. Installing...").yellow() ); - Command::new("cargo") + + let install_status = Command::new("cargo") .args(["install", "cargo-watch"]) .status() .await?; + + if !install_status.success() { + pb.finish_and_clear(); + anyhow::bail!("Failed to install cargo-watch. Please install it manually: cargo install cargo-watch"); + } + } + + pb.finish_and_clear(); + + // Print configuration + if !args.quiet { + println!("{} {} {}", GEAR, style("Command:").bold(), args.command); + println!( + "{} {} {}", + WATCH, + style("Extensions:").bold(), + args.extensions + ); + println!( + "{} {} {}", + WATCH, + style("Watching:").bold(), + args.watch_paths.join(", ") + ); + println!( + "{} {} {}", + WATCH, + style("Ignoring:").bold(), + args.ignore_paths.join(", ") + ); + println!("{} {} {}ms", GEAR, style("Delay:").bold(), args.delay); + println!(); + println!("{}", style("Press Ctrl+C to stop watching.").dim()); + println!(); + println!( + "{} {}", + ROCKET, + style("Starting watch mode...").green().bold() + ); + println!(); } + // Build cargo-watch command let mut cmd = Command::new("cargo"); cmd.arg("watch"); + // Clear screen option if args.clear { cmd.arg("-c"); } - cmd.arg("-x").arg(&args.command); + // Delay option + cmd.arg("-d").arg(format!("{}", args.delay as f64 / 1000.0)); - // Ignore common directories to improve performance - cmd.args(["-i", ".git", "-i", "target", "-i", "node_modules"]); + // Extension filter + for ext in args.extensions.split(',') { + let ext = ext.trim(); + if !ext.is_empty() { + cmd.arg("-e").arg(ext); + } + } + + // Watch paths + for path in &args.watch_paths { + // Only add if path exists + if std::path::Path::new(path).exists() { + cmd.arg("-w").arg(path); + } + } - cmd.spawn()?.wait().await?; + // Ignore paths + for path in &args.ignore_paths { + cmd.arg("-i").arg(path); + } + + // Polling mode + if args.poll { + cmd.arg("--poll"); + } + + // No restart on fail + if args.no_restart_on_fail { + cmd.arg("--no-restart"); + } + + // Quiet mode for cargo-watch + if args.quiet { + cmd.arg("-q"); + } + + // The command to execute + cmd.arg("-x").arg(&args.command); + + // Run the watch process + let mut child = cmd.spawn()?; + child.wait().await?; Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_default_args() { + let args = WatchArgs { + command: "run".to_string(), + clear: false, + extensions: "rs,toml".to_string(), + watch_paths: vec!["src".to_string()], + ignore_paths: vec![".git".to_string()], + delay: 500, + quiet: false, + no_restart_on_fail: false, + poll: false, + }; + + assert_eq!(args.command, "run"); + assert_eq!(args.delay, 500); + assert!(!args.clear); + } + + #[test] + fn test_extension_parsing() { + let extensions = "rs,toml,html,css"; + let parsed: Vec<&str> = extensions.split(',').map(|s| s.trim()).collect(); + assert_eq!(parsed, vec!["rs", "toml", "html", "css"]); + } +} diff --git a/crates/cargo-rustapi/src/templates/api.rs b/crates/cargo-rustapi/src/templates/api.rs index 02c1664..057a953 100644 --- a/crates/cargo-rustapi/src/templates/api.rs +++ b/crates/cargo-rustapi/src/templates/api.rs @@ -40,7 +40,7 @@ use tokio::sync::RwLock; pub type AppState = Arc>; -#[rustapi::main] +#[rustapi_rs::main] async fn main() -> Result<(), Box> { // Initialize tracing tracing_subscriber::fmt() @@ -64,11 +64,11 @@ async fn main() -> Result<(), Box> { // Health check .route("/health", get(handlers::health)) // Items CRUD - .mount(handlers::items::list) - .mount(handlers::items::get) - .mount(handlers::items::create) - .mount(handlers::items::update) - .mount(handlers::items::delete) + .mount_route(handlers::items::list_route()) + .mount_route(handlers::items::get_route()) + .mount_route(handlers::items::create_route()) + .mount_route(handlers::items::update_route()) + .mount_route(handlers::items::delete_route()) // Documentation .docs("/docs") .run(&addr) @@ -133,18 +133,18 @@ use crate::AppState; use rustapi_rs::prelude::*; /// List all items -#[rustapi::get("/items")] -#[rustapi::tag("Items")] -#[rustapi::summary("List all items")] +#[rustapi_rs::get("/items")] +#[rustapi_rs::tag("Items")] +#[rustapi_rs::summary("List all items")] pub async fn list(State(state): State) -> Json> { let store = state.read().await; Json(store.items.values().cloned().collect()) } /// Get an item by ID -#[rustapi::get("/items/{id}")] -#[rustapi::tag("Items")] -#[rustapi::summary("Get item by ID")] +#[rustapi_rs::get("/items/{id}")] +#[rustapi_rs::tag("Items")] +#[rustapi_rs::summary("Get item by ID")] pub async fn get( Path(id): Path, State(state): State, @@ -158,25 +158,25 @@ pub async fn get( } /// Create a new item -#[rustapi::post("/items")] -#[rustapi::tag("Items")] -#[rustapi::summary("Create a new item")] +#[rustapi_rs::post("/items")] +#[rustapi_rs::tag("Items")] +#[rustapi_rs::summary("Create a new item")] pub async fn create( State(state): State, Json(body): Json, -) -> Result>> { +) -> Json { let item = Item::new(body.name, body.description); let mut store = state.write().await; store.items.insert(item.id.clone(), item.clone()); - Ok(Created(Json(item))) + Json(item) } /// Update an item -#[rustapi::put("/items/{id}")] -#[rustapi::tag("Items")] -#[rustapi::summary("Update an item")] +#[rustapi_rs::put("/items/{id}")] +#[rustapi_rs::tag("Items")] +#[rustapi_rs::summary("Update an item")] pub async fn update( Path(id): Path, State(state): State, @@ -192,7 +192,7 @@ pub async fn update( item.name = name; } if let Some(description) = body.description { - item.description = description; + item.description = Some(description); } item.updated_at = chrono_now(); @@ -200,9 +200,9 @@ pub async fn update( } /// Delete an item -#[rustapi::delete("/items/{id}")] -#[rustapi::tag("Items")] -#[rustapi::summary("Delete an item")] +#[rustapi_rs::delete("/items/{id}")] +#[rustapi_rs::tag("Items")] +#[rustapi_rs::summary("Delete an item")] pub async fn delete( Path(id): Path, State(state): State, @@ -230,7 +230,7 @@ fn chrono_now() -> String { let models_mod = r#"//! Data models use serde::{Deserialize, Serialize}; -use rustapi_rs::Schema; +use rustapi_rs::prelude::Schema; use std::collections::HashMap; /// In-memory data store diff --git a/crates/cargo-rustapi/src/templates/full.rs b/crates/cargo-rustapi/src/templates/full.rs index 6245d2b..50b8a90 100644 --- a/crates/cargo-rustapi/src/templates/full.rs +++ b/crates/cargo-rustapi/src/templates/full.rs @@ -56,7 +56,7 @@ use tokio::sync::RwLock; pub type AppState = Arc>; -#[rustapi::main] +#[rustapi_rs::main] async fn main() -> Result<(), Box> { // Load environment variables load_dotenv(); @@ -70,7 +70,7 @@ async fn main() -> Result<(), Box> { .init(); // Get configuration - let env = Environment::from_env(); + let env = Environment::current(); let host = env_or("HOST", "127.0.0.1"); let port = env_or("PORT", "8080"); let addr = format!("{}:{}", host, port); @@ -93,17 +93,18 @@ async fn main() -> Result<(), Box> { .route("/auth/login", post(handlers::auth::login)) .route("/auth/me", get(handlers::auth::me)) // Protected items endpoints (require JWT) - .mount(handlers::items::list) - .mount(handlers::items::get) - .mount(handlers::items::create) - .mount(handlers::items::update) - .mount(handlers::items::delete) + .mount_route(handlers::items::list_route()) + .mount_route(handlers::items::get_route()) + .mount_route(handlers::items::create_route()) + .mount_route(handlers::items::update_route()) + .mount_route(handlers::items::delete_route()) // Documentation - .docs_with_info("/docs", ApiInfo { - title: env!("CARGO_PKG_NAME").to_string(), - version: env!("CARGO_PKG_VERSION").to_string(), - description: Some("Full-featured RustAPI application".to_string()), - }) + .docs_with_info( + "/docs", + env!("CARGO_PKG_NAME"), + env!("CARGO_PKG_VERSION"), + Some("Full-featured RustAPI application"), + ) .run(&addr) .await } @@ -154,7 +155,7 @@ pub struct LoginResponse { pub token_type: String, } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, Schema)] pub struct UserClaims { pub sub: String, pub username: String, @@ -162,9 +163,9 @@ pub struct UserClaims { } /// Login and get a JWT token -#[rustapi::post("/auth/login")] -#[rustapi::tag("Authentication")] -#[rustapi::summary("Login with username and password")] +#[rustapi_rs::post("/auth/login")] +#[rustapi_rs::tag("Authentication")] +#[rustapi_rs::summary("Login with username and password")] pub async fn login(Json(body): Json) -> Result> { // TODO: Validate credentials against your database if body.username == "admin" && body.password == "password" { @@ -177,7 +178,8 @@ pub async fn login(Json(body): Json) -> Result exp: (chrono_now() + 86400) as usize, // 24 hours }; - let token = create_token(&claims, &jwt_secret)?; + let token = create_token(&claims, &jwt_secret) + .map_err(|e| ApiError::internal(format!("Failed to create token: {}", e)))?; Ok(Json(LoginResponse { token, @@ -189,11 +191,11 @@ pub async fn login(Json(body): Json) -> Result } /// Get current user info -#[rustapi::get("/auth/me")] -#[rustapi::tag("Authentication")] -#[rustapi::summary("Get current authenticated user")] +#[rustapi_rs::get("/auth/me")] +#[rustapi_rs::tag("Authentication")] +#[rustapi_rs::summary("Get current authenticated user")] pub async fn me(auth: AuthUser) -> Json { - Json(auth.claims) + Json(auth.0) } fn chrono_now() -> u64 { @@ -214,9 +216,9 @@ use crate::AppState; use rustapi_rs::prelude::*; /// List all items -#[rustapi::get("/items")] -#[rustapi::tag("Items")] -#[rustapi::summary("List all items")] +#[rustapi_rs::get("/items")] +#[rustapi_rs::tag("Items")] +#[rustapi_rs::summary("List all items")] pub async fn list( _auth: AuthUser, State(state): State, @@ -226,9 +228,9 @@ pub async fn list( } /// Get an item by ID -#[rustapi::get("/items/{id}")] -#[rustapi::tag("Items")] -#[rustapi::summary("Get item by ID")] +#[rustapi_rs::get("/items/{id}")] +#[rustapi_rs::tag("Items")] +#[rustapi_rs::summary("Get item by ID")] pub async fn get( _auth: AuthUser, Path(id): Path, @@ -243,28 +245,28 @@ pub async fn get( } /// Create a new item -#[rustapi::post("/items")] -#[rustapi::tag("Items")] -#[rustapi::summary("Create a new item")] +#[rustapi_rs::post("/items")] +#[rustapi_rs::tag("Items")] +#[rustapi_rs::summary("Create a new item")] pub async fn create( auth: AuthUser, State(state): State, Json(body): Json, -) -> Result>> { - let item = Item::new(body.name, body.description, auth.claims.sub.clone()); +) -> Json { + let item = Item::new(body.name, body.description, auth.0.sub.clone()); let mut store = state.write().await; store.items.insert(item.id.clone(), item.clone()); - tracing::info!("User {} created item {}", auth.claims.username, item.id); + tracing::info!("User {} created item {}", auth.0.username, item.id); - Ok(Created(Json(item))) + Json(item) } /// Update an item -#[rustapi::put("/items/{id}")] -#[rustapi::tag("Items")] -#[rustapi::summary("Update an item")] +#[rustapi_rs::put("/items/{id}")] +#[rustapi_rs::tag("Items")] +#[rustapi_rs::summary("Update an item")] pub async fn update( _auth: AuthUser, Path(id): Path, @@ -281,7 +283,7 @@ pub async fn update( item.name = name; } if let Some(description) = body.description { - item.description = description; + item.description = Some(description); } item.updated_at = chrono_now(); @@ -289,9 +291,9 @@ pub async fn update( } /// Delete an item -#[rustapi::delete("/items/{id}")] -#[rustapi::tag("Items")] -#[rustapi::summary("Delete an item")] +#[rustapi_rs::delete("/items/{id}")] +#[rustapi_rs::tag("Items")] +#[rustapi_rs::summary("Delete an item")] pub async fn delete( auth: AuthUser, Path(id): Path, @@ -303,7 +305,7 @@ pub async fn delete( .remove(&id) .ok_or_else(|| ApiError::not_found(format!("Item {} not found", id)))?; - tracing::info!("User {} deleted item {}", auth.claims.username, id); + tracing::info!("User {} deleted item {}", auth.0.username, id); Ok(NoContent) } @@ -321,7 +323,7 @@ fn chrono_now() -> String { let models_mod = r#"//! Data models use serde::{Deserialize, Serialize}; -use rustapi_rs::Schema; +use rustapi_rs::prelude::Schema; use std::collections::HashMap; pub struct Store { diff --git a/crates/cargo-rustapi/src/templates/minimal.rs b/crates/cargo-rustapi/src/templates/minimal.rs index d8ed093..8f3fa7f 100644 --- a/crates/cargo-rustapi/src/templates/minimal.rs +++ b/crates/cargo-rustapi/src/templates/minimal.rs @@ -16,6 +16,7 @@ edition = "2021" rustapi-rs = {{ version = "0.1"{features} }} tokio = {{ version = "1", features = ["full"] }} serde = {{ version = "1", features = ["derive"] }} +tracing-subscriber = "0.3" "#, name = name, features = common::features_to_cargo(features), @@ -37,7 +38,7 @@ async fn hello() -> Json { }) } -#[rustapi::main] +#[rustapi_rs::main] async fn main() -> Result<(), Box> { tracing_subscriber::fmt::init(); diff --git a/crates/cargo-rustapi/src/templates/web.rs b/crates/cargo-rustapi/src/templates/web.rs index 2dfb78c..90b73e5 100644 --- a/crates/cargo-rustapi/src/templates/web.rs +++ b/crates/cargo-rustapi/src/templates/web.rs @@ -11,7 +11,7 @@ pub async fn generate(name: &str, features: &[String]) -> Result<()> { all_features.push("view".to_string()); } - // Cargo.toml + // Cargo.toml - rustapi-view is accessed through rustapi-rs when "view" feature is enabled let cargo_toml = format!( r#"[package] name = "{name}" @@ -20,7 +20,6 @@ edition = "2021" [dependencies] rustapi-rs = {{ version = "0.1"{features} }} -rustapi-view = "0.1" tokio = {{ version = "1", features = ["full"] }} serde = {{ version = "1", features = ["derive"] }} tracing = "0.1" @@ -40,9 +39,9 @@ tracing-subscriber = {{ version = "0.3", features = ["env-filter"] }} let main_rs = r#"mod handlers; use rustapi_rs::prelude::*; -use rustapi_view::Templates; +use rustapi_rs::view::Templates; -#[rustapi::main] +#[rustapi_rs::main] async fn main() -> Result<(), Box> { // Initialize tracing tracing_subscriber::fmt() @@ -77,7 +76,7 @@ async fn main() -> Result<(), Box> { let handlers_mod = r#"//! Page handlers use rustapi_rs::prelude::*; -use rustapi_view::{Templates, View}; +use rustapi_rs::view::{Templates, View}; use serde::Serialize; #[derive(Serialize)] diff --git a/crates/cargo-rustapi/tests/cli_tests.rs b/crates/cargo-rustapi/tests/cli_tests.rs new file mode 100644 index 0000000..d401a90 --- /dev/null +++ b/crates/cargo-rustapi/tests/cli_tests.rs @@ -0,0 +1,357 @@ +//! Integration tests for cargo-rustapi CLI + +use assert_cmd::Command; +use predicates::prelude::*; +use std::fs; +use tempfile::tempdir; + +/// Helper to get the cargo-rustapi binary +fn cargo_rustapi() -> Command { + Command::cargo_bin("cargo-rustapi").expect("Failed to find cargo-rustapi binary") +} + +mod new_command { + use super::*; + + #[test] + fn test_new_help() { + cargo_rustapi() + .arg("new") + .arg("--help") + .assert() + .success() + .stdout(predicate::str::contains("Create a new RustAPI project")); + } + + #[test] + fn test_new_minimal_template() { + let dir = tempdir().expect("Failed to create temp dir"); + let project_name = "test-minimal-project"; + let project_path = dir.path().join(project_name); + + // Change to temp directory and create project + cargo_rustapi() + .current_dir(dir.path()) + .args(["new", project_name, "--template", "minimal", "--yes"]) + .assert() + .success(); + + // Verify project structure + assert!(project_path.exists(), "Project directory should exist"); + assert!( + project_path.join("Cargo.toml").exists(), + "Cargo.toml should exist" + ); + assert!( + project_path.join("src/main.rs").exists(), + "src/main.rs should exist" + ); + + // Verify Cargo.toml content + let cargo_content = + fs::read_to_string(project_path.join("Cargo.toml")).expect("Failed to read Cargo.toml"); + assert!( + cargo_content.contains("rustapi-rs"), + "Cargo.toml should depend on rustapi-rs" + ); + } + + #[test] + fn test_new_api_template() { + let dir = tempdir().expect("Failed to create temp dir"); + let project_name = "test-api-project"; + let project_path = dir.path().join(project_name); + + cargo_rustapi() + .current_dir(dir.path()) + .args(["new", project_name, "--template", "api", "--yes"]) + .assert() + .success(); + + // Verify API project structure + assert!(project_path.join("src/handlers").is_dir()); + assert!(project_path.join("src/models").is_dir()); + assert!(project_path.join("src/handlers/mod.rs").exists()); + assert!(project_path.join("src/handlers/items.rs").exists()); + assert!(project_path.join("src/models/mod.rs").exists()); + } + + #[test] + fn test_new_with_features() { + let dir = tempdir().expect("Failed to create temp dir"); + let project_name = "test-features-project"; + let project_path = dir.path().join(project_name); + + cargo_rustapi() + .current_dir(dir.path()) + .args([ + "new", + project_name, + "--template", + "minimal", + "--features", + "jwt,cors", + "--yes", + ]) + .assert() + .success(); + + let cargo_content = + fs::read_to_string(project_path.join("Cargo.toml")).expect("Failed to read Cargo.toml"); + assert!( + cargo_content.contains("jwt") && cargo_content.contains("cors"), + "Cargo.toml should include jwt and cors features" + ); + } + + #[test] + fn test_new_existing_directory_fails() { + let dir = tempdir().expect("Failed to create temp dir"); + let project_name = "existing-dir"; + + // Create the directory first + fs::create_dir(dir.path().join(project_name)).expect("Failed to create dir"); + + cargo_rustapi() + .current_dir(dir.path()) + .args(["new", project_name, "--template", "minimal", "--yes"]) + .assert() + .failure() + .stderr(predicate::str::contains("already exists")); + } + + #[test] + fn test_new_invalid_name_fails() { + let dir = tempdir().expect("Failed to create temp dir"); + + cargo_rustapi() + .current_dir(dir.path()) + .args(["new", "invalid/name", "--template", "minimal", "--yes"]) + .assert() + .failure(); + } +} + +mod doctor_command { + use super::*; + + #[test] + fn test_doctor_help() { + cargo_rustapi() + .arg("doctor") + .arg("--help") + .assert() + .success() + .stdout(predicate::str::contains("environment health")); + } + + #[test] + fn test_doctor_runs() { + // Doctor should run and check for tools + // It will succeed even if some tools are missing (just warns) + cargo_rustapi().arg("doctor").assert().success(); + } + + #[test] + fn test_doctor_checks_rust() { + let output = cargo_rustapi() + .arg("doctor") + .output() + .expect("Failed to run doctor"); + + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + stdout.contains("Rust compiler") || stdout.contains("rustc"), + "Doctor should check for Rust compiler" + ); + } +} + +mod generate_command { + use super::*; + + #[test] + fn test_generate_help() { + cargo_rustapi() + .args(["generate", "--help"]) + .assert() + .success() + .stdout(predicate::str::contains("Generate code from templates")); + } + + #[test] + fn test_generate_handler() { + let dir = tempdir().expect("Failed to create temp dir"); + + // First create a minimal project + cargo_rustapi() + .current_dir(dir.path()) + .args(["new", "test-gen", "--template", "minimal", "--yes"]) + .assert() + .success(); + + // Generate a handler + cargo_rustapi() + .current_dir(dir.path().join("test-gen")) + .args(["generate", "handler", "users"]) + .assert() + .success(); + + // Verify handler was created + let handler_path = dir.path().join("test-gen/src/handlers/users.rs"); + assert!(handler_path.exists(), "Handler file should be created"); + + let content = fs::read_to_string(&handler_path).expect("Failed to read handler"); + assert!(content.contains("pub async fn list")); + assert!(content.contains("pub async fn get")); + assert!(content.contains("pub async fn create")); + } + + #[test] + fn test_generate_model() { + let dir = tempdir().expect("Failed to create temp dir"); + + // First create a minimal project + cargo_rustapi() + .current_dir(dir.path()) + .args(["new", "test-model", "--template", "minimal", "--yes"]) + .assert() + .success(); + + // Generate a model (model name is used as-is, should be PascalCase) + cargo_rustapi() + .current_dir(dir.path().join("test-model")) + .args(["generate", "model", "User"]) + .assert() + .success(); + + // Model file is lowercase + let model_path = dir.path().join("test-model/src/models/user.rs"); + assert!(model_path.exists(), "Model file should be created"); + + let content = fs::read_to_string(&model_path).expect("Failed to read model"); + // The generate command uses the name as-is for struct name + assert!(content.contains("struct User")); + assert!(content.contains("impl User")); + } +} + +mod watch_command { + use super::*; + + #[test] + fn test_watch_help() { + cargo_rustapi() + .arg("watch") + .arg("--help") + .assert() + .success() + .stdout(predicate::str::contains("Watch for changes")); + } + + #[test] + fn test_watch_accepts_command_flag() { + cargo_rustapi() + .args(["watch", "--help"]) + .assert() + .success() + .stdout(predicate::str::contains("--command")) + .stdout(predicate::str::contains("--clear")); + } + + #[test] + fn test_watch_accepts_extension_filter() { + cargo_rustapi() + .args(["watch", "--help"]) + .assert() + .success() + .stdout(predicate::str::contains("--extensions")); + } + + #[test] + fn test_watch_accepts_path_filter() { + cargo_rustapi() + .args(["watch", "--help"]) + .assert() + .success() + .stdout(predicate::str::contains("--watch-path")); + } +} + +mod migrate_command { + use super::*; + + #[test] + fn test_migrate_help() { + cargo_rustapi() + .args(["migrate", "--help"]) + .assert() + .success() + .stdout(predicate::str::contains("Database migration")); + } + + #[test] + fn test_migrate_run_help() { + cargo_rustapi() + .args(["migrate", "run", "--help"]) + .assert() + .success() + .stdout(predicate::str::contains("pending migrations")); + } + + #[test] + fn test_migrate_status_help() { + cargo_rustapi() + .args(["migrate", "status", "--help"]) + .assert() + .success() + .stdout(predicate::str::contains("migration status")); + } + + #[test] + fn test_migrate_create_help() { + cargo_rustapi() + .args(["migrate", "create", "--help"]) + .assert() + .success() + .stdout(predicate::str::contains("new migration")); + } + + #[test] + fn test_migrate_create_generates_files() { + let dir = tempdir().expect("Failed to create temp dir"); + + // Create a project first + cargo_rustapi() + .current_dir(dir.path()) + .args(["new", "test-migrate", "--template", "minimal", "--yes"]) + .assert() + .success(); + + // Create a migration + cargo_rustapi() + .current_dir(dir.path().join("test-migrate")) + .args(["migrate", "create", "create_users_table"]) + .assert() + .success(); + + // Check migrations directory exists + let migrations_dir = dir.path().join("test-migrate/migrations"); + assert!(migrations_dir.exists(), "migrations directory should exist"); + + // Check that migration files were created + let entries: Vec<_> = fs::read_dir(&migrations_dir) + .expect("Failed to read migrations dir") + .collect(); + assert!(!entries.is_empty(), "Migration files should be created"); + } + + #[test] + fn test_migrate_revert_help() { + cargo_rustapi() + .args(["migrate", "revert", "--help"]) + .assert() + .success() + .stdout(predicate::str::contains("Revert")); + } +} diff --git a/crates/rustapi-macros/Cargo.toml b/crates/rustapi-macros/Cargo.toml index 02f1634..011e0b2 100644 --- a/crates/rustapi-macros/Cargo.toml +++ b/crates/rustapi-macros/Cargo.toml @@ -17,6 +17,7 @@ syn = { workspace = true } quote = { workspace = true } proc-macro2 = { workspace = true } serde_json = { workspace = true } +proc-macro-crate = "3.1" # Note: async-trait is used in generated code, not in the macro itself # Users need to have async-trait available when using the Validate derive diff --git a/crates/rustapi-macros/src/derive_schema.rs b/crates/rustapi-macros/src/derive_schema.rs index 3820c47..d6f672b 100644 --- a/crates/rustapi-macros/src/derive_schema.rs +++ b/crates/rustapi-macros/src/derive_schema.rs @@ -1,13 +1,88 @@ use proc_macro2::TokenStream; +use proc_macro_crate::{crate_name, FoundCrate}; use quote::quote; use syn::{Data, DataEnum, DataStruct, Fields, Ident}; +/// Determine the path to rustapi_openapi module based on the user's dependencies. +/// +/// This function checks if the user's Cargo.toml has: +/// 1. `rustapi-rs` - use `::rustapi_rs::prelude::rustapi_openapi` +/// 2. `rustapi-openapi` - use `::rustapi_openapi` directly +/// +/// This allows the Schema derive macro to work in both: +/// - Internal crates (like rustapi-openapi itself) +/// - User projects that depend on rustapi-rs +fn get_openapi_path() -> TokenStream { + // Try both hyphenated and underscored versions for rustapi-rs + // Cargo normalizes crate names but proc-macro-crate looks at Cargo.toml + let rustapi_rs_found = crate_name("rustapi-rs").or_else(|_| crate_name("rustapi_rs")); + + if let Ok(found) = rustapi_rs_found { + match found { + FoundCrate::Itself => { + // We're in rustapi-rs itself + quote! { crate::prelude::rustapi_openapi } + } + FoundCrate::Name(name) => { + // Normalize to underscore for use in code + let normalized = name.replace('-', "_"); + let ident = syn::Ident::new(&normalized, proc_macro2::Span::call_site()); + quote! { ::#ident::prelude::rustapi_openapi } + } + } + } else if let Ok(found) = + crate_name("rustapi-openapi").or_else(|_| crate_name("rustapi_openapi")) + { + // Fallback to rustapi-openapi directly + match found { + FoundCrate::Itself => { + // We're inside rustapi-openapi itself, use crate:: + quote! { crate } + } + FoundCrate::Name(name) => { + let normalized = name.replace('-', "_"); + let ident = syn::Ident::new(&normalized, proc_macro2::Span::call_site()); + quote! { ::#ident } + } + } + } else { + // Default fallback - assume rustapi_rs is available (most common case) + quote! { ::rustapi_rs::prelude::rustapi_openapi } + } +} + +/// Get serde_json path - either from rustapi_rs::prelude or directly +fn get_serde_json_path() -> TokenStream { + // Try both hyphenated and underscored versions + let rustapi_rs_found = crate_name("rustapi-rs").or_else(|_| crate_name("rustapi_rs")); + + if let Ok(found) = rustapi_rs_found { + match found { + FoundCrate::Itself => { + quote! { crate::prelude::serde_json } + } + FoundCrate::Name(name) => { + let normalized = name.replace('-', "_"); + let ident = syn::Ident::new(&normalized, proc_macro2::Span::call_site()); + quote! { ::#ident::prelude::serde_json } + } + } + } else { + // Fallback to serde_json directly (internal crates should have it) + quote! { ::serde_json } + } +} + pub fn expand_derive_schema(input: syn::DeriveInput) -> TokenStream { let name = input.ident; let generics = input.generics; let (impl_generics, ty_generics, where_clause) = generics.split_for_impl(); let name_str = name.to_string(); + // Get the correct paths based on available crates + let openapi_path = get_openapi_path(); + let serde_json_path = get_serde_json_path(); + // Generate name() impl body let type_params: Vec = generics.type_params().map(|p| p.ident.clone()).collect(); let name_impl_body = if type_params.is_empty() { @@ -17,23 +92,26 @@ pub fn expand_derive_schema(input: syn::DeriveInput) -> TokenStream { let mut n = String::from(#name_str); #( n.push('_'); - n.push_str(&<#type_params as ::rustapi_openapi::schema::RustApiSchema>::name()); + n.push_str(&<#type_params as #openapi_path::schema::RustApiSchema>::name()); )* std::borrow::Cow::Owned(n) } }; let (schema_impl, field_schemas_impl) = match input.data { - Data::Struct(data) => impl_struct_schema_bodies(&name, data), - Data::Enum(data) => (impl_enum_schema(&name, data), quote! { None }), + Data::Struct(data) => impl_struct_schema_bodies(&openapi_path, &serde_json_path, data), + Data::Enum(data) => ( + impl_enum_schema(&openapi_path, &serde_json_path, data), + quote! { None }, + ), Data::Union(_) => { return syn::Error::new_spanned(name, "Unions not supported").to_compile_error(); } }; quote! { - impl #impl_generics ::rustapi_openapi::schema::RustApiSchema for #name #ty_generics #where_clause { - fn schema(ctx: &mut ::rustapi_openapi::schema::SchemaCtx) -> ::rustapi_openapi::schema::SchemaRef { + impl #impl_generics #openapi_path::schema::RustApiSchema for #name #ty_generics #where_clause { + fn schema(ctx: &mut #openapi_path::schema::SchemaCtx) -> #openapi_path::schema::SchemaRef { #schema_impl } @@ -46,14 +124,18 @@ pub fn expand_derive_schema(input: syn::DeriveInput) -> TokenStream { #name_impl_body } - fn field_schemas(ctx: &mut ::rustapi_openapi::schema::SchemaCtx) -> Option<::std::collections::BTreeMap> { + fn field_schemas(ctx: &mut #openapi_path::schema::SchemaCtx) -> Option<::std::collections::BTreeMap> { #field_schemas_impl } } } } -fn impl_struct_schema_bodies(_name: &Ident, data: DataStruct) -> (TokenStream, TokenStream) { +fn impl_struct_schema_bodies( + openapi_path: &TokenStream, + serde_json_path: &TokenStream, + data: DataStruct, +) -> (TokenStream, TokenStream) { let mut field_logic = Vec::new(); let mut field_schemas_logic = Vec::new(); @@ -81,16 +163,16 @@ fn impl_struct_schema_bodies(_name: &Ident, data: DataStruct) -> (TokenStream, T }; field_logic.push(quote! { - let field_schema_ref = <#field_type as ::rustapi_openapi::schema::RustApiSchema>::schema(ctx); + let field_schema_ref = <#field_type as #openapi_path::schema::RustApiSchema>::schema(ctx); let field_schema = match field_schema_ref { - ::rustapi_openapi::schema::SchemaRef::Schema(s) => *s, - ::rustapi_openapi::schema::SchemaRef::Ref { reference } => { - let mut s = ::rustapi_openapi::schema::JsonSchema2020::new(); + #openapi_path::schema::SchemaRef::Schema(s) => *s, + #openapi_path::schema::SchemaRef::Ref { reference } => { + let mut s = #openapi_path::schema::JsonSchema2020::new(); s.reference = Some(reference); s }, - ::rustapi_openapi::schema::SchemaRef::Inline(v) => { - ::serde_json::from_value(v).unwrap_or_default() + #openapi_path::schema::SchemaRef::Inline(v) => { + #serde_json_path::from_value(v).unwrap_or_default() } }; properties.insert(#field_name_str.to_string(), field_schema); @@ -98,7 +180,7 @@ fn impl_struct_schema_bodies(_name: &Ident, data: DataStruct) -> (TokenStream, T }); field_schemas_logic.push(quote! { - let field_schema_ref = <#field_type as ::rustapi_openapi::schema::RustApiSchema>::schema(ctx); + let field_schema_ref = <#field_type as #openapi_path::schema::RustApiSchema>::schema(ctx); map.insert(#field_name_str.to_string(), field_schema_ref); }); } @@ -107,21 +189,21 @@ fn impl_struct_schema_bodies(_name: &Ident, data: DataStruct) -> (TokenStream, T } let schema_body = quote! { - let name_cow = ::name(); + let name_cow = ::name(); let name = name_cow.as_ref(); if let Some(_) = ctx.components.get(name) { - return ::rustapi_openapi::schema::SchemaRef::Ref { reference: format!("#/components/schemas/{}", name) }; + return #openapi_path::schema::SchemaRef::Ref { reference: format!("#/components/schemas/{}", name) }; } - ctx.components.insert(name.to_string(), ::rustapi_openapi::schema::JsonSchema2020::new()); + ctx.components.insert(name.to_string(), #openapi_path::schema::JsonSchema2020::new()); let mut properties = ::std::collections::BTreeMap::new(); let mut required = Vec::new(); #(#field_logic)* - let mut schema = ::rustapi_openapi::schema::JsonSchema2020::object(); + let mut schema = #openapi_path::schema::JsonSchema2020::object(); schema.properties = Some(properties); if !required.is_empty() { schema.required = Some(required); @@ -129,7 +211,7 @@ fn impl_struct_schema_bodies(_name: &Ident, data: DataStruct) -> (TokenStream, T ctx.components.insert(name.to_string(), schema); - ::rustapi_openapi::schema::SchemaRef::Ref { reference: format!("#/components/schemas/{}", name) } + #openapi_path::schema::SchemaRef::Ref { reference: format!("#/components/schemas/{}", name) } }; let field_schemas_body = if !field_schemas_logic.is_empty() { @@ -145,7 +227,11 @@ fn impl_struct_schema_bodies(_name: &Ident, data: DataStruct) -> (TokenStream, T (schema_body, field_schemas_body) } -fn impl_enum_schema(_name: &Ident, data: DataEnum) -> TokenStream { +fn impl_enum_schema( + openapi_path: &TokenStream, + serde_json_path: &TokenStream, + data: DataEnum, +) -> TokenStream { let is_string_enum = data .variants .iter() @@ -156,19 +242,19 @@ fn impl_enum_schema(_name: &Ident, data: DataEnum) -> TokenStream { let push_variants = variants.iter().map(|v| quote! { #v.into() }); return quote! { - let name_cow = ::name(); + let name_cow = ::name(); let name = name_cow.as_ref(); if let Some(_) = ctx.components.get(name) { - return ::rustapi_openapi::schema::SchemaRef::Ref { reference: format!("#/components/schemas/{}", name) }; + return #openapi_path::schema::SchemaRef::Ref { reference: format!("#/components/schemas/{}", name) }; } - let mut schema = ::rustapi_openapi::schema::JsonSchema2020::string(); + let mut schema = #openapi_path::schema::JsonSchema2020::string(); schema.enum_values = Some(vec![ #(#push_variants),* ]); ctx.components.insert(name.to_string(), schema); - ::rustapi_openapi::schema::SchemaRef::Ref { reference: format!("#/components/schemas/{}", name) } + #openapi_path::schema::SchemaRef::Ref { reference: format!("#/components/schemas/{}", name) } }; } @@ -185,16 +271,16 @@ fn impl_enum_schema(_name: &Ident, data: DataEnum) -> TokenStream { let fname = field.ident.unwrap().to_string(); let fty = field.ty; props_logic.push(quote! { - let fs_ref = <#fty as ::rustapi_openapi::schema::RustApiSchema>::schema(ctx); + let fs_ref = <#fty as #openapi_path::schema::RustApiSchema>::schema(ctx); let fs = match fs_ref { - ::rustapi_openapi::schema::SchemaRef::Schema(s) => *s, - ::rustapi_openapi::schema::SchemaRef::Ref { reference } => { - let mut s = ::rustapi_openapi::schema::JsonSchema2020::new(); + #openapi_path::schema::SchemaRef::Schema(s) => *s, + #openapi_path::schema::SchemaRef::Ref { reference } => { + let mut s = #openapi_path::schema::JsonSchema2020::new(); s.reference = Some(reference); s }, - ::rustapi_openapi::schema::SchemaRef::Inline(v) => { - ::serde_json::from_value(v).unwrap_or_default() + #openapi_path::schema::SchemaRef::Inline(v) => { + #serde_json_path::from_value(v).unwrap_or_default() }, }; v_props.insert(#fname.to_string(), fs); @@ -208,13 +294,13 @@ fn impl_enum_schema(_name: &Ident, data: DataEnum) -> TokenStream { let mut v_req = Vec::new(); #(#props_logic)* - let mut v_schema = ::rustapi_openapi::schema::JsonSchema2020::object(); + let mut v_schema = #openapi_path::schema::JsonSchema2020::object(); v_schema.properties = Some(v_props); v_schema.required = Some(v_req); let mut outer_props = ::std::collections::BTreeMap::new(); outer_props.insert(#variant_name.to_string(), v_schema); - let mut outer = ::rustapi_openapi::schema::JsonSchema2020::object(); + let mut outer = #openapi_path::schema::JsonSchema2020::object(); outer.properties = Some(outer_props); outer.required = Some(vec![#variant_name.to_string()]); @@ -227,22 +313,22 @@ fn impl_enum_schema(_name: &Ident, data: DataEnum) -> TokenStream { let fty = &unnamed.unnamed[0].ty; one_of_logic.push(quote! { { - let fs_ref = <#fty as ::rustapi_openapi::schema::RustApiSchema>::schema(ctx); + let fs_ref = <#fty as #openapi_path::schema::RustApiSchema>::schema(ctx); let fs = match fs_ref { - ::rustapi_openapi::schema::SchemaRef::Schema(s) => *s, - ::rustapi_openapi::schema::SchemaRef::Ref { reference } => { - let mut s = ::rustapi_openapi::schema::JsonSchema2020::new(); + #openapi_path::schema::SchemaRef::Schema(s) => *s, + #openapi_path::schema::SchemaRef::Ref { reference } => { + let mut s = #openapi_path::schema::JsonSchema2020::new(); s.reference = Some(reference); s }, - ::rustapi_openapi::schema::SchemaRef::Inline(v) => { - ::serde_json::from_value(v).unwrap_or_default() + #openapi_path::schema::SchemaRef::Inline(v) => { + #serde_json_path::from_value(v).unwrap_or_default() }, }; let mut outer_props = ::std::collections::BTreeMap::new(); outer_props.insert(#variant_name.to_string(), fs); - let mut outer = ::rustapi_openapi::schema::JsonSchema2020::object(); + let mut outer = #openapi_path::schema::JsonSchema2020::object(); outer.properties = Some(outer_props); outer.required = Some(vec![#variant_name.to_string()]); outer @@ -250,14 +336,14 @@ fn impl_enum_schema(_name: &Ident, data: DataEnum) -> TokenStream { }); } else { one_of_logic.push(quote! { - ::rustapi_openapi::schema::JsonSchema2020::object() + #openapi_path::schema::JsonSchema2020::object() }); } } Fields::Unit => { one_of_logic.push(quote! { { - let mut s = ::rustapi_openapi::schema::JsonSchema2020::string(); + let mut s = #openapi_path::schema::JsonSchema2020::string(); s.enum_values = Some(vec![#variant_name.into()]); s } @@ -267,20 +353,20 @@ fn impl_enum_schema(_name: &Ident, data: DataEnum) -> TokenStream { } quote! { - let name_cow = ::name(); + let name_cow = ::name(); let name = name_cow.as_ref(); if let Some(_) = ctx.components.get(name) { - return ::rustapi_openapi::schema::SchemaRef::Ref { reference: format!("#/components/schemas/{}", name) }; + return #openapi_path::schema::SchemaRef::Ref { reference: format!("#/components/schemas/{}", name) }; } - ctx.components.insert(name.to_string(), ::rustapi_openapi::schema::JsonSchema2020::new()); + ctx.components.insert(name.to_string(), #openapi_path::schema::JsonSchema2020::new()); - let mut schema = ::rustapi_openapi::schema::JsonSchema2020::new(); + let mut schema = #openapi_path::schema::JsonSchema2020::new(); schema.one_of = Some(vec![ #(#one_of_logic),* ]); ctx.components.insert(name.to_string(), schema); - ::rustapi_openapi::schema::SchemaRef::Ref { reference: format!("#/components/schemas/{}", name) } + #openapi_path::schema::SchemaRef::Ref { reference: format!("#/components/schemas/{}", name) } } } diff --git a/crates/rustapi-rs/src/lib.rs b/crates/rustapi-rs/src/lib.rs index c1ec363..d117d4d 100644 --- a/crates/rustapi-rs/src/lib.rs +++ b/crates/rustapi-rs/src/lib.rs @@ -8,7 +8,7 @@ //! //! ## Quick Start //! -//! ```rust,no_run +//! ```rust,ignore //! use rustapi_rs::prelude::*; //! //! #[derive(Serialize, Schema)] @@ -303,6 +303,11 @@ pub mod prelude { // Re-export OpenAPI schema derive pub use rustapi_openapi::Schema; + // Re-export crates needed by Schema derive macro + // These are required for the macro-generated code to compile + pub use rustapi_openapi; + pub use serde_json; + // Re-export commonly used external types pub use serde::{Deserialize, Serialize}; pub use tracing::{debug, error, info, trace, warn}; diff --git a/docs/cookbook/src/getting_started/quickstart.md b/docs/cookbook/src/getting_started/quickstart.md index df81d4d..c94b12b 100644 --- a/docs/cookbook/src/getting_started/quickstart.md +++ b/docs/cookbook/src/getting_started/quickstart.md @@ -3,6 +3,14 @@ > [!TIP] > From zero to a production-ready API in 60 seconds. +## Install the CLI + +First, install the RustAPI CLI tool: + +```bash +cargo install cargo-rustapi +``` + ## Create a New Project Use the CLI to generate a new project. We'll call it `my-api`. @@ -12,6 +20,8 @@ cargo rustapi new my-api cd my-api ``` +> **Note**: If `cargo rustapi` doesn't work, you can also run `cargo-rustapi new my-api` directly. + This command sets up a complete project structure with handling, models, and tests ready to go. ## The Code