Skip to main content

ferridriver_config/
lib.rs

1//! Unified ferridriver configuration.
2//!
3//! Defines the canonical schema for `ferridriver.toml` and exposes typed
4//! sub-sections that downstream crates (`ferridriver-mcp`, `ferridriver-test`,
5//! `ferridriver-bdd`) consume.
6//!
7//! # Layout
8//!
9//! ```toml
10//! # ferridriver.toml
11//!
12//! [mcp]
13//! [mcp.server]
14//! name = "my-server"
15//!
16//! [mcp.browser]
17//! backend = "cdp-pipe"
18//! headless = true
19//!
20//! [test]
21//! testMatch = ["**/*.spec.ts"]
22//! workers = 4
23//!
24//! [test.browser]
25//! browser = "chromium"
26//! ```
27//!
28//! # Search order
29//!
30//! 1. Explicit path passed by the caller.
31//! 2. `./ferridriver.{toml,yaml,yml,json}` in the current working directory.
32//! 3. `~/.config/ferridriver/config.{toml,yaml,yml,json}`.
33
34pub mod mcp;
35pub mod test;
36
37use std::path::{Path, PathBuf};
38
39use serde::{Deserialize, Serialize};
40
41/// Top-level configuration document.
42#[derive(Debug, Default, Deserialize, Serialize)]
43#[serde(default, rename_all = "camelCase")]
44pub struct FerridriverConfig {
45  /// Extension files (plugins): each a single `.js`/`.mjs`/`.ts`/`.mts`
46  /// file or a directory scanned shallowly for those. An extension
47  /// registers MCP tools (`defineTool`) and/or BDD steps
48  /// (`Given`/`When`/`Then`); the MCP server consumes its tools and the
49  /// test runner consumes its steps. Top-level (not under `mcp`) because
50  /// both hosts load it.
51  pub extensions: Vec<String>,
52  /// Sandbox-relaxation knobs for the scripting VM (default-deny).
53  pub scripting: ScriptingConfig,
54  /// MCP server configuration.
55  pub mcp: mcp::McpConfig,
56  /// Test runner configuration.
57  pub test: test::TestConfig,
58}
59
60/// Opt-in relaxations of the scripting sandbox. Every field defaults to
61/// the locked-down value; an operator who widens it is stating they
62/// understand the exposure — same posture as `allow.net`.
63#[derive(Debug, Default, Clone, Deserialize, Serialize)]
64#[serde(default, rename_all = "camelCase")]
65pub struct ScriptingConfig {
66  /// Server environment variable names a script may read via
67  /// `process.env`. Empty (default) ⇒ `process.env` is `{}`. Only names
68  /// listed here, and only if present in the server's environment, are
69  /// exposed — a script never sees an ambient secret the operator did
70  /// not name.
71  pub allow_env: Vec<String>,
72}
73
74impl FerridriverConfig {
75  /// Load the unified configuration document.
76  ///
77  /// If `explicit` is `Some`, that path is read directly and the format is
78  /// inferred from the file extension. Otherwise the standard search paths
79  /// are tried; if none exist, `Self::default()` is returned.
80  ///
81  /// # Errors
82  ///
83  /// Returns an error if a file is found but cannot be read or parsed.
84  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  /// Load the unified configuration from an explicit file path.
98  ///
99  /// # Errors
100  ///
101  /// Returns an error if the file cannot be read or parsed.
102  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/// Search the cwd and `~/.config/ferridriver/` for the canonical config file.
124#[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}