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        // Get router
409        let router = if let Some(routes_fn) = routes_fn {
410            routes_fn()
411        } else {
412            Router::new()
413        };
414
415        // Create server with configuration from environment
416        if let Err(e) = Server::from_config(router).run().await {
417            eprintln!("Failed to start server: {e}");
418            std::process::exit(1);
419        }
420    }
421
422    async fn get_database_connection() -> sea_orm::DatabaseConnection {
423        let database_url = env::var("DATABASE_URL").expect("DATABASE_URL must be set");
424
425        // For SQLite, ensure the database file can be created
426        let database_url = if database_url.starts_with("sqlite://") {
427            let path = database_url.trim_start_matches("sqlite://");
428            let path = path.trim_start_matches("./");
429
430            if let Some(parent) = Path::new(path).parent() {
431                if !parent.as_os_str().is_empty() {
432                    std::fs::create_dir_all(parent).ok();
433                }
434            }
435
436            if !Path::new(path).exists() {
437                std::fs::File::create(path).ok();
438            }
439
440            format!("sqlite:{path}?mode=rwc")
441        } else {
442            database_url
443        };
444
445        sea_orm::Database::connect(&database_url)
446            .await
447            .expect("Failed to connect to database")
448    }
449
450    /// Run migrations during server boot without success logging.
451    ///
452    /// "Silent" refers only to the success path (no progress logs that would
453    /// interleave with server startup). On failure this method writes to stderr
454    /// and aborts the process to prevent the server from accepting traffic with
455    /// a stale schema.
456    async fn run_migrations_silent<Migrator: MigratorTrait>() {
457        let db = Self::get_database_connection().await;
458        if let Err(e) = Migrator::up(&db, None).await {
459            eprintln!("Migration failed: {e}");
460            std::process::exit(1);
461        }
462    }
463
464    async fn run_migrations<Migrator: MigratorTrait>() {
465        println!("Running migrations...");
466        let db = Self::get_database_connection().await;
467        Migrator::up(&db, None)
468            .await
469            .expect("Failed to run migrations");
470        println!("Migrations completed successfully!");
471    }
472
473    async fn show_migration_status<Migrator: MigratorTrait>() {
474        println!("Migration status:");
475        let db = Self::get_database_connection().await;
476        Migrator::status(&db)
477            .await
478            .expect("Failed to get migration status");
479    }
480
481    async fn rollback_migrations<Migrator: MigratorTrait>(steps: u32) {
482        println!("Rolling back {steps} migration(s)...");
483        let db = Self::get_database_connection().await;
484        Migrator::down(&db, Some(steps))
485            .await
486            .expect("Failed to rollback migrations");
487        println!("Rollback completed successfully!");
488    }
489
490    async fn fresh_migrations<Migrator: MigratorTrait>() {
491        println!("WARNING: Dropping all tables and re-running migrations...");
492        let db = Self::get_database_connection().await;
493        Migrator::fresh(&db)
494            .await
495            .expect("Failed to refresh database");
496        println!("Database refreshed successfully!");
497    }
498
499    async fn run_scheduler_daemon_internal(bootstrap_fn: Option<BootstrapFn>) {
500        // Run bootstrap for scheduler context
501        if let Some(bootstrap_fn) = bootstrap_fn {
502            bootstrap_fn().await;
503        }
504
505        println!("==============================================");
506        println!("  Ferro Scheduler Daemon");
507        println!("==============================================");
508        println!();
509        println!("  Note: Create tasks with `ferro make:task <name>`");
510        println!("  Press Ctrl+C to stop");
511        println!();
512        println!("==============================================");
513
514        eprintln!("Scheduler daemon is not yet configured.");
515        eprintln!("Create a scheduled task with: ferro make:task <name>");
516        eprintln!("Then register it in src/schedule.rs");
517    }
518
519    async fn run_scheduled_tasks_internal(bootstrap_fn: Option<BootstrapFn>) {
520        // Run bootstrap for scheduler context
521        if let Some(bootstrap_fn) = bootstrap_fn {
522            bootstrap_fn().await;
523        }
524
525        println!("Running scheduled tasks...");
526        eprintln!("Scheduler is not yet configured.");
527        eprintln!("Create a scheduled task with: ferro make:task <name>");
528    }
529
530    async fn list_scheduled_tasks() {
531        println!("Registered scheduled tasks:");
532        println!();
533        eprintln!("No scheduled tasks registered.");
534        eprintln!("Create a scheduled task with: ferro make:task <name>");
535    }
536}