use serde::Deserialize;
use std::path::Path;
use crate::error::{CliError, Result};
#[derive(Debug, Deserialize, Default)]
pub struct GenerateConfig {
pub schema: Option<String>,
pub output: Option<String>,
pub targets: Option<Vec<String>>,
}
#[derive(Debug, Deserialize, Default)]
pub struct TenantConfig {
pub root: Option<String>,
}
impl TenantConfig {
pub fn root(&self) -> std::path::PathBuf {
std::path::PathBuf::from(self.root.as_deref().unwrap_or("data"))
}
}
#[derive(Debug, Deserialize, Default)]
pub struct AuthConfig {
#[serde(default)]
pub enabled: bool,
pub issuer: Option<String>,
pub audience: Option<String>,
pub tenant_claim: Option<String>,
pub jwks_url: Option<String>,
pub jwks_refresh_secs: Option<u64>,
pub public_key_path: Option<String>,
pub algorithms: Option<Vec<String>>,
pub leeway_secs: Option<u64>,
}
impl AuthConfig {
pub fn env_exports(&self) -> Vec<(String, String)> {
if !self.enabled {
return Vec::new();
}
let mut out = Vec::new();
if let Some(path) = &self.public_key_path {
out.push(("FORGEDB_JWT_PUBKEY".to_string(), path.clone()));
}
if let Some(url) = &self.jwks_url {
out.push(("FORGEDB_JWKS_URL".to_string(), url.clone()));
}
if let Some(secs) = self.jwks_refresh_secs {
out.push(("FORGEDB_JWKS_REFRESH_SECS".to_string(), secs.to_string()));
}
if let Some(iss) = &self.issuer {
out.push(("FORGEDB_JWT_ISSUER".to_string(), iss.clone()));
}
if let Some(aud) = &self.audience {
out.push(("FORGEDB_JWT_AUDIENCE".to_string(), aud.clone()));
}
if let Some(claim) = &self.tenant_claim {
out.push(("FORGEDB_TENANT_CLAIM".to_string(), claim.clone()));
}
if let Some(algs) = self.algorithms.as_ref().filter(|a| !a.is_empty()) {
out.push(("FORGEDB_JWT_ALGS".to_string(), algs.join(",")));
}
if let Some(leeway) = self.leeway_secs {
out.push(("FORGEDB_JWT_LEEWAY".to_string(), leeway.to_string()));
}
out
}
}
#[derive(Debug, Deserialize, Default)]
pub struct RuntimeConfig {
#[serde(default)]
pub replication: bool,
pub changefeed_capacity: Option<usize>,
pub max_cascade_depth: Option<u32>,
pub replication_log_retention: Option<u64>,
}
#[derive(Debug, Deserialize, Default)]
pub struct StorageConfig {
pub fsync: Option<String>,
pub wal_checkpoint_interval: Option<u64>,
pub compaction: Option<bool>,
pub compaction_threshold: Option<u64>,
}
#[derive(Debug, Deserialize, Default)]
pub struct TransactionConfig {
pub max_retries: Option<u32>,
}
#[derive(Debug, Deserialize, Default)]
pub struct ServerConfig {
pub page_default_limit: Option<usize>,
pub page_max_limit: Option<usize>,
pub metrics: Option<bool>,
}
#[derive(Debug, Deserialize, Default)]
pub struct WasmConfig {
pub commit_debounce_ms: Option<u64>,
pub commit_max_frames: Option<u64>,
}
#[derive(Debug, Deserialize, Default)]
pub struct ForgeConfig {
#[serde(default)]
pub generate: GenerateConfig,
#[serde(default)]
pub tenant: TenantConfig,
#[serde(default)]
pub auth: AuthConfig,
#[serde(default)]
pub runtime: RuntimeConfig,
#[serde(default)]
pub storage: StorageConfig,
#[serde(default)]
pub transaction: TransactionConfig,
#[serde(default)]
pub server: ServerConfig,
#[serde(default)]
pub wasm: WasmConfig,
}
impl ForgeConfig {
pub fn gen_config(&self) -> Result<forgedb_codegen::GenConfig> {
let d = forgedb_codegen::GenConfig::DEFAULT;
let fsync = match self.storage.fsync.as_deref() {
None => d.fsync,
Some("always") => forgedb_codegen::FsyncMode::Always,
Some("never") => forgedb_codegen::FsyncMode::Never,
Some(other) => {
return Err(CliError::Config(format!(
"invalid [storage].fsync = \"{other}\" — expected \"always\" or \"never\""
)));
}
};
Ok(forgedb_codegen::GenConfig {
replication: self.runtime.replication,
fsync,
wal_checkpoint_interval: self
.storage
.wal_checkpoint_interval
.unwrap_or(d.wal_checkpoint_interval),
compaction: self.storage.compaction.unwrap_or(d.compaction),
compaction_threshold: self
.storage
.compaction_threshold
.unwrap_or(d.compaction_threshold),
changefeed_capacity: self
.runtime
.changefeed_capacity
.unwrap_or(d.changefeed_capacity),
max_cascade_depth: self.runtime.max_cascade_depth.unwrap_or(d.max_cascade_depth),
txn_max_retries: self.transaction.max_retries.unwrap_or(d.txn_max_retries),
page_default_limit: self
.server
.page_default_limit
.unwrap_or(d.page_default_limit),
page_max_limit: self.server.page_max_limit.unwrap_or(d.page_max_limit),
metrics: self.server.metrics.unwrap_or(d.metrics),
wasm_commit_debounce_ms: self
.wasm
.commit_debounce_ms
.unwrap_or(d.wasm_commit_debounce_ms),
wasm_commit_max_frames: self
.wasm
.commit_max_frames
.unwrap_or(d.wasm_commit_max_frames),
replication_log_retention: self
.runtime
.replication_log_retention
.unwrap_or(d.replication_log_retention),
})
}
}
pub fn load_config(path: Option<&str>) -> Result<ForgeConfig> {
match path {
Some(explicit_path) => {
let content = std::fs::read_to_string(explicit_path).map_err(|e| {
CliError::Config(format!(
"Cannot read config file '{}': {}",
explicit_path, e
))
})?;
toml::from_str(&content).map_err(|e| {
CliError::Config(format!(
"Invalid config file '{}': {}",
explicit_path, e
))
})
}
None => {
if Path::new("forgedb.toml").exists() {
let content = std::fs::read_to_string("forgedb.toml")
.map_err(|e| CliError::Config(format!("Cannot read forgedb.toml: {}", e)))?;
toml::from_str(&content)
.map_err(|e| CliError::Config(format!("Invalid forgedb.toml: {}", e)))
} else {
Ok(ForgeConfig::default())
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use forgedb_codegen::{FsyncMode, GenConfig};
#[test]
fn empty_config_maps_to_default_gen_config() {
let cfg: ForgeConfig = toml::from_str("[project]\nname = \"x\"\n").unwrap();
assert_eq!(cfg.gen_config().unwrap(), GenConfig::DEFAULT);
assert!(!cfg.runtime.replication);
}
#[test]
fn runtime_and_storage_knobs_map_through() {
let src = r#"
[runtime]
replication = true
changefeed_capacity = 256
max_cascade_depth = 16
replication_log_retention = 4096
[storage]
fsync = "never"
wal_checkpoint_interval = 500
compaction = false
compaction_threshold = 250
[transaction]
max_retries = 7
[server]
page_default_limit = 25
page_max_limit = 500
metrics = false
[wasm]
commit_debounce_ms = 500
commit_max_frames = 40
"#;
let cfg: ForgeConfig = toml::from_str(src).unwrap();
let gc = cfg.gen_config().unwrap();
assert_eq!(
gc,
GenConfig {
replication: true,
fsync: FsyncMode::Never,
wal_checkpoint_interval: 500,
compaction: false,
compaction_threshold: 250,
changefeed_capacity: 256,
max_cascade_depth: 16,
txn_max_retries: 7,
page_default_limit: 25,
page_max_limit: 500,
metrics: false,
wasm_commit_debounce_ms: 500,
wasm_commit_max_frames: 40,
replication_log_retention: 4096,
}
);
}
#[test]
fn partial_storage_keeps_other_defaults() {
let cfg: ForgeConfig = toml::from_str("[storage]\nfsync = \"always\"\n").unwrap();
let gc = cfg.gen_config().unwrap();
assert_eq!(gc, GenConfig::DEFAULT);
}
#[test]
fn invalid_fsync_is_a_config_error() {
let cfg: ForgeConfig = toml::from_str("[storage]\nfsync = \"sometimes\"\n").unwrap();
let err = cfg.gen_config().unwrap_err();
assert!(matches!(err, CliError::Config(_)), "unknown fsync mode → config error");
}
}