Skip to main content

sword_core/config/
mod.rs

1mod env;
2mod error;
3mod registrar;
4mod utils;
5
6use env::expand_env_variables;
7use serde::de::{DeserializeOwned, IntoDeserializer};
8use std::{env::current_exe, fs, path::Path, str::FromStr, sync::Arc};
9use toml::{Table, Value};
10
11pub use error::ConfigError;
12pub use registrar::*;
13pub use sword_macros::config;
14pub use utils::{ByteConfig, TimeConfig};
15
16const DEFAULT_CONFIG_PATH: &str = "config/config.toml";
17const CONFIG_ENV_VAR: &str = "SWORD_CONFIG_PATH";
18
19/// Application configuration loaded from TOML files.
20///
21/// Loads configuration with the following priority:
22/// 1. Explicit path via `from_path()`
23/// 2. `SWORD_CONFIG_PATH` environment variable
24/// 3. `config/config.toml`
25/// 4. `<executable_dir>/config/config.toml`
26#[derive(Debug, Clone, Default)]
27pub struct Config {
28    inner: Arc<Table>,
29}
30
31impl Config {
32    /// Loads configuration using default priority order.
33    ///
34    /// # Errors
35    ///
36    /// Returns `ConfigError` if no configuration file is found or contains invalid TOML.
37    pub fn new() -> Result<Self, ConfigError> {
38        let content = Self::load_config_file(None)?;
39
40        let expanded = expand_env_variables(&content)
41            .map_err(ConfigError::interpolation_error)?;
42
43        Ok(Self {
44            inner: Arc::new(Table::from_str(&expanded)?),
45        })
46    }
47
48    /// Loads configuration from a specific file path without fallbacks.
49    ///
50    /// # Errors
51    ///
52    /// Returns `ConfigError::FileNotFound` if the file doesn't exist.
53    pub fn from_path<P: AsRef<Path>>(path: P) -> Result<Self, ConfigError> {
54        let content = Self::load_config_file(Some(path.as_ref()))?;
55
56        let expanded = expand_env_variables(&content)
57            .map_err(ConfigError::interpolation_error)?;
58
59        Ok(Self {
60            inner: Arc::new(Table::from_str(&expanded)?),
61        })
62    }
63
64    /// Retrieves and deserializes a configuration section.
65    ///
66    /// Type `T` must implement `ConfigItem` via `#[config(key = "section")]` macro.
67    pub fn get<T: DeserializeOwned + ConfigItem>(&self) -> Result<T, ConfigError> {
68        let key = T::toml_key();
69
70        let Some(config_item) = self.inner.get(key).cloned() else {
71            return Err(ConfigError::key_not_found(key));
72        };
73
74        let value = Value::into_deserializer(config_item);
75
76        Ok(T::deserialize(value)?)
77    }
78
79    /// Retrieves a configuration section, panicking if not found or invalid.
80    pub fn get_or_panic<T: DeserializeOwned + ConfigItem>(&self) -> T {
81        self.get::<T>().unwrap_or_else(|_| {
82            panic!("Failed to load configuration for key '{}'", T::toml_key())
83        })
84    }
85
86    /// Retrieves a configuration section, returning default if not found or invalid.
87    pub fn get_or_default<T: DeserializeOwned + ConfigItem + Default>(&self) -> T {
88        self.get::<T>().unwrap_or_default()
89    }
90
91    fn load_config_file(path: Option<&Path>) -> Result<String, ConfigError> {
92        if let Some(p) = path {
93            if p.exists() {
94                return Ok(fs::read_to_string(p)?);
95            }
96            return Err(ConfigError::FileNotFound);
97        }
98
99        if let Ok(env_path) = std::env::var(CONFIG_ENV_VAR) {
100            let env_path = Path::new(&env_path);
101
102            if env_path.exists() {
103                return Ok(fs::read_to_string(env_path)?);
104            }
105
106            eprintln!(
107                "Warning: {} is set to '{}' but file does not exist. Falling back to default paths.",
108                CONFIG_ENV_VAR,
109                env_path.display()
110            );
111        }
112
113        let default_path = Path::new(DEFAULT_CONFIG_PATH);
114
115        if default_path.exists() {
116            return Ok(fs::read_to_string(default_path)?);
117        }
118
119        Self::load_from_exe_directory()
120    }
121
122    fn load_from_exe_directory() -> Result<String, ConfigError> {
123        let exe_path = current_exe().map_err(|_| ConfigError::FileNotFound)?;
124        let exe_dir = exe_path.parent().ok_or(ConfigError::FileNotFound)?;
125
126        let fallback_path = exe_dir.join(DEFAULT_CONFIG_PATH);
127
128        if !fallback_path.exists() {
129            return Err(ConfigError::FileNotFound);
130        }
131
132        Ok(fs::read_to_string(fallback_path)?)
133    }
134}