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)]
38pub struct Config {
39    /// Where this config was loaded from. Kept so we can resolve relative
40    /// paths (rule sets, middlewares, respond dirs) against the config
41    /// file's parent directory — not the process's working directory.
42    #[serde(skip)]
43    pub file_path: Option<String>,
44
45    pub listener: Option<ListenerConfig>,
46    pub log: Option<LogConfig>,
47    pub service: ServiceConfig,
48    /// Optional filter configuration for `FileTreeView`. When absent,
49    /// [`FileTreeViewConfig::default()`] applies (dotfiles hidden,
50    /// built-in excludes on).
51    pub file_tree_view: Option<file_tree_config::FileTreeViewConfig>,
52}
53
54impl Config {
55    /// Build a `Config` by reading the TOML file, resolving rule-set
56    /// paths, and validating the result.
57    ///
58    /// Middleware paths are *recorded* on the returned Config but not
59    /// compiled here — the server crate performs compilation. See the
60    /// module docstring for why.
61    // clippy: ConfigError is a public error type (RFC 030 §6 escalation
62    // trigger); boxing its large variant would change that type's shape.
63    // See ESCALATION-002 in the RFC 030 review-request package.
64    #[allow(clippy::result_large_err)]
65    pub fn new(
66        config_file_path: Option<&String>,
67        fallback_respond_dir_path: Option<&String>,
68    ) -> ConfigResult<Self> {
69        let mut ret = Self::init(config_file_path)?;
70
71        ret.set_rule_sets()?;
72
73        ret.compute_fallback_respond_dir(fallback_respond_dir_path)?;
74
75        if !ret.validate() {
76            return Err(ConfigError::Validation);
77        }
78
79        log::info!("{}", ret);
80
81        Ok(ret)
82    }
83
84    /// Load + parse the TOML file. Returns `Config::default()` when no
85    /// path is provided (this is the zero-config "just serve a folder"
86    /// path).
87    // clippy: ConfigError is a public error type (RFC 030 §6 escalation
88    // trigger); boxing its large variant would change that type's shape.
89    // See ESCALATION-002 in the RFC 030 review-request package.
90    #[allow(clippy::result_large_err)]
91    fn init(config_file_path: Option<&String>) -> ConfigResult<Self> {
92        let Some(config_file_path) = config_file_path else {
93            return Ok(Config::default());
94        };
95
96        log::info!("[config] {}\n", config_file_path);
97
98        let path = Path::new(config_file_path);
99        let toml_string =
100            fs::read_to_string(config_file_path).map_err(|e| ConfigError::ConfigRead {
101                path: path.to_path_buf(),
102                source: e,
103            })?;
104
105        let mut config: Config =
106            toml::from_str(&toml_string).map_err(|e| ConfigError::ConfigParse {
107                path: path.to_path_buf(),
108                canonical: path.canonicalize().ok(),
109                source: e,
110            })?;
111        config.file_path = Some(config_file_path.to_owned());
112
113        Ok(config)
114    }
115
116    /// Load every rule-set file listed in `service.rule_sets`.
117    // clippy: ConfigError is a public error type (RFC 030 §6 escalation
118    // trigger); boxing its large variant would change that type's shape.
119    // See ESCALATION-002 in the RFC 030 review-request package.
120    #[allow(clippy::result_large_err)]
121    fn set_rule_sets(&mut self) -> ConfigResult<()> {
122        let relative_dir_path = self.current_dir_to_parent_dir_relative_path()?;
123
124        let Some(rule_sets_file_paths) = self.service.rule_sets_file_paths.as_ref() else {
125            return Ok(());
126        };
127
128        let mut rule_sets = Vec::with_capacity(rule_sets_file_paths.len());
129        for (rule_set_idx, rule_set_file_path) in rule_sets_file_paths.iter().enumerate() {
130            let joined = Path::new(relative_dir_path.as_str()).join(rule_set_file_path);
131            let path_str = joined.to_str().ok_or_else(|| ConfigError::ConfigRead {
132                path: joined.clone(),
133                source: std::io::Error::new(
134                    std::io::ErrorKind::InvalidData,
135                    format!(
136                        "rule set #{} path contains non-UTF-8 bytes: {}",
137                        rule_set_idx + 1,
138                        joined.to_string_lossy(),
139                    ),
140                ),
141            })?;
142
143            // `RuleSet::new` returns `RoutingError`; `ConfigError::RuleSet`
144            // converts via `#[from]`.
145            rule_sets.push(RuleSet::new(
146                path_str,
147                relative_dir_path.as_str(),
148                rule_set_idx,
149            )?);
150        }
151
152        self.service.rule_sets = rule_sets;
153        Ok(())
154    }
155
156    /// Resolve the fallback respond dir against the config file's parent
157    /// directory. See the module doc for why we don't resolve against CWD.
158    // clippy: ConfigError is a public error type (RFC 030 §6 escalation
159    // trigger); boxing its large variant would change that type's shape.
160    // See ESCALATION-002 in the RFC 030 review-request package.
161    #[allow(clippy::result_large_err)]
162    pub fn compute_fallback_respond_dir(
163        &mut self,
164        fallback_respond_dir_path: Option<&String>,
165    ) -> ConfigResult<()> {
166        if let Some(fallback_respond_dir_path) = fallback_respond_dir_path {
167            self.service.fallback_respond_dir = fallback_respond_dir_path.to_owned();
168            return Ok(());
169        }
170
171        if self.service.fallback_respond_dir.as_str() == SERVICE_DEFAULT_FALLBACK_RESPOND_DIR {
172            return Ok(());
173        }
174
175        let relative_path = self.current_dir_to_parent_dir_relative_path()?;
176        let joined =
177            Path::new(relative_path.as_str()).join(self.service.fallback_respond_dir.as_str());
178        let resolved = joined.to_str().ok_or_else(|| ConfigError::PathResolve {
179            path: joined.clone(),
180            source: std::io::Error::new(
181                std::io::ErrorKind::InvalidData,
182                format!(
183                    "fallback_respond_dir path contains non-UTF-8 bytes: {}",
184                    joined.to_string_lossy(),
185                ),
186            ),
187        })?;
188        self.service.fallback_respond_dir = resolved.to_owned();
189        Ok(())
190    }
191
192    /// HTTP listener address, if HTTP is enabled.
193    pub fn listener_http_addr(&self) -> Option<String> {
194        let https_is_active = self.listener_https_addr().is_some();
195        if https_is_active {
196            let port_is_single = self
197                .listener
198                .as_ref()
199                .and_then(|l| l.tls.as_ref())
200                .map(|t| t.port.is_none())
201                .unwrap_or(false);
202            if port_is_single {
203                return None;
204            }
205        }
206
207        let listener_default;
208        let listener = match self.listener.as_ref() {
209            Some(l) => l,
210            None => {
211                listener_default = ListenerConfig::default();
212                &listener_default
213            }
214        };
215
216        Some(format!("{}:{}", listener.ip_address, listener.port))
217    }
218
219    /// HTTPS listener address, if TLS is configured.
220    pub fn listener_https_addr(&self) -> Option<String> {
221        let listener = self.listener.as_ref()?;
222        let tls = listener.tls.as_ref()?;
223        let port = tls.port.unwrap_or(listener.port);
224        Some(format!("{}:{}", listener.ip_address, port))
225    }
226
227    /// Validate settings. Returns `false` when any subcomponent's
228    /// validator returned false — they log details at their own call
229    /// site so the user sees every problem in one pass.
230    fn validate(&self) -> bool {
231        if let Some(listener) = self.listener.as_ref() {
232            if !listener.validate() {
233                return false;
234            }
235
236            if self.listener_http_addr().is_none() && self.listener_https_addr().is_none() {
237                log::error!("at least one listener (http or https) is required");
238                return false;
239            }
240        }
241        self.service.validate()
242    }
243
244    /// Relative path from CWD to the parent dir of the config file.
245    // clippy: ConfigError is a public error type (RFC 030 §6 escalation
246    // trigger); boxing its large variant would change that type's shape.
247    // See ESCALATION-002 in the RFC 030 review-request package.
248    #[allow(clippy::result_large_err)]
249    pub fn current_dir_to_parent_dir_relative_path(&self) -> ConfigResult<String> {
250        let Some(file_path) = self.file_path.as_ref() else {
251            return Ok(String::from("."));
252        };
253
254        let relative_dir_path = current_dir_to_file_parent_dir_relative_path(file_path.as_str())
255            .map_err(|e| ConfigError::PathResolve {
256                path: Path::new(file_path).to_path_buf(),
257                source: e,
258            })?;
259
260        let as_str = relative_dir_path
261            .to_str()
262            .ok_or_else(|| ConfigError::PathResolve {
263                path: relative_dir_path.clone(),
264                source: std::io::Error::new(
265                    std::io::ErrorKind::InvalidData,
266                    format!(
267                        "relative path contains non-UTF-8 bytes: {}",
268                        relative_dir_path.to_string_lossy()
269                    ),
270                ),
271            })?;
272
273        Ok(as_str.to_owned())
274    }
275}
276
277impl Default for Config {
278    fn default() -> Self {
279        Config {
280            file_path: None,
281            listener: Some(ListenerConfig {
282                ip_address: LISTENER_DEFAULT_IP_ADDRESS.to_owned(),
283                port: LISTENER_DEFAULT_PORT,
284                tls: None,
285            }),
286            log: Some(LogConfig::default()),
287            service: ServiceConfig::default(),
288            file_tree_view: None,
289        }
290    }
291}
292
293impl std::fmt::Display for Config {
294    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
295        let log = self.log.clone().unwrap_or_default();
296        let _ = write!(f, "{}", log);
297        let _ = writeln!(f, "{}", PRINT_DELIMITER);
298        let _ = write!(f, "{}", self.service);
299        Ok(())
300    }
301}