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    /// Tenancy isolation configuration
77    #[serde(default)]
78    pub tenancy:     security::TenancyTomlConfig,
79}
80
81impl Default for FraiseQLSettings {
82    fn default() -> Self {
83        Self {
84            schema_file: "schema.json".to_string(),
85            output_file: "schema.compiled.json".to_string(),
86            security:    SecurityConfig::default(),
87            tenancy:     security::TenancyTomlConfig::default(),
88        }
89    }
90}
91
92impl TomlProjectConfig {
93    /// Load configuration from fraiseql.toml file.
94    ///
95    /// Supports `${VAR}` environment variable interpolation throughout the file.
96    ///
97    /// # Errors
98    ///
99    /// Returns an error if the file does not exist, cannot be read, or cannot be
100    /// parsed as valid TOML matching the `TomlProjectConfig` structure.
101    pub fn from_file(path: &str) -> Result<Self> {
102        info!("Loading configuration from {path}");
103
104        let path = Path::new(path);
105        if !path.exists() {
106            anyhow::bail!("Configuration file not found: {}", path.display());
107        }
108
109        let raw = std::fs::read_to_string(path).context("Failed to read fraiseql.toml")?;
110        let toml_content = expand_env_vars(&raw)?;
111
112        let config: TomlProjectConfig = toml::from_str(&toml_content)
113            .map_err(|e| anyhow::anyhow!("Failed to parse fraiseql.toml: {e}"))?;
114
115        Ok(config)
116    }
117
118    /// Validate configuration.
119    ///
120    /// # Errors
121    ///
122    /// Returns an error if any security, server, or database configuration value
123    /// is invalid (e.g. unsupported algorithm, zero window, or bad port range).
124    pub fn validate(&self) -> Result<()> {
125        info!("Validating configuration");
126        self.fraiseql.security.validate()?;
127        self.fraiseql.tenancy.validate()?;
128        self.server.validate()?;
129        self.database.validate()?;
130        Ok(())
131    }
132}
133
134/// Expand `${VAR}` environment variable placeholders in a string.
135///
136/// Unknown variables are left as-is (no panic, silent passthrough).
137#[allow(clippy::expect_used)] // Reason: regex pattern is a compile-time constant guaranteed to be valid
138pub(crate) fn expand_env_vars(content: &str) -> Result<String> {
139    use std::sync::LazyLock;
140
141    static ENV_VAR_REGEX: LazyLock<regex::Regex> = LazyLock::new(|| {
142        regex::Regex::new(r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}").expect("env var regex is valid")
143    });
144
145    let mut result = String::with_capacity(content.len());
146    let mut last_end = 0;
147
148    for cap in ENV_VAR_REGEX.captures_iter(content) {
149        // INVARIANT: Regex captures iterator yields Captures where group 0 (the full match) is
150        // always present
151        let m = cap.get(0).expect("INVARIANT: Regex captures group 0 is always present");
152        result.push_str(&content[last_end..m.start()]);
153        let var_name = &cap[1];
154        match std::env::var(var_name) {
155            Ok(val) => {
156                validate_env_var_value(var_name, &val)?;
157                result.push_str(&val);
158            },
159            Err(_) => {
160                result.push_str(&format!("${{{}}}", var_name));
161            },
162        }
163        last_end = m.end();
164    }
165    result.push_str(&content[last_end..]);
166    Ok(result)
167}
168
169fn validate_env_var_value(var_name: &str, value: &str) -> Result<()> {
170    if value.contains('\n') {
171        anyhow::bail!("Environment variable {} contains newline character", var_name);
172    }
173    if value.contains('\r') {
174        anyhow::bail!("Environment variable {} contains carriage return character", var_name);
175    }
176    if value.contains('\0') {
177        anyhow::bail!("Environment variable {} contains null character", var_name);
178    }
179    // Check for unescaped TOML metacharacters: ", ', \, ], [, {, }
180    if value.contains('"')
181        || value.contains('\'')
182        || value.contains('\\')
183        || value.contains(']')
184        || value.contains('[')
185        || value.contains('{')
186        || value.contains('}')
187    {
188        anyhow::bail!(
189            "Environment variable {} contains TOML metacharacter that could break TOML parsing",
190            var_name
191        );
192    }
193    Ok(())
194}
195
196#[cfg(test)]
197mod tests;