Skip to main content

fraiseql_cli/config/
mod.rs

1//! Configuration loading and management
2//!
3//! This module handles loading configuration from fraiseql.toml files,
4//! including security settings, project metadata, and compilation options.
5
6pub mod runtime;
7pub mod security;
8pub mod toml_schema;
9
10use std::path::Path;
11
12use anyhow::{Context, Result};
13pub use runtime::{DatabaseRuntimeConfig, ServerRuntimeConfig};
14pub use security::SecurityConfig;
15use serde::{Deserialize, Serialize};
16pub use toml_schema::TomlSchema;
17use tracing::info;
18
19/// Project configuration from fraiseql.toml
20#[derive(Debug, Clone, Default, Deserialize, Serialize)]
21#[serde(default, deny_unknown_fields)]
22pub struct TomlProjectConfig {
23    /// Project metadata (name, version, description)
24    #[serde(rename = "project")]
25    pub project: ProjectConfig,
26
27    /// FraiseQL-specific settings
28    #[serde(rename = "fraiseql")]
29    pub fraiseql: FraiseQLSettings,
30
31    /// HTTP server runtime configuration (optional — all fields have defaults).
32    #[serde(default)]
33    pub server: ServerRuntimeConfig,
34
35    /// Database connection pool configuration (optional — all fields have defaults).
36    #[serde(default)]
37    pub database: DatabaseRuntimeConfig,
38}
39
40/// Project metadata
41#[derive(Debug, Clone, Deserialize, Serialize)]
42#[serde(default, deny_unknown_fields)]
43pub struct ProjectConfig {
44    /// Project name
45    pub name:            String,
46    /// Project version
47    pub version:         String,
48    /// Optional project description
49    pub description:     Option<String>,
50    /// Target database backend (e.g. "postgresql", "mysql", "sqlite", "sqlserver")
51    pub database_target: Option<String>,
52}
53
54impl Default for ProjectConfig {
55    fn default() -> Self {
56        Self {
57            name:            "my-fraiseql-app".to_string(),
58            version:         "1.0.0".to_string(),
59            description:     None,
60            database_target: None,
61        }
62    }
63}
64
65/// FraiseQL-specific settings
66#[derive(Debug, Clone, Deserialize, Serialize)]
67#[serde(default, deny_unknown_fields)]
68pub struct FraiseQLSettings {
69    /// Path to the GraphQL schema file
70    pub schema_file: String,
71    /// Path to the output compiled schema file
72    pub output_file: String,
73    /// Security configuration
74    #[serde(rename = "security")]
75    pub security:    SecurityConfig,
76}
77
78impl Default for FraiseQLSettings {
79    fn default() -> Self {
80        Self {
81            schema_file: "schema.json".to_string(),
82            output_file: "schema.compiled.json".to_string(),
83            security:    SecurityConfig::default(),
84        }
85    }
86}
87
88impl TomlProjectConfig {
89    /// Load configuration from fraiseql.toml file.
90    ///
91    /// Supports `${VAR}` environment variable interpolation throughout the file.
92    ///
93    /// # Errors
94    ///
95    /// Returns an error if the file does not exist, cannot be read, or cannot be
96    /// parsed as valid TOML matching the `TomlProjectConfig` structure.
97    pub fn from_file(path: &str) -> Result<Self> {
98        info!("Loading configuration from {path}");
99
100        let path = Path::new(path);
101        if !path.exists() {
102            anyhow::bail!("Configuration file not found: {}", path.display());
103        }
104
105        let raw = std::fs::read_to_string(path).context("Failed to read fraiseql.toml")?;
106        let toml_content = expand_env_vars(&raw);
107
108        let config: TomlProjectConfig = toml::from_str(&toml_content)
109            .map_err(|e| anyhow::anyhow!("Failed to parse fraiseql.toml: {e}"))?;
110
111        Ok(config)
112    }
113
114    /// Validate configuration.
115    ///
116    /// # Errors
117    ///
118    /// Returns an error if any security, server, or database configuration value
119    /// is invalid (e.g. unsupported algorithm, zero window, or bad port range).
120    pub fn validate(&self) -> Result<()> {
121        info!("Validating configuration");
122        self.fraiseql.security.validate()?;
123        self.server.validate()?;
124        self.database.validate()?;
125        Ok(())
126    }
127}
128
129/// Expand `${VAR}` environment variable placeholders in a string.
130///
131/// Unknown variables are left as-is (no panic, silent passthrough).
132#[allow(clippy::expect_used)] // Reason: regex pattern is a compile-time constant guaranteed to be valid
133pub(crate) fn expand_env_vars(content: &str) -> String {
134    use std::sync::LazyLock;
135
136    static ENV_VAR_REGEX: LazyLock<regex::Regex> = LazyLock::new(|| {
137        regex::Regex::new(r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}").expect("env var regex is valid")
138    });
139
140    ENV_VAR_REGEX
141        .replace_all(content, |caps: &regex::Captures| {
142            std::env::var(&caps[1]).unwrap_or_else(|_| format!("${{{}}}", &caps[1]))
143        })
144        .into_owned()
145}
146
147#[cfg(test)]
148mod tests {
149    use super::*;
150
151    #[test]
152    fn test_default_config() {
153        let config = TomlProjectConfig::default();
154        assert_eq!(config.project.name, "my-fraiseql-app");
155        assert_eq!(config.fraiseql.schema_file, "schema.json");
156    }
157
158    #[test]
159    fn test_default_security_config() {
160        let config = TomlProjectConfig::default();
161        assert!(config.fraiseql.security.audit_logging.enabled);
162        assert!(config.fraiseql.security.rate_limiting.enabled);
163    }
164
165    #[test]
166    fn test_validation() {
167        let config = TomlProjectConfig::default();
168        config.validate().unwrap_or_else(|e| panic!("expected Ok from validate: {e:?}"));
169    }
170
171    #[test]
172    fn test_role_definitions_default() {
173        let config = TomlProjectConfig::default();
174        assert!(config.fraiseql.security.role_definitions.is_empty());
175        assert!(config.fraiseql.security.default_role.is_none());
176    }
177
178    #[test]
179    fn test_parse_role_definitions_from_toml() {
180        let toml_str = r#"
181[project]
182name = "test-app"
183
184[fraiseql]
185schema_file = "schema.json"
186
187[[fraiseql.security.role_definitions]]
188name = "viewer"
189description = "Read-only access"
190scopes = ["read:*"]
191
192[[fraiseql.security.role_definitions]]
193name = "admin"
194description = "Full access"
195scopes = ["admin:*"]
196
197[fraiseql.security]
198default_role = "viewer"
199"#;
200
201        let config: TomlProjectConfig = toml::from_str(toml_str).expect("Failed to parse TOML");
202
203        assert_eq!(config.fraiseql.security.role_definitions.len(), 2);
204        assert_eq!(config.fraiseql.security.role_definitions[0].name, "viewer");
205        assert_eq!(config.fraiseql.security.role_definitions[0].scopes[0], "read:*");
206        assert_eq!(config.fraiseql.security.role_definitions[1].name, "admin");
207        assert_eq!(config.fraiseql.security.default_role, Some("viewer".to_string()));
208    }
209
210    #[test]
211    fn test_security_config_validation_empty_role_name() {
212        let toml_str = r#"
213[[fraiseql.security.role_definitions]]
214name = ""
215scopes = ["read:*"]
216"#;
217
218        let config: TomlProjectConfig = toml::from_str(toml_str).expect("Failed to parse TOML");
219        assert!(config.validate().is_err(), "Should fail with empty role name");
220    }
221
222    #[test]
223    fn test_security_config_validation_empty_scopes() {
224        let toml_str = r#"
225[[fraiseql.security.role_definitions]]
226name = "viewer"
227scopes = []
228"#;
229
230        let config: TomlProjectConfig = toml::from_str(toml_str).expect("Failed to parse TOML");
231        assert!(config.validate().is_err(), "Should fail with empty scopes");
232    }
233
234    #[test]
235    fn test_fraiseql_config_parses_server_section() {
236        let toml_str = r#"
237[server]
238host = "127.0.0.1"
239port = 9000
240
241[server.cors]
242origins = ["https://example.com"]
243"#;
244        let config: TomlProjectConfig = toml::from_str(toml_str).expect("Failed to parse TOML");
245        assert_eq!(config.server.host, "127.0.0.1");
246        assert_eq!(config.server.port, 9000);
247        assert_eq!(config.server.cors.origins, ["https://example.com"]);
248    }
249
250    #[test]
251    fn test_fraiseql_config_parses_database_section() {
252        let toml_str = r#"
253[database]
254url      = "postgresql://localhost/testdb"
255pool_min = 3
256pool_max = 15
257ssl_mode = "require"
258"#;
259        let config: TomlProjectConfig = toml::from_str(toml_str).expect("Failed to parse TOML");
260        assert_eq!(config.database.url, Some("postgresql://localhost/testdb".to_string()));
261        assert_eq!(config.database.pool_min, 3);
262        assert_eq!(config.database.pool_max, 15);
263        assert_eq!(config.database.ssl_mode, "require");
264    }
265
266    #[test]
267    fn test_env_var_expansion_in_fraiseql_config() {
268        temp_env::with_var("TEST_DB_URL", Some("postgres://test/db"), || {
269            let toml_str = r#"
270[database]
271url = "${TEST_DB_URL}"
272"#;
273            let expanded = expand_env_vars(toml_str);
274            let config: TomlProjectConfig =
275                toml::from_str(&expanded).expect("Failed to parse TOML");
276            assert_eq!(config.database.url, Some("postgres://test/db".to_string()));
277        });
278    }
279
280    #[test]
281    fn test_env_var_expansion_unknown_var_passthrough() {
282        // Unknown variables should be left as-is, not panic
283        let toml_str = r#"url = "${NONEXISTENT_VAR_XYZ123}""#;
284        let expanded = expand_env_vars(toml_str);
285        assert_eq!(expanded, toml_str, "Unknown vars must be left unchanged");
286    }
287
288    #[test]
289    fn test_env_var_expansion_multiple_occurrences() {
290        temp_env::with_var("FRAISEQL_TEST_HOST", Some("db.example.com"), || {
291            let toml_str = r#"primary = "${FRAISEQL_TEST_HOST}" replica = "${FRAISEQL_TEST_HOST}""#;
292            let expanded = expand_env_vars(toml_str);
293            assert_eq!(expanded, r#"primary = "db.example.com" replica = "db.example.com""#);
294        });
295    }
296}