use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};
use toml;
use crate::cli::error::{CliError, CliResult};
pub struct ConfigManager {
config_dir: PathBuf,
active_profile: String,
configs: HashMap<String, OxirsConfig>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct OxirsConfig {
#[serde(default)]
pub general: GeneralConfig,
#[serde(default)]
pub server: ServerConfig,
#[serde(default)]
pub datasets: HashMap<String, DatasetConfig>,
#[serde(default)]
pub tools: ToolsConfig,
#[serde(default)]
pub env: HashMap<String, toml::Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GeneralConfig {
#[serde(default = "default_format")]
pub default_format: String,
#[serde(default)]
pub output_dir: Option<PathBuf>,
#[serde(default = "default_true")]
pub show_progress: bool,
#[serde(default = "default_true")]
pub colored_output: bool,
#[serde(default = "default_timeout")]
pub timeout: u64,
#[serde(default = "default_log_level")]
pub log_level: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ServerConfig {
#[serde(default = "default_host")]
pub host: String,
#[serde(default = "default_port")]
pub port: u16,
#[serde(default)]
pub admin_enabled: bool,
#[serde(default)]
pub cors: CorsConfig,
#[serde(default)]
pub auth: AuthConfig,
#[serde(default)]
pub enable_graphql: bool,
#[serde(default = "default_graphql_path")]
pub graphql_path: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct CorsConfig {
pub enabled: bool,
pub allowed_origins: Vec<String>,
pub allowed_methods: Vec<String>,
pub allowed_headers: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct AuthConfig {
pub enabled: bool,
pub method: Option<String>, pub config: HashMap<String, toml::Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DatasetConfig {
pub dataset_type: String,
pub location: String,
#[serde(default)]
pub read_only: bool,
#[serde(default)]
pub options: HashMap<String, toml::Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ToolsConfig {
#[serde(default)]
pub riot: RiotConfig,
#[serde(default)]
pub query: QueryConfig,
#[serde(default)]
pub tdb: TdbConfig,
#[serde(default)]
pub validation: ValidationConfig,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct RiotConfig {
pub strict_mode: bool,
pub base_uri: Option<String>,
pub pretty_print: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct QueryConfig {
pub timeout: Option<u64>,
pub optimize: bool,
pub explain: bool,
pub result_limit: Option<usize>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct TdbConfig {
pub cache_size: Option<usize>,
pub file_mode: Option<String>,
pub sync_mode: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ValidationConfig {
pub abort_on_error: bool,
pub max_errors: Option<usize>,
pub report_format: Option<String>,
}
impl ConfigManager {
pub fn new() -> CliResult<Self> {
let config_dir = Self::get_config_dir()?;
Ok(Self {
config_dir,
active_profile: "default".to_string(),
configs: HashMap::new(),
})
}
fn get_config_dir() -> CliResult<PathBuf> {
if let Ok(dir) = std::env::var("OXIRS_CONFIG_DIR") {
return Ok(PathBuf::from(dir));
}
dirs::config_dir()
.map(|p| p.join("oxirs"))
.ok_or_else(|| CliError::config_error("Cannot determine config directory"))
}
pub fn load_profile(&mut self, profile: &str) -> CliResult<&OxirsConfig> {
if self.configs.contains_key(profile) {
return Ok(&self.configs[profile]);
}
let config = self.load_config_cascade(profile)?;
self.configs.insert(profile.to_string(), config);
self.active_profile = profile.to_string();
Ok(&self.configs[profile])
}
fn load_config_cascade(&self, profile: &str) -> CliResult<OxirsConfig> {
let mut config = OxirsConfig::default();
let global_path = self.config_dir.join("config.toml");
if global_path.exists() {
let global_config = self.load_config_file(&global_path)?;
config = self.merge_configs(config, global_config);
}
if profile != "default" {
let profile_path = self.config_dir.join(format!("config.{profile}.toml"));
if profile_path.exists() {
let profile_config = self.load_config_file(&profile_path)?;
config = self.merge_configs(config, profile_config);
}
}
config = self.apply_env_overrides(config)?;
Ok(config)
}
fn load_config_file(&self, path: &Path) -> CliResult<OxirsConfig> {
let content = fs::read_to_string(path)
.map_err(|e| CliError::config_error(format!("Cannot read config file: {e}")))?;
toml::from_str(&content)
.map_err(|e| CliError::config_error(format!("Invalid TOML in config file: {e}")))
}
fn merge_configs(&self, mut base: OxirsConfig, overlay: OxirsConfig) -> OxirsConfig {
if overlay.general.default_format != default_format() {
base.general.default_format = overlay.general.default_format;
}
if overlay.general.output_dir.is_some() {
base.general.output_dir = overlay.general.output_dir;
}
if overlay.server.host != default_host() {
base.server.host = overlay.server.host;
}
if overlay.server.port != default_port() {
base.server.port = overlay.server.port;
}
base.datasets.extend(overlay.datasets);
base.tools = overlay.tools;
base
}
fn apply_env_overrides(&self, mut config: OxirsConfig) -> CliResult<OxirsConfig> {
if let Ok(format) = std::env::var("OXIRS_DEFAULT_FORMAT") {
config.general.default_format = format;
}
if let Ok(dir) = std::env::var("OXIRS_OUTPUT_DIR") {
config.general.output_dir = Some(PathBuf::from(dir));
}
if std::env::var("OXIRS_NO_COLOR").is_ok() || std::env::var("NO_COLOR").is_ok() {
config.general.colored_output = false;
}
if let Ok(host) = std::env::var("OXIRS_SERVER_HOST") {
config.server.host = host;
}
if let Ok(port_str) = std::env::var("OXIRS_SERVER_PORT") {
if let Ok(port) = port_str.parse::<u16>() {
config.server.port = port;
}
}
Ok(config)
}
pub fn get_config(&self) -> CliResult<&OxirsConfig> {
self.configs
.get(&self.active_profile)
.ok_or_else(|| CliError::config_error("No configuration loaded"))
}
pub fn save_config(&self, config: &OxirsConfig, profile: Option<&str>) -> CliResult<()> {
let profile = profile.unwrap_or(&self.active_profile);
fs::create_dir_all(&self.config_dir)
.map_err(|e| CliError::config_error(format!("Cannot create config directory: {e}")))?;
let path = if profile == "default" {
self.config_dir.join("config.toml")
} else {
self.config_dir.join(format!("config.{profile}.toml"))
};
let content = toml::to_string_pretty(config)
.map_err(|e| CliError::config_error(format!("Cannot serialize config: {e}")))?;
fs::write(&path, content)
.map_err(|e| CliError::config_error(format!("Cannot write config file: {e}")))?;
Ok(())
}
pub fn list_profiles(&self) -> CliResult<Vec<String>> {
let mut profiles = vec!["default".to_string()];
if self.config_dir.exists() {
for entry in fs::read_dir(&self.config_dir)? {
let entry = entry?;
let path = entry.path();
if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
if name.starts_with("config.") && name.ends_with(".toml") {
let profile = name
.strip_prefix("config.")
.and_then(|n| n.strip_suffix(".toml"))
.unwrap_or("");
if !profile.is_empty() {
profiles.push(profile.to_string());
}
}
}
}
}
profiles.sort();
profiles.dedup();
Ok(profiles)
}
pub fn generate_default_config(&self) -> CliResult<()> {
let config = OxirsConfig::default();
self.save_config(&config, Some("default"))
}
}
impl Default for GeneralConfig {
fn default() -> Self {
Self {
default_format: default_format(),
output_dir: None,
show_progress: default_true(),
colored_output: default_true(),
timeout: default_timeout(),
log_level: default_log_level(),
}
}
}
impl Default for ServerConfig {
fn default() -> Self {
Self {
host: default_host(),
port: default_port(),
admin_enabled: false,
cors: CorsConfig::default(),
auth: AuthConfig::default(),
enable_graphql: false,
graphql_path: default_graphql_path(),
}
}
}
fn default_format() -> String {
"turtle".to_string()
}
fn default_true() -> bool {
true
}
fn default_timeout() -> u64 {
30
}
fn default_log_level() -> String {
"info".to_string()
}
fn default_host() -> String {
"localhost".to_string()
}
fn default_port() -> u16 {
3030
}
fn default_graphql_path() -> String {
"/graphql".to_string()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_default_config() {
let config = OxirsConfig::default();
assert_eq!(config.general.default_format, "turtle");
assert_eq!(config.server.host, "localhost");
assert_eq!(config.server.port, 3030);
}
#[test]
fn test_config_serialization() {
let config = OxirsConfig::default();
let toml_str = toml::to_string(&config).unwrap();
assert!(toml_str.contains("[general]"));
assert!(toml_str.contains("[server]"));
}
#[test]
fn test_env_override() {
use std::sync::Mutex;
static ENV_LOCK: Mutex<()> = Mutex::new(());
let _guard = ENV_LOCK.lock().expect("lock should not be poisoned");
std::env::set_var("OXIRS_DEFAULT_FORMAT", "ntriples");
std::env::set_var("OXIRS_SERVER_PORT", "8080");
let manager = ConfigManager::new().unwrap();
let config = manager.apply_env_overrides(OxirsConfig::default()).unwrap();
assert_eq!(config.general.default_format, "ntriples");
assert_eq!(config.server.port, 8080);
std::env::remove_var("OXIRS_DEFAULT_FORMAT");
std::env::remove_var("OXIRS_SERVER_PORT");
}
}