Skip to main content

ferro_rs/
app.rs

1//! Application builder for Ferro framework
2//!
3//! Provides a fluent builder API to configure and run a Ferro application.
4//!
5//! # Example
6//!
7//! ```rust,ignore
8//! use ferro_rs::Application;
9//!
10//! #[tokio::main]
11//! async fn main() {
12//!     Application::new()
13//!         .config(config::register_all)
14//!         .bootstrap(bootstrap::register)
15//!         .routes(routes::register)
16//!         .migrations::<migrations::Migrator>()
17//!         .run()
18//!         .await;
19//! }
20//! ```
21
22use crate::seeder::SeederRegistry;
23use crate::{Config, Router, Server};
24use clap::{Parser, Subcommand};
25use sea_orm_migration::prelude::*;
26use std::env;
27use std::future::Future;
28use std::path::Path;
29use std::pin::Pin;
30
31/// Type alias for async bootstrap function
32type BootstrapFn = Box<dyn FnOnce() -> Pin<Box<dyn Future<Output = ()> + Send>> + Send>;
33
34/// CLI structure for Ferro applications
35#[derive(Parser)]
36#[command(name = "app")]
37#[command(about = "Ferro application server and utilities")]
38struct Cli {
39    #[command(subcommand)]
40    command: Option<Commands>,
41}
42
43#[derive(Subcommand)]
44enum Commands {
45    /// Run the web server (default command)
46    Serve {
47        /// Skip running migrations on startup
48        #[arg(long)]
49        no_migrate: bool,
50    },
51    /// Run pending database migrations
52    #[command(name = "db:migrate")]
53    DbMigrate,
54    /// Show migration status
55    #[command(name = "db:status")]
56    DbStatus,
57    /// Rollback the last migration(s)
58    #[command(name = "db:rollback")]
59    DbRollback {
60        /// Number of migrations to rollback
61        #[arg(default_value = "1")]
62        steps: u32,
63    },
64    /// Drop all tables and re-run all migrations
65    #[command(name = "db:fresh")]
66    DbFresh,
67    /// Run the scheduler daemon (checks every minute)
68    #[command(name = "schedule:work")]
69    ScheduleWork,
70    /// Run all due scheduled tasks once
71    #[command(name = "schedule:run")]
72    ScheduleRun,
73    /// List all registered scheduled tasks
74    #[command(name = "schedule:list")]
75    ScheduleList,
76    /// Run database seeders
77    #[command(name = "db:seed")]
78    DbSeed {
79        /// Run only a specific seeder
80        #[arg(long)]
81        class: Option<String>,
82    },
83    /// Export the JSON-UI v2 spec schema (full spec or a single component's Props)
84    #[cfg(feature = "json-ui")]
85    #[command(name = "json-ui:schema")]
86    JsonUiSchema {
87        /// Write to file instead of stdout
88        #[arg(long, short = 'o')]
89        output: Option<String>,
90
91        /// Pretty-print JSON output (default behavior โ€” flag accepted for explicitness)
92        #[arg(long)]
93        pretty: bool,
94
95        /// Export only the Props schema for a single component (e.g., "Card")
96        #[arg(long)]
97        component: Option<String>,
98    },
99}
100
101/// Application builder for Ferro framework
102///
103/// Use this to configure and run your Ferro application with a fluent API.
104pub struct Application<M = NoMigrator>
105where
106    M: MigratorTrait,
107{
108    config_fn: Option<Box<dyn FnOnce()>>,
109    bootstrap_fn: Option<BootstrapFn>,
110    routes_fn: Option<Box<dyn FnOnce() -> Router + Send>>,
111    seeders_fn: Option<Box<dyn FnOnce() -> SeederRegistry + Send>>,
112    _migrator: std::marker::PhantomData<M>,
113}
114
115/// Placeholder type for when no migrator is configured
116pub struct NoMigrator;
117
118impl MigratorTrait for NoMigrator {
119    fn migrations() -> Vec<Box<dyn MigrationTrait>> {
120        vec![]
121    }
122}
123
124impl Application<NoMigrator> {
125    /// Create a new application builder
126    pub fn new() -> Self {
127        Application {
128            config_fn: None,
129            bootstrap_fn: None,
130            routes_fn: None,
131            seeders_fn: None,
132            _migrator: std::marker::PhantomData,
133        }
134    }
135}
136
137impl Default for Application<NoMigrator> {
138    fn default() -> Self {
139        Self::new()
140    }
141}
142
143impl<M> Application<M>
144where
145    M: MigratorTrait,
146{
147    /// Register a configuration function
148    ///
149    /// This function is called early during startup to register
150    /// application configuration.
151    ///
152    /// # Example
153    ///
154    /// ```rust,ignore
155    /// App::new()
156    ///     .config(config::register_all)
157    /// ```
158    pub fn config<F>(mut self, f: F) -> Self
159    where
160        F: FnOnce() + 'static,
161    {
162        self.config_fn = Some(Box::new(f));
163        self
164    }
165
166    /// Register a bootstrap function
167    ///
168    /// This async function is called to register services, middleware,
169    /// and other application components.
170    ///
171    /// # Example
172    ///
173    /// ```rust,ignore
174    /// App::new()
175    ///     .bootstrap(bootstrap::register)
176    /// ```
177    pub fn bootstrap<F, Fut>(mut self, f: F) -> Self
178    where
179        F: FnOnce() -> Fut + Send + 'static,
180        Fut: Future<Output = ()> + Send + 'static,
181    {
182        self.bootstrap_fn = Some(Box::new(move || Box::pin(f())));
183        self
184    }
185
186    /// Register a routes function
187    ///
188    /// This function returns the application's router configuration.
189    ///
190    /// # Example
191    ///
192    /// ```rust,ignore
193    /// App::new()
194    ///     .routes(routes::register)
195    /// ```
196    pub fn routes<F>(mut self, f: F) -> Self
197    where
198        F: FnOnce() -> Router + Send + 'static,
199    {
200        self.routes_fn = Some(Box::new(f));
201        self
202    }
203
204    /// Configure the migrator type for database migrations
205    ///
206    /// # Example
207    ///
208    /// ```rust,ignore
209    /// Application::new()
210    ///     .migrations::<migrations::Migrator>()
211    /// ```
212    pub fn migrations<NewM>(self) -> Application<NewM>
213    where
214        NewM: MigratorTrait,
215    {
216        Application {
217            config_fn: self.config_fn,
218            bootstrap_fn: self.bootstrap_fn,
219            routes_fn: self.routes_fn,
220            seeders_fn: self.seeders_fn,
221            _migrator: std::marker::PhantomData,
222        }
223    }
224
225    /// Register a seeders function
226    ///
227    /// This function returns the application's seeder registry for database seeding.
228    ///
229    /// # Example
230    ///
231    /// ```rust,ignore
232    /// Application::new()
233    ///     .seeders(seeders::register)
234    /// ```
235    pub fn seeders<F>(mut self, f: F) -> Self
236    where
237        F: FnOnce() -> SeederRegistry + Send + 'static,
238    {
239        self.seeders_fn = Some(Box::new(f));
240        self
241    }
242
243    /// Run the application
244    ///
245    /// This parses CLI arguments and executes the appropriate command:
246    /// - `serve` (default): Run the web server
247    /// - `db:migrate`: Run pending migrations
248    /// - `db:status`: Show migration status
249    /// - `db:rollback`: Rollback migrations
250    /// - `db:fresh`: Drop and re-run all migrations
251    /// - `schedule:*`: Scheduler commands
252    pub async fn run(self) {
253        let cli = Cli::parse();
254
255        // Initialize framework configuration (loads .env files)
256        Config::init(Path::new("."));
257
258        // Destructure self to avoid partial move issues
259        let Application {
260            config_fn,
261            bootstrap_fn,
262            routes_fn,
263            seeders_fn,
264            _migrator,
265        } = self;
266
267        // Run user's config registration
268        if let Some(config_fn) = config_fn {
269            config_fn();
270        }
271
272        // Initialize translator (after config so user can override LangConfig)
273        crate::lang::init::init();
274
275        match cli.command {
276            None | Some(Commands::Serve { no_migrate: false }) => {
277                // Default: run server with auto-migrate
278                Self::run_migrations_silent::<M>().await;
279                Self::run_server_internal(bootstrap_fn, routes_fn).await;
280            }
281            Some(Commands::Serve { no_migrate: true }) => {
282                // Run server without migrations
283                Self::run_server_internal(bootstrap_fn, routes_fn).await;
284            }
285            Some(Commands::DbMigrate) => {
286                Self::run_migrations::<M>().await;
287            }
288            Some(Commands::DbStatus) => {
289                Self::show_migration_status::<M>().await;
290            }
291            Some(Commands::DbRollback { steps }) => {
292                Self::rollback_migrations::<M>(steps).await;
293            }
294            Some(Commands::DbFresh) => {
295                Self::fresh_migrations::<M>().await;
296            }
297            Some(Commands::ScheduleWork) => {
298                Self::run_scheduler_daemon_internal(bootstrap_fn).await;
299            }
300            Some(Commands::ScheduleRun) => {
301                Self::run_scheduled_tasks_internal(bootstrap_fn).await;
302            }
303            Some(Commands::ScheduleList) => {
304                Self::list_scheduled_tasks().await;
305            }
306            Some(Commands::DbSeed { class }) => {
307                Self::run_seeders(seeders_fn, class).await;
308            }
309            #[cfg(feature = "json-ui")]
310            Some(Commands::JsonUiSchema {
311                output,
312                pretty,
313                component,
314            }) => {
315                Self::run_json_ui_schema(output, pretty, component).await;
316            }
317        }
318    }
319
320    #[cfg(feature = "json-ui")]
321    async fn run_json_ui_schema(output: Option<String>, pretty: bool, component: Option<String>) {
322        // Build a local Catalog so BuildFailed surfaces as non-zero exit
323        // (NOT a panic via global_catalog's `expect`). RESEARCH ยง8 L-1 pattern.
324        let catalog = match ferro_json_ui::Catalog::build() {
325            Ok(c) => c,
326            Err(e) => {
327                eprintln!("error building catalog: {e}");
328                std::process::exit(1);
329            }
330        };
331
332        let value: &serde_json::Value = match &component {
333            Some(name) => match catalog.component_schema(name) {
334                Some(v) => v,
335                None => {
336                    eprintln!("error: unknown component '{name}'");
337                    std::process::exit(1);
338                }
339            },
340            None => catalog.json_schema(),
341        };
342
343        // CONTEXT D-21: default output is pretty-printed. The --pretty flag
344        // stays as an explicit opt-in for back-compat with tooling that passes
345        // it; compact is NOT reachable via any flag in Phase 117.
346        let _ = pretty;
347        let serialized = serde_json::to_string_pretty(value).expect("schema serializes");
348
349        match output {
350            Some(path) => {
351                if let Err(e) = std::fs::write(&path, serialized) {
352                    eprintln!("error writing to {path}: {e}");
353                    std::process::exit(1);
354                }
355            }
356            None => println!("{serialized}"),
357        }
358    }
359
360    async fn run_seeders(
361        seeders_fn: Option<Box<dyn FnOnce() -> SeederRegistry + Send>>,
362        class: Option<String>,
363    ) {
364        let database_url = match std::env::var("DATABASE_URL") {
365            Ok(u) => u,
366            Err(_) => {
367                eprintln!("DATABASE_URL must be set");
368                std::process::exit(1);
369            }
370        };
371        let db = match sea_orm::Database::connect(&database_url).await {
372            Ok(c) => c,
373            Err(e) => {
374                eprintln!("Failed to connect to database: {e}");
375                std::process::exit(1);
376            }
377        };
378
379        let registry = match seeders_fn {
380            Some(f) => f(),
381            None => {
382                eprintln!("No seeders registered.");
383                eprintln!("Register seeders with .seeders(seeders::register) in main.rs");
384                return;
385            }
386        };
387
388        let result = match class {
389            Some(name) => registry.run_one(&name, &db).await,
390            None => registry.run_all(&db).await,
391        };
392
393        if let Err(e) = result {
394            eprintln!("Seeding failed: {e}");
395            std::process::exit(1);
396        }
397    }
398
399    async fn run_server_internal(
400        bootstrap_fn: Option<BootstrapFn>,
401        routes_fn: Option<Box<dyn FnOnce() -> Router + Send>>,
402    ) {
403        // Run bootstrap
404        if let Some(bootstrap_fn) = bootstrap_fn {
405            bootstrap_fn().await;
406        }
407
408        // Initialize the queue DB connection + spawn the WorkerLoop if jobs are
409        // registered (D-09). Guard with is_initialized() so a consumer bootstrap
410        // that already called Queue::init() does not cause a double-init error.
411        if !ferro_queue::Queue::is_initialized() {
412            let conn = Self::get_database_connection().await;
413            let _ = ferro_queue::Queue::init(conn).await;
414        }
415        if ferro_queue::Queue::has_registered_jobs() {
416            // Warn when jobs are registered (so a WorkerLoop is started) but the
417            // queue is in sync mode (WR-04). In sync mode every `dispatch()`
418            // runs inline in the request path โ€” `.delay()`/`.on_queue()` are
419            // ignored โ€” while the WorkerLoop polls an empty queue. This is the
420            // default when QUEUE_CONNECTION is unset, which is a foot-gun in
421            // production.
422            if ferro_queue::QueueConfig::is_sync_mode() {
423                eprintln!(
424                    "WARNING: queue jobs are registered but QUEUE_CONNECTION is sync \
425                     (or unset, which defaults to sync). dispatch() will run jobs inline \
426                     in the request path and ignore delay/on_queue. Set QUEUE_CONNECTION \
427                     to a non-sync value (e.g. 'db') to enable background processing."
428                );
429            }
430            let config = ferro_queue::WorkerConfig::default();
431            let worker = ferro_queue::WorkerLoop::from_registry(config);
432            tokio::spawn(async move {
433                if let Err(e) = worker.run().await {
434                    eprintln!("WorkerLoop exited with error: {e}");
435                }
436            });
437        }
438
439        // Get router
440        let router = if let Some(routes_fn) = routes_fn {
441            routes_fn()
442        } else {
443            Router::new()
444        };
445
446        // Create server with configuration from environment
447        if let Err(e) = Server::from_config(router).run().await {
448            eprintln!("Failed to start server: {e}");
449            std::process::exit(1);
450        }
451    }
452
453    async fn get_database_connection() -> sea_orm::DatabaseConnection {
454        let database_url = env::var("DATABASE_URL").expect("DATABASE_URL must be set");
455
456        // For SQLite, ensure the database file can be created
457        let database_url = if database_url.starts_with("sqlite://") {
458            let path = database_url.trim_start_matches("sqlite://");
459            let path = path.trim_start_matches("./");
460
461            if let Some(parent) = Path::new(path).parent() {
462                if !parent.as_os_str().is_empty() {
463                    std::fs::create_dir_all(parent).ok();
464                }
465            }
466
467            if !Path::new(path).exists() {
468                std::fs::File::create(path).ok();
469            }
470
471            format!("sqlite:{path}?mode=rwc")
472        } else {
473            database_url
474        };
475
476        sea_orm::Database::connect(&database_url)
477            .await
478            .expect("Failed to connect to database")
479    }
480
481    /// Run migrations during server boot without success logging.
482    ///
483    /// "Silent" refers only to the success path (no progress logs that would
484    /// interleave with server startup). On failure this method writes to stderr
485    /// and aborts the process to prevent the server from accepting traffic with
486    /// a stale schema.
487    async fn run_migrations_silent<Migrator: MigratorTrait>() {
488        let db = Self::get_database_connection().await;
489        if let Err(e) = Migrator::up(&db, None).await {
490            eprintln!("Migration failed: {e}");
491            std::process::exit(1);
492        }
493    }
494
495    async fn run_migrations<Migrator: MigratorTrait>() {
496        println!("Running migrations...");
497        let db = Self::get_database_connection().await;
498        Migrator::up(&db, None)
499            .await
500            .expect("Failed to run migrations");
501        println!("Migrations completed successfully!");
502    }
503
504    async fn show_migration_status<Migrator: MigratorTrait>() {
505        println!("Migration status:");
506        let db = Self::get_database_connection().await;
507        Migrator::status(&db)
508            .await
509            .expect("Failed to get migration status");
510    }
511
512    async fn rollback_migrations<Migrator: MigratorTrait>(steps: u32) {
513        println!("Rolling back {steps} migration(s)...");
514        let db = Self::get_database_connection().await;
515        Migrator::down(&db, Some(steps))
516            .await
517            .expect("Failed to rollback migrations");
518        println!("Rollback completed successfully!");
519    }
520
521    async fn fresh_migrations<Migrator: MigratorTrait>() {
522        println!("WARNING: Dropping all tables and re-running migrations...");
523        let db = Self::get_database_connection().await;
524        Migrator::fresh(&db)
525            .await
526            .expect("Failed to refresh database");
527        println!("Database refreshed successfully!");
528    }
529
530    async fn run_scheduler_daemon_internal(bootstrap_fn: Option<BootstrapFn>) {
531        // Run bootstrap for scheduler context
532        if let Some(bootstrap_fn) = bootstrap_fn {
533            bootstrap_fn().await;
534        }
535
536        println!("==============================================");
537        println!("  Ferro Scheduler Daemon");
538        println!("==============================================");
539        println!();
540        println!("  Note: Create tasks with `ferro make:task <name>`");
541        println!("  Press Ctrl+C to stop");
542        println!();
543        println!("==============================================");
544
545        eprintln!("Scheduler daemon is not yet configured.");
546        eprintln!("Create a scheduled task with: ferro make:task <name>");
547        eprintln!("Then register it in src/schedule.rs");
548    }
549
550    async fn run_scheduled_tasks_internal(bootstrap_fn: Option<BootstrapFn>) {
551        // Run bootstrap for scheduler context
552        if let Some(bootstrap_fn) = bootstrap_fn {
553            bootstrap_fn().await;
554        }
555
556        println!("Running scheduled tasks...");
557        eprintln!("Scheduler is not yet configured.");
558        eprintln!("Create a scheduled task with: ferro make:task <name>");
559    }
560
561    async fn list_scheduled_tasks() {
562        println!("Registered scheduled tasks:");
563        println!();
564        eprintln!("No scheduled tasks registered.");
565        eprintln!("Create a scheduled task with: ferro make:task <name>");
566    }
567}