From 5d2fc3a97e9ade86dff6a0e3d30e6f4fe650c87f Mon Sep 17 00:00:00 2001 From: Tunay Engin Date: Sun, 1 Feb 2026 20:54:27 +0300 Subject: [PATCH 1/7] Add database migration commands and CLI tests Introduces a new `migrate` command to the CLI, providing database migration management (run, revert, status, create, reset) via a wrapper around sqlx-cli. Enhances the watch command with more options and improved output. Updates project templates to use `rustapi_rs` attribute macros. Adds comprehensive integration tests for CLI commands, and updates dependencies to include `predicates` for testing. --- Cargo.lock | 10 + crates/cargo-rustapi/Cargo.toml | 1 + crates/cargo-rustapi/src/cli.rs | 8 +- crates/cargo-rustapi/src/commands/migrate.rs | 507 ++++++++++++++++++ crates/cargo-rustapi/src/commands/mod.rs | 2 + crates/cargo-rustapi/src/commands/watch.rs | 203 ++++++- crates/cargo-rustapi/src/templates/api.rs | 34 +- crates/cargo-rustapi/src/templates/full.rs | 46 +- crates/cargo-rustapi/src/templates/minimal.rs | 2 +- crates/cargo-rustapi/src/templates/web.rs | 2 +- crates/cargo-rustapi/tests/cli_tests.rs | 357 ++++++++++++ 11 files changed, 1119 insertions(+), 53 deletions(-) create mode 100644 crates/cargo-rustapi/src/commands/migrate.rs create mode 100644 crates/cargo-rustapi/tests/cli_tests.rs diff --git a/Cargo.lock b/Cargo.lock index 2a00251..fd8ed1d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -350,6 +350,7 @@ dependencies = [ "console 0.16.2", "dialoguer", "indicatif", + "predicates", "reqwest", "serde", "serde_json", @@ -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]] 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..76b1564 --- /dev/null +++ b/crates/cargo-rustapi/src/commands/migrate.rs @@ -0,0 +1,507 @@ +//! 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<()> { + // Check if sqlx-cli is installed + ensure_sqlx_installed().await?; + + match args { + MigrateArgs::Run(args) => run_migrations(args).await, + MigrateArgs::Revert(args) => revert_migrations(args).await, + MigrateArgs::Status(args) => show_status(args).await, + MigrateArgs::Create(args) => create_migration(args).await, + MigrateArgs::Reset(args) => 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/watch.rs b/crates/cargo-rustapi/src/commands/watch.rs index 2512821..447d022 100644 --- a/crates/cargo-rustapi/src/commands/watch.rs +++ b/crates/cargo-rustapi/src/commands/watch.rs @@ -1,54 +1,237 @@ -//! 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)); + + // 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); + } + } + + // Ignore paths + for path in &args.ignore_paths { + cmd.arg("-i").arg(path); + } - // Ignore common directories to improve performance - cmd.args(["-i", ".git", "-i", "target", "-i", "node_modules"]); + // 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); - cmd.spawn()?.wait().await?; + // 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..348e6c2 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() @@ -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,9 +158,9 @@ 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, @@ -174,9 +174,9 @@ pub async fn create( } /// 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, @@ -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..b33d465 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(); @@ -162,9 +162,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" { @@ -189,9 +189,9 @@ 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) } @@ -214,9 +214,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 +226,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,9 +243,9 @@ 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, @@ -262,9 +262,9 @@ pub async fn create( } /// 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, @@ -289,9 +289,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, @@ -321,7 +321,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..8c1d1fb 100644 --- a/crates/cargo-rustapi/src/templates/minimal.rs +++ b/crates/cargo-rustapi/src/templates/minimal.rs @@ -37,7 +37,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..bfefb3f 100644 --- a/crates/cargo-rustapi/src/templates/web.rs +++ b/crates/cargo-rustapi/src/templates/web.rs @@ -42,7 +42,7 @@ tracing-subscriber = {{ version = "0.3", features = ["env-filter"] }} use rustapi_rs::prelude::*; use rustapi_view::Templates; -#[rustapi::main] +#[rustapi_rs::main] async fn main() -> Result<(), Box> { // Initialize tracing tracing_subscriber::fmt() diff --git a/crates/cargo-rustapi/tests/cli_tests.rs b/crates/cargo-rustapi/tests/cli_tests.rs new file mode 100644 index 0000000..4694b81 --- /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")); + } +} From f92fb41dc071eb670741080e6b8beaa782cfcdc7 Mon Sep 17 00:00:00 2001 From: Tunay Engin Date: Sun, 1 Feb 2026 23:38:32 +0300 Subject: [PATCH 2/7] Improve Schema derive macro compatibility and update docs Refactored the Schema derive macro in rustapi-macros to dynamically resolve crate paths for rustapi_openapi and serde_json, improving compatibility for both internal and external users. Updated dependencies to include proc-macro-crate. Adjusted API and full template handlers to use new mount_route and simplified create handler return types. Updated quickstart documentation to clarify CLI installation and usage. Re-exported rustapi_openapi and serde_json in rustapi-rs prelude for macro support. --- Cargo.lock | 48 +++++- crates/cargo-rustapi/src/templates/api.rs | 16 +- crates/cargo-rustapi/src/templates/full.rs | 16 +- crates/rustapi-macros/Cargo.toml | 1 + crates/rustapi-macros/src/derive_schema.rs | 156 ++++++++++++------ crates/rustapi-rs/src/lib.rs | 5 + .../src/getting_started/quickstart.md | 10 ++ 7 files changed, 186 insertions(+), 66 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index fd8ed1d..05a305d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -359,7 +359,7 @@ dependencies = [ "thiserror 2.0.17", "tokio", "toml", - "toml_edit", + "toml_edit 0.22.27", "tracing", "tracing-subscriber", "walkdir", @@ -2548,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" @@ -3152,6 +3161,7 @@ dependencies = [ name = "rustapi-macros" version = "0.1.233" dependencies = [ + "proc-macro-crate", "proc-macro2", "quote", "serde_json", @@ -4340,8 +4350,8 @@ checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" dependencies = [ "serde", "serde_spanned", - "toml_datetime", - "toml_edit", + "toml_datetime 0.6.11", + "toml_edit 0.22.27", ] [[package]] @@ -4353,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" @@ -4362,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/src/templates/api.rs b/crates/cargo-rustapi/src/templates/api.rs index 348e6c2..057a953 100644 --- a/crates/cargo-rustapi/src/templates/api.rs +++ b/crates/cargo-rustapi/src/templates/api.rs @@ -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) @@ -164,13 +164,13 @@ pub async fn get( 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 @@ -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(); diff --git a/crates/cargo-rustapi/src/templates/full.rs b/crates/cargo-rustapi/src/templates/full.rs index b33d465..de390b2 100644 --- a/crates/cargo-rustapi/src/templates/full.rs +++ b/crates/cargo-rustapi/src/templates/full.rs @@ -93,11 +93,11 @@ 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(), @@ -250,7 +250,7 @@ pub async fn create( auth: AuthUser, State(state): State, Json(body): Json, -) -> Result>> { +) -> Json { let item = Item::new(body.name, body.description, auth.claims.sub.clone()); let mut store = state.write().await; @@ -258,7 +258,7 @@ pub async fn create( tracing::info!("User {} created item {}", auth.claims.username, item.id); - Ok(Created(Json(item))) + Json(item) } /// Update an item @@ -281,7 +281,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(); 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..6ed62b6 100644 --- a/crates/rustapi-macros/src/derive_schema.rs +++ b/crates/rustapi-macros/src/derive_schema.rs @@ -1,13 +1,77 @@ 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 { + // First try rustapi-rs (the umbrella crate most users will have) + if let Ok(found) = crate_name("rustapi-rs") { + match found { + FoundCrate::Itself => { + // We're in rustapi-rs itself + quote! { ::rustapi_rs::prelude::rustapi_openapi } + } + FoundCrate::Name(name) => { + let ident = syn::Ident::new(&name, proc_macro2::Span::call_site()); + quote! { ::#ident::prelude::rustapi_openapi } + } + } + } else if let Ok(found) = 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 ident = syn::Ident::new(&name, proc_macro2::Span::call_site()); + quote! { ::#ident } + } + } + } else { + // Default fallback - assume rustapi_rs is available + quote! { ::rustapi_rs::prelude::rustapi_openapi } + } +} + +/// Get serde_json path - either from rustapi_rs::prelude or directly +fn get_serde_json_path() -> TokenStream { + // First try rustapi-rs (the umbrella crate most users will have) + if let Ok(found) = crate_name("rustapi-rs") { + match found { + FoundCrate::Itself => { + quote! { ::rustapi_rs::prelude::serde_json } + } + FoundCrate::Name(name) => { + let ident = syn::Ident::new(&name, 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 +81,23 @@ 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 +110,14 @@ 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 +145,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 +162,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 +171,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 +193,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 +209,7 @@ 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 +220,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 +249,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 +272,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 +291,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 +314,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 +331,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..c81dce0 100644 --- a/crates/rustapi-rs/src/lib.rs +++ b/crates/rustapi-rs/src/lib.rs @@ -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 From 6e50a563e3395ee9bd24fa51392e80d5e091bcb9 Mon Sep 17 00:00:00 2001 From: Tunay Engin Date: Mon, 2 Feb 2026 00:46:48 +0300 Subject: [PATCH 3/7] Update minimal.rs --- crates/cargo-rustapi/src/templates/minimal.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/cargo-rustapi/src/templates/minimal.rs b/crates/cargo-rustapi/src/templates/minimal.rs index 8c1d1fb..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), From ec0dc5529ee3518d6d2dc1a2a72746f4f3f7f475 Mon Sep 17 00:00:00 2001 From: Tunay Engin Date: Mon, 2 Feb 2026 05:16:47 +0300 Subject: [PATCH 4/7] Improve hot-reload, template, and macro handling Enhances the run command with improved hot-reload messaging and aliasing, updates project templates to use correct crate paths and API usage, and refines macro crate path resolution for better compatibility with hyphenated and underscored crate names. Also adds missing attribute to new command argument and fixes usage of authentication claims in templates. --- crates/cargo-rustapi/src/commands/new.rs | 1 + crates/cargo-rustapi/src/commands/run.rs | 25 +++++++++++----- crates/cargo-rustapi/src/templates/full.rs | 26 +++++++++-------- crates/cargo-rustapi/src/templates/web.rs | 7 ++--- crates/rustapi-macros/src/derive_schema.rs | 33 ++++++++++++++-------- 5 files changed, 58 insertions(+), 34 deletions(-) diff --git a/crates/cargo-rustapi/src/commands/new.rs b/crates/cargo-rustapi/src/commands/new.rs index 8a9489f..ee94604 100644 --- a/crates/cargo-rustapi/src/commands/new.rs +++ b/crates/cargo-rustapi/src/commands/new.rs @@ -15,6 +15,7 @@ use crate::templates::{self, ProjectTemplate}; #[derive(Args, Debug)] pub struct NewArgs { /// Project name + #[arg(short, long)] 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..d32d78e 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,21 @@ 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 +101,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 +115,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 +124,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/templates/full.rs b/crates/cargo-rustapi/src/templates/full.rs index de390b2..50b8a90 100644 --- a/crates/cargo-rustapi/src/templates/full.rs +++ b/crates/cargo-rustapi/src/templates/full.rs @@ -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); @@ -99,11 +99,12 @@ async fn main() -> Result<(), Box> { .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, @@ -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, @@ -193,7 +195,7 @@ pub async fn login(Json(body): Json) -> Result #[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 { @@ -251,12 +253,12 @@ pub async fn create( State(state): State, Json(body): Json, ) -> Json { - let item = Item::new(body.name, body.description, auth.claims.sub.clone()); + 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); Json(item) } @@ -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) } diff --git a/crates/cargo-rustapi/src/templates/web.rs b/crates/cargo-rustapi/src/templates/web.rs index bfefb3f..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,7 +39,7 @@ 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_rs::main] async fn main() -> Result<(), Box> { @@ -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/rustapi-macros/src/derive_schema.rs b/crates/rustapi-macros/src/derive_schema.rs index 6ed62b6..b2f79e6 100644 --- a/crates/rustapi-macros/src/derive_schema.rs +++ b/crates/rustapi-macros/src/derive_schema.rs @@ -13,19 +13,25 @@ use syn::{Data, DataEnum, DataStruct, Fields, Ident}; /// - Internal crates (like rustapi-openapi itself) /// - User projects that depend on rustapi-rs fn get_openapi_path() -> TokenStream { - // First try rustapi-rs (the umbrella crate most users will have) - if let Ok(found) = crate_name("rustapi-rs") { + // 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! { ::rustapi_rs::prelude::rustapi_openapi } + quote! { crate::prelude::rustapi_openapi } } FoundCrate::Name(name) => { - let ident = syn::Ident::new(&name, proc_macro2::Span::call_site()); + // 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") { + } else if let Ok(found) = crate_name("rustapi-openapi").or_else(|_| crate_name("rustapi_openapi")) { // Fallback to rustapi-openapi directly match found { FoundCrate::Itself => { @@ -33,26 +39,31 @@ fn get_openapi_path() -> TokenStream { quote! { crate } } FoundCrate::Name(name) => { - let ident = syn::Ident::new(&name, proc_macro2::Span::call_site()); + 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 + // 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 { - // First try rustapi-rs (the umbrella crate most users will have) - if let Ok(found) = crate_name("rustapi-rs") { + // 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! { ::rustapi_rs::prelude::serde_json } + quote! { crate::prelude::serde_json } } FoundCrate::Name(name) => { - let ident = syn::Ident::new(&name, proc_macro2::Span::call_site()); + let normalized = name.replace('-', "_"); + let ident = syn::Ident::new(&normalized, proc_macro2::Span::call_site()); quote! { ::#ident::prelude::serde_json } } } From a1752ceb640beda02ee6b5ac49457670cb21d349 Mon Sep 17 00:00:00 2001 From: Tunay Engin Date: Mon, 2 Feb 2026 05:22:31 +0300 Subject: [PATCH 5/7] Refactor formatting and argument handling in commands Improves code readability and consistency by refactoring multi-line formatting, argument passing, and function signatures in migrate.rs, run.rs, watch.rs, and derive_schema.rs. Also updates test formatting in cli_tests.rs for better clarity. --- crates/cargo-rustapi/src/commands/migrate.rs | 51 ++++++++++++-------- crates/cargo-rustapi/src/commands/run.rs | 11 +++-- crates/cargo-rustapi/src/commands/watch.rs | 16 +++--- crates/cargo-rustapi/tests/cli_tests.rs | 8 +-- crates/rustapi-macros/src/derive_schema.rs | 35 +++++++++----- 5 files changed, 72 insertions(+), 49 deletions(-) diff --git a/crates/cargo-rustapi/src/commands/migrate.rs b/crates/cargo-rustapi/src/commands/migrate.rs index 76b1564..aee49e8 100644 --- a/crates/cargo-rustapi/src/commands/migrate.rs +++ b/crates/cargo-rustapi/src/commands/migrate.rs @@ -140,13 +140,16 @@ async fn ensure_sqlx_installed() -> Result<()> { "{}", style("sqlx-cli is not installed. Installing...").yellow() ); - println!( - "{}", - style("This may take a few minutes...").dim() - ); + 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"]) + .args([ + "install", + "sqlx-cli", + "--no-default-features", + "--features", + "postgres,mysql,sqlite", + ]) .status() .await .context("Failed to run cargo install")?; @@ -166,7 +169,11 @@ async fn ensure_sqlx_installed() -> Result<()> { /// Run pending migrations async fn run_migrations(args: MigrateRunArgs) -> Result<()> { - println!("{} {} Running migrations...", DB, style("migrate run").cyan().bold()); + println!( + "{} {} Running migrations...", + DB, + style("migrate run").cyan().bold() + ); println!(); // Ensure migrations directory exists @@ -237,7 +244,10 @@ async fn revert_migrations(args: MigrateRevertArgs) -> Result<()> { // 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")?; + let status = cmd + .status() + .await + .context("Failed to run sqlx migrate revert")?; if !status.success() { anyhow::bail!("Failed to revert migration {}", i + 1); } @@ -251,7 +261,11 @@ async fn revert_migrations(args: MigrateRevertArgs) -> Result<()> { /// Show migration status async fn show_status(args: MigrateStatusArgs) -> Result<()> { - println!("{} {} Checking migration status...", DB, style("migrate status").cyan().bold()); + println!( + "{} {} Checking migration status...", + DB, + style("migrate status").cyan().bold() + ); println!(); // Check if migrations directory exists @@ -364,7 +378,7 @@ async fn create_migration(args: MigrateCreateArgs) -> Result<()> { // 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?; @@ -374,10 +388,7 @@ async fn create_migration(args: MigrateCreateArgs) -> Result<()> { } println!(); - println!( - "{}", - style("Edit the migration file(s), then run:").dim() - ); + println!("{}", style("Edit the migration file(s), then run:").dim()); println!(" cargo rustapi migrate run"); Ok(()) @@ -458,18 +469,18 @@ async fn reset_database(args: MigrateResetArgs) -> Result<()> { /// 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) @@ -483,10 +494,10 @@ mod tests { 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); @@ -500,7 +511,7 @@ mod tests { source: "migrations".to_string(), timestamp: false, }; - + assert_eq!(args.name, "create_users"); assert!(args.reversible); } diff --git a/crates/cargo-rustapi/src/commands/run.rs b/crates/cargo-rustapi/src/commands/run.rs index d32d78e..18d4dc1 100644 --- a/crates/cargo-rustapi/src/commands/run.rs +++ b/crates/cargo-rustapi/src/commands/run.rs @@ -35,7 +35,9 @@ pub async fn run_dev(args: RunArgs) -> Result<()> { if args.watch { println!( "{}", - style("🔄 Starting RustAPI in hot-reload mode...").bold().cyan() + style("🔄 Starting RustAPI in hot-reload mode...") + .bold() + .cyan() ); println!( "{}", @@ -45,7 +47,10 @@ pub async fn run_dev(args: RunArgs) -> Result<()> { // Use cargo-watch if available run_with_watch(&args).await } else { - println!("{}", style("🚀 Starting RustAPI development server...").bold()); + println!( + "{}", + style("🚀 Starting RustAPI development server...").bold() + ); println!(); run_cargo(&args).await } @@ -126,7 +131,7 @@ async fn run_with_watch(args: &RunArgs) -> Result<()> { // 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 447d022..d8386ba 100644 --- a/crates/cargo-rustapi/src/commands/watch.rs +++ b/crates/cargo-rustapi/src/commands/watch.rs @@ -131,20 +131,16 @@ pub async fn watch(args: WatchArgs) -> Result<()> { style("Ignoring:").bold(), args.ignore_paths.join(", ") ); - println!( - "{} {} {}ms", - GEAR, - style("Delay:").bold(), - args.delay - ); + println!("{} {} {}ms", GEAR, style("Delay:").bold(), args.delay); + println!(); + println!("{}", style("Press Ctrl+C to stop watching.").dim()); println!(); println!( - "{}", - style("Press Ctrl+C to stop watching.").dim() + "{} {}", + ROCKET, + style("Starting watch mode...").green().bold() ); println!(); - println!("{} {}", ROCKET, style("Starting watch mode...").green().bold()); - println!(); } // Build cargo-watch command diff --git a/crates/cargo-rustapi/tests/cli_tests.rs b/crates/cargo-rustapi/tests/cli_tests.rs index 4694b81..d401a90 100644 --- a/crates/cargo-rustapi/tests/cli_tests.rs +++ b/crates/cargo-rustapi/tests/cli_tests.rs @@ -48,8 +48,8 @@ mod new_command { ); // Verify Cargo.toml content - let cargo_content = fs::read_to_string(project_path.join("Cargo.toml")) - .expect("Failed to read Cargo.toml"); + 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" @@ -96,8 +96,8 @@ mod new_command { .assert() .success(); - let cargo_content = fs::read_to_string(project_path.join("Cargo.toml")) - .expect("Failed to read Cargo.toml"); + 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" diff --git a/crates/rustapi-macros/src/derive_schema.rs b/crates/rustapi-macros/src/derive_schema.rs index b2f79e6..d6f672b 100644 --- a/crates/rustapi-macros/src/derive_schema.rs +++ b/crates/rustapi-macros/src/derive_schema.rs @@ -4,20 +4,19 @@ 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")); - + 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 => { @@ -31,7 +30,9 @@ fn get_openapi_path() -> TokenStream { quote! { ::#ident::prelude::rustapi_openapi } } } - } else if let Ok(found) = crate_name("rustapi-openapi").or_else(|_| crate_name("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 => { @@ -53,9 +54,8 @@ fn get_openapi_path() -> TokenStream { /// 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")); - + 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 => { @@ -100,7 +100,10 @@ pub fn expand_derive_schema(input: syn::DeriveInput) -> TokenStream { let (schema_impl, field_schemas_impl) = match input.data { 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::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(); } @@ -128,7 +131,11 @@ pub fn expand_derive_schema(input: syn::DeriveInput) -> TokenStream { } } -fn impl_struct_schema_bodies(openapi_path: &TokenStream, serde_json_path: &TokenStream, 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(); @@ -220,7 +227,11 @@ fn impl_struct_schema_bodies(openapi_path: &TokenStream, serde_json_path: &Token (schema_body, field_schemas_body) } -fn impl_enum_schema(openapi_path: &TokenStream, serde_json_path: &TokenStream, data: DataEnum) -> TokenStream { +fn impl_enum_schema( + openapi_path: &TokenStream, + serde_json_path: &TokenStream, + data: DataEnum, +) -> TokenStream { let is_string_enum = data .variants .iter() From 096ced31962afcc57c5550ab3430dfbb14a276d4 Mon Sep 17 00:00:00 2001 From: Tunay Engin Date: Mon, 2 Feb 2026 17:36:17 +0300 Subject: [PATCH 6/7] CI: test and covarage error fixes --- crates/cargo-rustapi/src/commands/new.rs | 3 +-- crates/rustapi-rs/src/lib.rs | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/crates/cargo-rustapi/src/commands/new.rs b/crates/cargo-rustapi/src/commands/new.rs index ee94604..af5bbcf 100644 --- a/crates/cargo-rustapi/src/commands/new.rs +++ b/crates/cargo-rustapi/src/commands/new.rs @@ -14,8 +14,7 @@ use crate::templates::{self, ProjectTemplate}; /// Arguments for the `new` command #[derive(Args, Debug)] pub struct NewArgs { - /// Project name - #[arg(short, long)] + /// Project name (positional argument) pub name: Option, /// Project template diff --git a/crates/rustapi-rs/src/lib.rs b/crates/rustapi-rs/src/lib.rs index c81dce0..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)] From a7cbab94441e37a8a8270a217c135499aaba9109 Mon Sep 17 00:00:00 2001 From: Tunay Engin Date: Mon, 2 Feb 2026 17:45:21 +0300 Subject: [PATCH 7/7] Update migrate.rs --- crates/cargo-rustapi/src/commands/migrate.rs | 25 +++++++++++++------- 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/crates/cargo-rustapi/src/commands/migrate.rs b/crates/cargo-rustapi/src/commands/migrate.rs index aee49e8..3d99548 100644 --- a/crates/cargo-rustapi/src/commands/migrate.rs +++ b/crates/cargo-rustapi/src/commands/migrate.rs @@ -117,15 +117,24 @@ pub struct MigrateResetArgs { /// Execute migration commands pub async fn migrate(args: MigrateArgs) -> Result<()> { - // Check if sqlx-cli is installed - ensure_sqlx_installed().await?; - match args { - MigrateArgs::Run(args) => run_migrations(args).await, - MigrateArgs::Revert(args) => revert_migrations(args).await, - MigrateArgs::Status(args) => show_status(args).await, - MigrateArgs::Create(args) => create_migration(args).await, - MigrateArgs::Reset(args) => reset_database(args).await, + 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 + } } }