use std::path::PathBuf; use std::sync::Arc; use std::time::Duration; use clap::Parser; use notify::{Config, RecommendedWatcher, RecursiveMode, Watcher}; use tokio::sync::RwLock; use axum::http::header::HeaderValue; use tower_http::cors::{AllowOrigin, CorsLayer}; use tower_http::trace::{DefaultOnResponse, TraceLayer}; use tracing::{info_span, Level}; use tracing_subscriber::EnvFilter; use rustycms::api::cache::ContentCache; use rustycms::api::handlers::AppState; use rustycms::referrers::{Referrer, ReferrerIndex}; use rustycms::schema::validator; use rustycms::schema::SchemaRegistry; use rustycms::store::{filesystem::FileStore, sqlite::SqliteStore, ContentStore}; #[derive(Parser)] #[command(name = "rustycms", about = "A file-based headless CMS written in Rust")] struct Cli { /// Path to the directory containing type definitions (*.json5) #[arg(long, default_value = "./types", env = "RUSTYCMS_TYPES_DIR")] types_dir: PathBuf, /// Path to the directory containing content files #[arg(long, default_value = "./content", env = "RUSTYCMS_CONTENT_DIR")] content_dir: PathBuf, /// Port to listen on #[arg(short = 'p', long, default_value_t = 3000)] port: u16, /// Host address to bind to #[arg(long, default_value = "127.0.0.1")] host: String, } /// Auto-detect locale subdirectories in the content dir. /// Matches 2-3 letter directory names (e.g. "de", "en", "fra") that contain at /// least one subdirectory themselves, ignoring known non-locale dirs like "assets". fn detect_locales(content_dir: &std::path::Path) -> Option> { let entries = std::fs::read_dir(content_dir).ok()?; let mut locales: Vec = entries .filter_map(|e| e.ok()) .filter(|e| e.file_type().map(|ft| ft.is_dir()).unwrap_or(false)) .filter_map(|e| { let name = e.file_name().to_string_lossy().to_string(); if name == "assets" || name.starts_with('.') { return None; } let is_locale = (2..=3).contains(&name.len()) && name.chars().all(|c| c.is_ascii_lowercase()); if !is_locale { return None; } let has_subdirs = std::fs::read_dir(e.path()) .ok() .map(|rd| rd.filter_map(|e| e.ok()).any(|e| e.file_type().map(|ft| ft.is_dir()).unwrap_or(false))) .unwrap_or(false); if has_subdirs { Some(name) } else { None } }) .collect(); if locales.is_empty() { return None; } locales.sort(); tracing::info!("Auto-detected locales from content directory: {:?}", locales); Some(locales) } fn reload_schemas( rt_handle: tokio::runtime::Handle, types_dir: PathBuf, server_url: String, registry: Arc>, openapi_spec: Arc>, cache: Arc, ) { rt_handle.spawn(async move { match SchemaRegistry::load(&types_dir) { Ok(new_registry) => { let spec = rustycms::api::openapi::generate_spec(&new_registry, &server_url); *registry.write().await = new_registry; *openapi_spec.write().await = spec; cache.invalidate_all().await; tracing::info!("Hot-reload: schemas and OpenAPI spec updated, content cache cleared"); } Err(e) => { tracing::error!("Hot-reload failed: {}", e); } } }); } #[tokio::main] async fn main() -> anyhow::Result<()> { dotenvy::dotenv().ok(); tracing_subscriber::fmt() .with_env_filter( EnvFilter::try_from_default_env() .unwrap_or_else(|_| EnvFilter::new("rustycms=info,tower_http=info")), ) .init(); let cli = Cli::parse(); tracing::info!("Loading schemas from {}", cli.types_dir.display()); let registry = SchemaRegistry::load(&cli.types_dir)?; tracing::info!( "Loaded {} schema(s): {:?}", registry.names().len(), registry.names() ); let store: std::sync::Arc = { let kind = std::env::var("RUSTYCMS_STORE").unwrap_or_else(|_| "file".into()); match kind.as_str() { "sqlite" => { let url = std::env::var("RUSTYCMS_DATABASE_URL") .or_else(|_| std::env::var("DATABASE_URL")) .unwrap_or_else(|_| "sqlite:content.db".into()); tracing::info!("Using SQLite store: {}", url); let s = SqliteStore::new(&url).await?; std::sync::Arc::new(s) } _ => { tracing::info!("Using file store: {}", cli.content_dir.display()); let s = FileStore::new(&cli.content_dir); std::sync::Arc::new(s) } } }; let server_url = format!("http://{}:{}", cli.host, cli.port); let openapi_spec = rustycms::api::openapi::generate_spec(®istry, &server_url); tracing::info!("OpenAPI spec generated"); let registry = Arc::new(RwLock::new(registry)); let openapi_spec = Arc::new(RwLock::new(openapi_spec)); let api_keys = rustycms::api::auth::ApiKeys::from_env(); if api_keys.as_ref().map(|k| k.is_enabled()).unwrap_or(false) { tracing::info!("API key auth enabled (write operations require key with role)"); } let cache_ttl_secs = std::env::var("RUSTYCMS_CACHE_TTL_SECS") .ok() .and_then(|s| s.parse().ok()) .unwrap_or(60); let cache = Arc::new(rustycms::api::cache::ContentCache::new(cache_ttl_secs)); let transform_cache = Arc::new(rustycms::api::cache::TransformCache::new(cache_ttl_secs)); if cache_ttl_secs > 0 { tracing::info!("Response cache enabled, TTL {}s", cache_ttl_secs); } let http_client = reqwest::Client::new(); let locales: Option> = std::env::var("RUSTYCMS_LOCALES") .ok() .map(|s| s.split(',').map(|l| l.trim().to_string()).filter(|l| !l.is_empty()).collect()) .filter(|v: &Vec| !v.is_empty()) .or_else(|| detect_locales(&cli.content_dir)); if let Some(ref locs) = locales { tracing::info!("Multilingual: locales {:?} (default: {})", locs, &locs[0]); } let assets_dir = cli.content_dir.join("assets"); // RUSTYCMS_ENVIRONMENTS: comma-separated list (e.g. production,staging). File store only. // Content for first env = content_dir; others = content_dir/. Assets = content_dir/assets or content_dir//assets. let store_kind = std::env::var("RUSTYCMS_STORE").unwrap_or_else(|_| "file".into()); let (environments, stores_map, assets_dirs_map, store, assets_dir) = match std::env::var("RUSTYCMS_ENVIRONMENTS").ok().as_deref() { Some(s) if !s.trim().is_empty() && store_kind != "sqlite" => { let env_list: Vec = s.split(',').map(|e| e.trim().to_string()).filter(|e| !e.is_empty()).collect(); if env_list.is_empty() { (None, None, None, store, assets_dir) } else { let mut stores = std::collections::HashMap::new(); let mut assets_dirs = std::collections::HashMap::new(); for (i, name) in env_list.iter().enumerate() { let content_base = if i == 0 { cli.content_dir.clone() } else { cli.content_dir.join(name) }; let assets_path = if i == 0 { cli.content_dir.join("assets") } else { cli.content_dir.join(name).join("assets") }; let s = FileStore::new(&content_base); stores.insert(name.clone(), Arc::new(s) as Arc); assets_dirs.insert(name.clone(), assets_path); } let default_store = stores.get(&env_list[0]).cloned().unwrap(); let default_assets = assets_dirs.get(&env_list[0]).cloned().unwrap(); tracing::info!("Environments enabled: {:?} (default: {})", env_list, &env_list[0]); (Some(env_list), Some(stores), Some(assets_dirs), default_store, default_assets) } } _ => { if std::env::var("RUSTYCMS_ENVIRONMENTS").is_ok() && store_kind == "sqlite" { tracing::warn!("RUSTYCMS_ENVIRONMENTS is ignored when using SQLite store"); } (None, None, None, store, assets_dir) } }; // RUSTYCMS_BASE_URL is the public URL of the API (e.g. https://api.example.com). // Used to expand relative /api/assets/ paths to absolute URLs in responses. // Falls back to the local server_url (http://host:port). let base_url = std::env::var("RUSTYCMS_BASE_URL").unwrap_or_else(|_| server_url.clone()); let webhook_urls = rustycms::api::webhooks::urls_from_env(); if !webhook_urls.is_empty() { tracing::info!("Webhooks enabled: {} URL(s)", webhook_urls.len()); } // Reverse referrer index (file-based in content dir). Only when not using environments (single content root). // When the index file is missing, run a full reindex over all collections and save. let (referrer_index, referrer_index_path) = if environments.is_none() { let path = assets_dir.parent().unwrap().join("_referrers.json"); let index = if path.exists() { ReferrerIndex::load(&path) } else { tracing::info!("Referrer index not found, building full index from content…"); let mut index = ReferrerIndex::new(); let collections_with_schema: Vec<(String, rustycms::schema::types::SchemaDefinition)> = { let guard = registry.read().await; guard .collection_names() .into_iter() .filter_map(|c| { guard.get(&c).filter(|s| !s.reusable).map(|s| (c, s.clone())) }) .collect() }; for (collection, schema) in collections_with_schema { let locale_opts: Vec> = locales .as_ref() .map(|l| l.iter().map(|s| s.as_str()).map(Some).collect()) .unwrap_or_else(|| vec![None]); for locale_ref in locale_opts { match store.list(&collection, locale_ref).await { Ok(entries) => { for (slug, value) in entries { let refs = validator::extract_references(&schema, &value); let referrer = Referrer { collection: collection.clone(), slug: slug.clone(), field: String::new(), locale: locale_ref.map(str::to_string), }; for (ref_coll, ref_slug, field) in refs { let mut r = referrer.clone(); r.field = field; index.add_referrer(&ref_coll, &ref_slug, r); } } } Err(e) => tracing::warn!("List {} (locale {:?}) failed: {}", collection, locale_ref, e), } } } if let Err(e) = index.save(&path) { tracing::warn!("Failed to save referrer index: {}", e); } else { tracing::info!("Referrer index saved to {}", path.display()); } index }; if path.exists() { tracing::info!("Referrer index loaded from {}", path.display()); } (Some(Arc::new(RwLock::new(index))), Some(path)) } else { (None, None) }; let state = Arc::new(AppState { registry: Arc::clone(®istry), store, openapi_spec: Arc::clone(&openapi_spec), types_dir: cli.types_dir.clone(), api_keys, cache: Arc::clone(&cache), transform_cache, http_client, locales, assets_dir, base_url, webhook_urls, environments, stores: stores_map, assets_dirs: assets_dirs_map, referrer_index, referrer_index_path, }); // Hot-reload: watch types_dir and reload schemas on change (run reload on main Tokio runtime from watcher thread) let rt_handle = tokio::runtime::Handle::current(); let types_dir_for_callback = cli.types_dir.canonicalize().unwrap_or_else(|_| cli.types_dir.clone()); let (tx, rx) = std::sync::mpsc::channel(); let mut watcher = RecommendedWatcher::new( move |res: Result| { if let Ok(ev) = res { if ev.kind.is_modify() || ev.kind.is_create() || ev.kind.is_remove() { // Any change under types_dir triggers reload (robust for editors that use temp files) let under_types = ev.paths.iter().any(|p| { p.canonicalize() .map(|c| c.starts_with(&types_dir_for_callback)) .unwrap_or_else(|_| { p.extension() .map(|e| e == "json5" || e == "json") .unwrap_or(false) }) }); if under_types { let _ = tx.send(()); } } } }, Config::default(), )?; watcher.watch(&cli.types_dir, RecursiveMode::Recursive)?; let types_dir_watch = cli.types_dir.clone(); let server_url_watch = server_url.clone(); std::thread::spawn(move || { let _watcher = watcher; while rx.recv().is_ok() { // Debounce: wait for editor to finish writing, drain extra events, then reload once std::thread::sleep(Duration::from_millis(800)); while rx.try_recv().is_ok() {} reload_schemas( rt_handle.clone(), types_dir_watch.clone(), server_url_watch.clone(), Arc::clone(®istry), Arc::clone(&openapi_spec), Arc::clone(&cache), ); } }); tracing::info!("Hot-reload: watching {}", cli.types_dir.display()); let cors = match std::env::var("RUSTYCMS_CORS_ORIGIN") { Ok(s) if s.trim().is_empty() || s.trim() == "*" => CorsLayer::permissive(), Ok(s) => { let o = s.trim().to_string(); match HeaderValue::try_from(o) { Ok(h) => CorsLayer::new().allow_origin(AllowOrigin::exact(h)), Err(_) => CorsLayer::permissive(), } } Err(_) => CorsLayer::permissive(), }; let trace = TraceLayer::new_for_http() .make_span_with(|request: &axum::http::Request| { let method = request.method().as_str(); let uri = request .uri() .path_and_query() .map(|pq| pq.as_str().to_string()) .unwrap_or_else(|| request.uri().path().to_string()); info_span!( "request", method = %method, uri = %uri, ) }) .on_response( DefaultOnResponse::new() .level(Level::INFO) .latency_unit(tower_http::LatencyUnit::Millis), ); let app = rustycms::api::routes::create_router(state) .layer(cors) .layer(trace); let addr = format!("{}:{}", cli.host, cli.port); tracing::info!("RustyCMS v0.1.0 listening on http://{}", addr); tracing::info!("Swagger UI: http://{}/swagger-ui", addr); let listener = tokio::net::TcpListener::bind(&addr).await?; let shutdown = async { #[cfg(unix)] { if let Ok(mut sig) = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) { tokio::select! { _ = tokio::signal::ctrl_c() => {} _ = sig.recv() => {} } } else { tokio::signal::ctrl_c().await.ok(); } } #[cfg(not(unix))] { tokio::signal::ctrl_c().await.ok(); } tracing::info!("Shutdown signal received, draining requests..."); }; axum::serve(listener, app) .with_graceful_shutdown(shutdown) .await?; Ok(()) }