Skip to main content

purwa_core/
config.rs

1//! Typed configuration from `purwa.toml`, merged with environment variables.
2//!
3//! # Resolution order
4//!
5//! 1. Optional `purwa.toml` (or an explicit path from [`AppConfig::load_with_file`]).
6//! 2. Environment variables with prefix `PURWA` and nested keys separated by `__`
7//!    (e.g. `PURWA_SERVER__PORT=8080`, `PURWA_DATABASE__URL=postgres://...`).
8//!
9//! After load, [`AppConfig::database_url`] also checks `DATABASE_URL` (no prefix) when
10//! `[database].url` is unset. Inertia asset versioning uses [`InertiaSection`] / `PURWA_INERTIA__*`.
11//!
12//! `dotenvy::dotenv()` runs from [`AppConfig::load`] / [`AppConfig::load_with_file`] so a project
13//! `.env` is loaded when present (missing file is ignored).
14//!
15//! # Router state
16//!
17//! Use [`crate::AppState`] with Axum `State` and `axum::extract::FromRef` for sub-state extraction
18//! (see `purwa-core` integration tests).
19
20use std::path::Path;
21use std::sync::Arc;
22
23use config::{Config, Environment, File};
24use serde::Deserialize;
25use thiserror::Error;
26
27/// Errors while loading or deserializing configuration.
28#[derive(Debug, Error)]
29pub enum PurwaConfigError {
30    #[error(transparent)]
31    Config(#[from] config::ConfigError),
32}
33
34/// Top-level `[app]` section in `purwa.toml`.
35#[derive(Debug, Clone, Deserialize)]
36#[serde(default)]
37pub struct AppSection {
38    /// Application display name.
39    pub name: String,
40}
41
42impl Default for AppSection {
43    fn default() -> Self {
44        Self {
45            name: "purwa-app".to_string(),
46        }
47    }
48}
49
50/// Top-level `[server]` section in `purwa.toml`.
51#[derive(Debug, Clone, Deserialize)]
52#[serde(default)]
53pub struct ServerSection {
54    pub host: String,
55    pub port: u16,
56}
57
58impl Default for ServerSection {
59    fn default() -> Self {
60        Self {
61            host: "0.0.0.0".to_string(),
62            port: 3000,
63        }
64    }
65}
66
67/// Top-level `[database]` section in `purwa.toml`.
68#[derive(Debug, Clone, Default, Deserialize)]
69#[serde(default)]
70pub struct DatabaseSection {
71    /// Postgres connection URL (optional if `DATABASE_URL` is set at runtime).
72    pub url: Option<String>,
73}
74
75/// Top-level `[queue]` section in `purwa.toml` (Phase 2).
76#[derive(Debug, Clone, Deserialize)]
77#[serde(default)]
78pub struct QueueSection {
79    /// Redis connection URL (optional if `REDIS_URL` is set at runtime).
80    pub redis_url: Option<String>,
81    /// Queue name used for key prefixes.
82    pub name: String,
83}
84
85impl Default for QueueSection {
86    fn default() -> Self {
87        Self {
88            redis_url: None,
89            name: "default".to_string(),
90        }
91    }
92}
93
94/// Top-level `[inertia]` section — asset versioning for Inertia.js (Sprint 6).
95#[derive(Debug, Clone, Deserialize)]
96#[serde(default)]
97pub struct InertiaSection {
98    /// Bumped when frontend assets change; compared to `X-Inertia-Version` on Inertia requests.
99    pub asset_version: String,
100}
101
102impl Default for InertiaSection {
103    fn default() -> Self {
104        Self {
105            asset_version: "1".to_string(),
106        }
107    }
108}
109
110/// Framework configuration: `purwa.toml` + env (`PURWA_*`).
111#[derive(Debug, Clone, Default, Deserialize)]
112#[serde(default)]
113pub struct AppConfig {
114    pub app: AppSection,
115    pub server: ServerSection,
116    pub database: DatabaseSection,
117    pub queue: QueueSection,
118    pub inertia: InertiaSection,
119}
120
121impl AppConfig {
122    /// Load using default discovery: optional `./purwa.toml` (via `config` file name `purwa`) + env.
123    pub fn load() -> Result<Arc<Self>, PurwaConfigError> {
124        Self::load_with_file(None)
125    }
126
127    /// Load from an explicit `purwa.toml` path, or when `None` use `File::with_name("purwa")` in the process CWD.
128    pub fn load_with_file(purwa_toml: Option<&Path>) -> Result<Arc<Self>, PurwaConfigError> {
129        dotenvy::dotenv().ok();
130        let mut builder = Config::builder();
131        match purwa_toml {
132            Some(path) => {
133                builder = builder.add_source(File::from(path).required(true));
134            }
135            None => {
136                builder = builder.add_source(File::with_name("purwa").required(false));
137            }
138        }
139        builder = builder.add_source(
140            Environment::with_prefix("PURWA")
141                .separator("__")
142                .try_parsing(true),
143        );
144        let cfg = builder.build()?;
145        let app: AppConfig = cfg.try_deserialize()?;
146        Ok(Arc::new(app))
147    }
148
149    /// Resolved database connection URL for SQLx / `PgPool`.
150    ///
151    /// Order: `[database].url` from config (file + `PURWA_DATABASE__URL`), then `DATABASE_URL`.
152    pub fn database_url(&self) -> Option<String> {
153        if let Some(ref u) = self.database.url {
154            let t = u.trim();
155            if !t.is_empty() {
156                return Some(t.to_string());
157            }
158        }
159        std::env::var("DATABASE_URL")
160            .ok()
161            .map(|s| s.trim().to_string())
162            .filter(|s| !s.is_empty())
163    }
164
165    /// Resolved Redis connection URL for queue workers.
166    ///
167    /// Order: `[queue].redis_url` from config (file + `PURWA_QUEUE__REDIS_URL`), then `REDIS_URL`.
168    pub fn queue_redis_url(&self) -> Option<String> {
169        if let Some(ref u) = self.queue.redis_url {
170            let t = u.trim();
171            if !t.is_empty() {
172                return Some(t.to_string());
173            }
174        }
175        std::env::var("REDIS_URL")
176            .ok()
177            .map(|s| s.trim().to_string())
178            .filter(|s| !s.is_empty())
179    }
180}