Skip to main content

apimock_config/
config.rs

1//! The `Config` struct: orchestrates loading, validation, and relative-path
2//! resolution for `apimock.toml` and every file it references.
3//!
4//! # What moved in 5.0
5//!
6//! Pre-5.0 `Config::new` also compiled every Rhai middleware. That step
7//! produced `MiddlewareHandler` values — which live in `apimock-server`
8//! after the split — so we deliberately stop there now. Middleware
9//! compilation happens in the server crate, *driven by* the paths
10//! listed in this struct's `service.middlewares_file_paths`. The
11//! separation keeps config dependency-free of Rhai and hyper, and
12//! prevents the GUI-oriented snapshot API (coming in stage 2) from
13//! reaching into execution state it has no business seeing.
14
15use apimock_routing::RuleSet;
16use constant::*;
17use listener_config::ListenerConfig;
18use log_config::LogConfig;
19use serde::Deserialize;
20use service_config::ServiceConfig;
21
22use std::{fs, path::Path};
23
24use crate::{
25    error::{ConfigError, ConfigResult},
26    path_util::current_dir_to_file_parent_dir_relative_path,
27};
28
29pub mod constant;
30pub mod file_tree_config;
31pub mod listener_config;
32pub mod log_config;
33pub mod service_config;
34
35/// Top-level application configuration, corresponding one-to-one with
36/// `apimock.toml`.
37#[derive(Clone, Deserialize)]
38#[non_exhaustive]
39pub struct Config {
40    /// Where this config was loaded from. Kept so we can resolve relative
41    /// paths (rule sets, middlewares, respond dirs) against the config
42    /// file's parent directory — not the process's working directory.
43    #[serde(skip)]
44    pub file_path: Option<String>,
45
46    pub listener: Option<ListenerConfig>,
47    pub log: Option<LogConfig>,
48    pub service: ServiceConfig,
49    /// Optional filter configuration for `FileTreeView`. When absent,
50    /// [`FileTreeViewConfig::default()`] applies (dotfiles hidden,
51    /// built-in excludes on).
52    pub file_tree_view: Option<file_tree_config::FileTreeViewConfig>,
53}
54
55impl Config {
56    /// Build a `Config` by reading the TOML file, resolving rule-set
57    /// paths, and validating the result.
58    ///
59    /// Middleware paths are *recorded* on the returned Config but not
60    /// compiled here — the server crate performs compilation. See the
61    /// module docstring for why.
62    pub fn new(
63        config_file_path: Option<&String>,
64        fallback_respond_dir_path: Option<&String>,
65    ) -> ConfigResult<Self> {
66        let mut ret = Self::init(config_file_path)?;
67
68        ret.set_rule_sets()?;
69
70        ret.compute_fallback_respond_dir(fallback_respond_dir_path)?;
71
72        if let Err(reason) = ret.validate() {
73            return Err(ConfigError::Validation { reason });
74        }
75
76        log::info!("{}", ret);
77
78        Ok(ret)
79    }
80
81    /// Load + parse the TOML file. Returns `Config::default()` when no
82    /// path is provided (this is the zero-config "just serve a folder"
83    /// path).
84    fn init(config_file_path: Option<&String>) -> ConfigResult<Self> {
85        let Some(config_file_path) = config_file_path else {
86            return Ok(Config::default());
87        };
88
89        log::info!("[config] {}\n", config_file_path);
90
91        let path = Path::new(config_file_path);
92        let toml_string =
93            fs::read_to_string(config_file_path).map_err(|e| ConfigError::ConfigRead {
94                path: path.to_path_buf(),
95                source: e,
96            })?;
97
98        let mut config: Config =
99            toml::from_str(&toml_string).map_err(|e| ConfigError::ConfigParse {
100                path: path.to_path_buf(),
101                canonical: path.canonicalize().ok(),
102                source: Box::new(e),
103            })?;
104        config.file_path = Some(config_file_path.to_owned());
105
106        Ok(config)
107    }
108
109    /// Load every rule-set file listed in `service.rule_sets`.
110    fn set_rule_sets(&mut self) -> ConfigResult<()> {
111        let relative_dir_path = self.current_dir_to_parent_dir_relative_path()?;
112
113        let Some(rule_sets_file_paths) = self.service.rule_sets_file_paths.as_ref() else {
114            return Ok(());
115        };
116
117        let mut rule_sets = Vec::with_capacity(rule_sets_file_paths.len());
118        for (rule_set_idx, rule_set_file_path) in rule_sets_file_paths.iter().enumerate() {
119            let joined = Path::new(relative_dir_path.as_str()).join(rule_set_file_path);
120            let path_str = joined.to_str().ok_or_else(|| ConfigError::ConfigRead {
121                path: joined.clone(),
122                source: std::io::Error::new(
123                    std::io::ErrorKind::InvalidData,
124                    format!(
125                        "rule set #{} path contains non-UTF-8 bytes: {}",
126                        rule_set_idx + 1,
127                        joined.to_string_lossy(),
128                    ),
129                ),
130            })?;
131
132            // `RuleSet::new` returns `RoutingError`; `ConfigError::RuleSet`
133            // converts via `#[from]`.
134            rule_sets.push(RuleSet::new(
135                path_str,
136                relative_dir_path.as_str(),
137                rule_set_idx,
138            )?);
139        }
140
141        self.service.rule_sets = rule_sets;
142        Ok(())
143    }
144
145    /// Resolve the fallback respond dir against the config file's parent
146    /// directory. See the module doc for why we don't resolve against CWD.
147    pub fn compute_fallback_respond_dir(
148        &mut self,
149        fallback_respond_dir_path: Option<&String>,
150    ) -> ConfigResult<()> {
151        if let Some(fallback_respond_dir_path) = fallback_respond_dir_path {
152            self.service.fallback_respond_dir = fallback_respond_dir_path.to_owned();
153            return Ok(());
154        }
155
156        if self.service.fallback_respond_dir.as_str() == SERVICE_DEFAULT_FALLBACK_RESPOND_DIR {
157            return Ok(());
158        }
159
160        let relative_path = self.current_dir_to_parent_dir_relative_path()?;
161        let joined =
162            Path::new(relative_path.as_str()).join(self.service.fallback_respond_dir.as_str());
163        let resolved = joined.to_str().ok_or_else(|| ConfigError::PathResolve {
164            path: joined.clone(),
165            source: std::io::Error::new(
166                std::io::ErrorKind::InvalidData,
167                format!(
168                    "fallback_respond_dir path contains non-UTF-8 bytes: {}",
169                    joined.to_string_lossy(),
170                ),
171            ),
172        })?;
173        self.service.fallback_respond_dir = resolved.to_owned();
174        Ok(())
175    }
176
177    /// HTTP listener address, if HTTP is enabled.
178    pub fn listener_http_addr(&self) -> Option<String> {
179        let https_is_active = self.listener_https_addr().is_some();
180        if https_is_active {
181            let port_is_single = self
182                .listener
183                .as_ref()
184                .and_then(|l| l.tls.as_ref())
185                .map(|t| t.port.is_none())
186                .unwrap_or(false);
187            if port_is_single {
188                return None;
189            }
190        }
191
192        let listener_default;
193        let listener = match self.listener.as_ref() {
194            Some(l) => l,
195            None => {
196                listener_default = ListenerConfig::default();
197                &listener_default
198            }
199        };
200
201        Some(format!("{}:{}", listener.ip_address, listener.port))
202    }
203
204    /// HTTPS listener address, if TLS is configured.
205    pub fn listener_https_addr(&self) -> Option<String> {
206        let listener = self.listener.as_ref()?;
207        let tls = listener.tls.as_ref()?;
208        let port = tls.port.unwrap_or(listener.port);
209        Some(format!("{}:{}", listener.ip_address, port))
210    }
211
212    /// Validate settings. Returns the first failure's reason (RFC 065 —
213    /// see `ServiceConfig::validate`'s own doc comment for why this
214    /// isn't a bare `bool` any more).
215    fn validate(&self) -> Result<(), String> {
216        if let Some(listener) = self.listener.as_ref() {
217            if !listener.validate() {
218                return Err("invalid listener configuration".to_owned());
219            }
220
221            if self.listener_http_addr().is_none() && self.listener_https_addr().is_none() {
222                return Err("at least one listener (http or https) is required".to_owned());
223            }
224        }
225        self.service.validate()
226    }
227
228    /// Relative path from CWD to the parent dir of the config file.
229    pub fn current_dir_to_parent_dir_relative_path(&self) -> ConfigResult<String> {
230        let Some(file_path) = self.file_path.as_ref() else {
231            return Ok(String::from("."));
232        };
233
234        let relative_dir_path = current_dir_to_file_parent_dir_relative_path(file_path.as_str())
235            .map_err(|e| ConfigError::PathResolve {
236                path: Path::new(file_path).to_path_buf(),
237                source: e,
238            })?;
239
240        let as_str = relative_dir_path
241            .to_str()
242            .ok_or_else(|| ConfigError::PathResolve {
243                path: relative_dir_path.clone(),
244                source: std::io::Error::new(
245                    std::io::ErrorKind::InvalidData,
246                    format!(
247                        "relative path contains non-UTF-8 bytes: {}",
248                        relative_dir_path.to_string_lossy()
249                    ),
250                ),
251            })?;
252
253        Ok(as_str.to_owned())
254    }
255}
256
257impl Default for Config {
258    fn default() -> Self {
259        Config {
260            file_path: None,
261            listener: Some(ListenerConfig {
262                ip_address: LISTENER_DEFAULT_IP_ADDRESS.to_owned(),
263                port: LISTENER_DEFAULT_PORT,
264                tls: None,
265            }),
266            log: Some(LogConfig::default()),
267            service: ServiceConfig::default(),
268            file_tree_view: None,
269        }
270    }
271}
272
273impl std::fmt::Display for Config {
274    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
275        let log = self.log.clone().unwrap_or_default();
276        let _ = write!(f, "{}", log);
277        let _ = writeln!(f, "{}", PRINT_DELIMITER);
278        let _ = write!(f, "{}", self.service);
279        Ok(())
280    }
281}