ferridriver_config/
lib.rs1pub mod mcp;
35pub mod test;
36
37use std::path::{Path, PathBuf};
38
39use serde::{Deserialize, Serialize};
40
41#[derive(Debug, Default, Deserialize, Serialize)]
43#[serde(default, rename_all = "camelCase")]
44pub struct FerridriverConfig {
45 pub extensions: Vec<String>,
52 pub scripting: ScriptingConfig,
54 pub mcp: mcp::McpConfig,
56 pub test: test::TestConfig,
58}
59
60#[derive(Debug, Default, Clone, Deserialize, Serialize)]
64#[serde(default, rename_all = "camelCase")]
65pub struct ScriptingConfig {
66 pub allow_env: Vec<String>,
72}
73
74impl FerridriverConfig {
75 pub fn load(explicit: Option<&Path>) -> anyhow::Result<Self> {
85 let path = match explicit {
86 Some(p) => Some(p.to_path_buf()),
87 None => find_default_path(),
88 };
89
90 let Some(path) = path else {
91 return Ok(Self::default());
92 };
93
94 Self::load_from(&path)
95 }
96
97 pub fn load_from(path: &Path) -> anyhow::Result<Self> {
103 let content =
104 std::fs::read_to_string(path).map_err(|e| anyhow::anyhow!("failed to read config {}: {e}", path.display()))?;
105
106 let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("toml");
107 let cfg: FerridriverConfig = match ext {
108 "toml" => toml::from_str(&content).map_err(|e| anyhow::anyhow!("invalid TOML config {}: {e}", path.display()))?,
109 "yaml" | "yml" => {
110 serde_yaml::from_str(&content).map_err(|e| anyhow::anyhow!("invalid YAML config {}: {e}", path.display()))?
111 },
112 "json" => {
113 serde_json::from_str(&content).map_err(|e| anyhow::anyhow!("invalid JSON config {}: {e}", path.display()))?
114 },
115 other => anyhow::bail!("unsupported config format: {other} (expected toml/yaml/yml/json)"),
116 };
117
118 tracing::debug!("loaded ferridriver config from {}", path.display());
119 Ok(cfg)
120 }
121}
122
123#[must_use]
125pub fn find_default_path() -> Option<PathBuf> {
126 let exts = ["toml", "yaml", "yml", "json"];
127
128 for ext in &exts {
129 let p = PathBuf::from(format!("ferridriver.{ext}"));
130 if p.exists() {
131 return Some(p);
132 }
133 }
134
135 if let Some(cd) = dirs::config_dir() {
136 let dir = cd.join("ferridriver");
137 for ext in &exts {
138 let p = dir.join(format!("config.{ext}"));
139 if p.exists() {
140 return Some(p);
141 }
142 }
143 }
144
145 None
146}
147
148#[cfg(test)]
149mod tests {
150 use super::*;
151
152 #[test]
153 fn default_root_is_empty() {
154 let root = FerridriverConfig::default();
155 assert_eq!(root.mcp.server_name(), "ferridriver");
156 assert!(root.test.test_match.is_empty());
157 }
158
159 #[test]
160 fn load_toml_with_both_sections() {
161 let dir = std::env::temp_dir().join("ferridriver-config-toml-both");
162 let _ = std::fs::create_dir_all(&dir);
163 let path = dir.join("ferridriver.toml");
164 std::fs::write(
165 &path,
166 r#"
167[mcp.server]
168name = "unified-test"
169
170[mcp.browser]
171backend = "cdp-raw"
172headless = true
173
174[test]
175workers = 7
176testMatch = ["tests/**/*.spec.ts"]
177
178[test.browser]
179browser = "chromium"
180backend = "cdp-pipe"
181"#,
182 )
183 .unwrap();
184
185 let root = FerridriverConfig::load_from(&path).unwrap();
186 let _ = std::fs::remove_dir_all(&dir);
187
188 assert_eq!(root.mcp.server_name(), "unified-test");
189 assert!(root.mcp.headless());
190 assert_eq!(root.test.workers, 7);
191 assert_eq!(root.test.test_match, vec!["tests/**/*.spec.ts"]);
192 }
193
194 #[test]
195 fn load_yaml_with_both_sections() {
196 let dir = std::env::temp_dir().join("ferridriver-config-yaml-both");
197 let _ = std::fs::create_dir_all(&dir);
198 let path = dir.join("ferridriver.yaml");
199 std::fs::write(
200 &path,
201 r#"
202mcp:
203 server:
204 name: "yaml-unified"
205 browser:
206 headless: true
207test:
208 workers: 5
209"#,
210 )
211 .unwrap();
212
213 let root = FerridriverConfig::load_from(&path).unwrap();
214 let _ = std::fs::remove_dir_all(&dir);
215
216 assert_eq!(root.mcp.server_name(), "yaml-unified");
217 assert!(root.mcp.headless());
218 assert_eq!(root.test.workers, 5);
219 }
220
221 #[test]
222 fn load_json_with_both_sections() {
223 let dir = std::env::temp_dir().join("ferridriver-config-json-both");
224 let _ = std::fs::create_dir_all(&dir);
225 let path = dir.join("ferridriver.json");
226 std::fs::write(
227 &path,
228 r#"{
229 "mcp": { "server": { "name": "json-unified" } },
230 "test": { "workers": 9 }
231 }"#,
232 )
233 .unwrap();
234
235 let root = FerridriverConfig::load_from(&path).unwrap();
236 let _ = std::fs::remove_dir_all(&dir);
237
238 assert_eq!(root.mcp.server_name(), "json-unified");
239 assert_eq!(root.test.workers, 9);
240 }
241
242 #[test]
243 fn serde_json_roundtrip_default() {
244 let root = FerridriverConfig::default();
245 let json = serde_json::to_value(&root).expect("serialize default");
246 let parsed: FerridriverConfig = serde_json::from_value(json.clone()).expect("deserialize back");
247 let json2 = serde_json::to_value(&parsed).expect("serialize parsed");
248 assert_eq!(json, json2, "default config should round-trip cleanly through JSON");
249 }
250
251 #[test]
252 fn serde_json_roundtrip_populated() {
253 let mut root = FerridriverConfig::default();
254 root.mcp.server.name = Some("custom".into());
255 root.mcp.browser.backend = Some("cdp-raw".into());
256 root.mcp.browser.headless = Some(true);
257 root.mcp.browser.chrome_args = vec!["--no-sandbox".into()];
258 root.test.workers = 4;
259 root.test.timeout = 60_000;
260 root.test.test_match = vec!["custom/**/*.spec.ts".into()];
261 root.test.browser.headless = true;
262 root.test.browser.use_options.is_mobile = true;
263 root.test.browser.use_options.locale = Some("en-GB".into());
264
265 let json = serde_json::to_value(&root).expect("serialize populated");
266 let parsed: FerridriverConfig = serde_json::from_value(json.clone()).expect("deserialize populated");
267 let json2 = serde_json::to_value(&parsed).expect("serialize roundtripped");
268 assert_eq!(json, json2, "populated config should round-trip");
269
270 assert_eq!(parsed.mcp.server.name.as_deref(), Some("custom"));
271 assert_eq!(parsed.mcp.browser.backend.as_deref(), Some("cdp-raw"));
272 assert_eq!(parsed.mcp.browser.headless, Some(true));
273 assert_eq!(parsed.test.workers, 4);
274 assert!(parsed.test.browser.headless);
275 assert!(parsed.test.browser.use_options.is_mobile);
276 }
277
278 #[test]
279 fn unsupported_extension_errors() {
280 let dir = std::env::temp_dir().join("ferridriver-config-bad-ext");
281 let _ = std::fs::create_dir_all(&dir);
282 let path = dir.join("ferridriver.ini");
283 std::fs::write(&path, "[mcp]\n").unwrap();
284
285 let err = FerridriverConfig::load_from(&path).unwrap_err();
286 let _ = std::fs::remove_dir_all(&dir);
287
288 assert!(err.to_string().contains("unsupported config format"));
289 }
290}