1use serde::{Deserialize, Serialize};
2use std::{
3 fs,
4 net::SocketAddr,
5 path::{Path, PathBuf},
6 str::FromStr,
7};
8use ts_rs::TS;
9
10pub const DEFAULT_CONFIG_PATH: &str = "moonlight.conf";
11pub const DEFAULT_BIND_ADDR: &str = "127.0.0.1:8080";
12pub const DEFAULT_CLI_STORAGE_PATH: &str = "data/moonlight/cli-runs.jsonl";
13pub const DEFAULT_HTTP_STORAGE_PATH: &str = "data/moonlight/http-runs.jsonl";
14pub const DEFAULT_REVIEW_STATE_PATH: &str = "data/moonlight/review-state.json";
15pub const DEFAULT_MAX_BODY_CAPTURE_BYTES: usize = 8192;
16pub const DEFAULT_MAX_REQUEST_BODY_BYTES: usize = 10 * 1024 * 1024;
17pub const DEFAULT_TARGET_TIMEOUT_MS: u64 = 30_000;
18
19pub const DEFAULT_IGNORE_JSON_PATHS: &[&str] = &["$.timestamp", "$.requestId", "$.traceId", "$.id"];
20pub const DEFAULT_IGNORE_HEADERS: &[&str] = &[
21 "date",
22 "server",
23 "set-cookie",
24 "x-request-id",
25 "traceparent",
26];
27pub const DEFAULT_REDACT_HEADERS: &[&str] = &[
28 "authorization",
29 "cookie",
30 "set-cookie",
31 "x-api-key",
32 "proxy-authorization",
33 "x-auth-token",
34 "x-csrf-token",
35];
36pub const DEFAULT_REDACT_QUERY_PARAMS: &[&str] = &[
37 "token",
38 "access_token",
39 "id_token",
40 "api_key",
41 "key",
42 "secret",
43 "password",
44];
45pub const DEFAULT_CORS_ORIGINS: &[&str] = &["http://127.0.0.1:5173", "http://localhost:5173"];
46
47#[derive(Debug, Clone, Serialize, Deserialize, TS)]
48pub struct AppConfig {
49 #[ts(type = "string")]
50 pub bind_addr: SocketAddr,
51 pub primary_url: String,
52 pub candidate_url: String,
53 pub secondary_url: String,
54 pub enable_secondary: bool,
55 pub return_target: ReturnTarget,
56 pub return_fallback: ReturnFallback,
57 pub response_timing: ResponseTiming,
58 pub max_body_capture_bytes: usize,
59 pub max_request_body_bytes: usize,
60 pub redact_headers: Vec<String>,
61 pub redact_json_paths: Vec<String>,
62 pub redact_json_path_patterns: Vec<String>,
63 pub redact_query_params: Vec<String>,
64 pub ignore_json_paths: Vec<String>,
65 pub ignore_json_path_patterns: Vec<String>,
66 pub ignore_headers: Vec<String>,
67 pub ignore_stderr: bool,
68 pub target_timeout_ms: u64,
69 #[ts(type = "string")]
70 pub storage_path: PathBuf,
71 #[ts(type = "string")]
72 pub review_state_path: PathBuf,
73 pub cors_origins: Vec<String>,
74 #[serde(skip_serializing, skip_deserializing)]
75 #[ts(skip)]
76 pub admin_token: Option<String>,
77 pub retention_max_runs: Option<usize>,
78 pub retention_max_bytes: Option<u64>,
79}
80
81#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, TS)]
82#[serde(rename_all = "snake_case")]
83pub enum ReturnTarget {
84 Primary,
85 Candidate,
86}
87
88impl FromStr for ReturnTarget {
89 type Err = anyhow::Error;
90
91 fn from_str(value: &str) -> Result<Self, Self::Err> {
92 match value {
93 "primary" => Ok(Self::Primary),
94 "candidate" => Ok(Self::Candidate),
95 other => anyhow::bail!("invalid return target {other:?}; use primary or candidate"),
96 }
97 }
98}
99
100#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, TS)]
101#[serde(rename_all = "snake_case")]
102pub enum ReturnFallback {
103 None,
104 Primary,
105}
106
107impl FromStr for ReturnFallback {
108 type Err = anyhow::Error;
109
110 fn from_str(value: &str) -> Result<Self, Self::Err> {
111 match value {
112 "none" => Ok(Self::None),
113 "primary" => Ok(Self::Primary),
114 other => anyhow::bail!("invalid return fallback {other:?}; use none or primary"),
115 }
116 }
117}
118
119#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, TS)]
120#[serde(rename_all = "snake_case")]
121pub enum ResponseTiming {
122 WaitAll,
123 ReturnSelected,
124}
125
126impl FromStr for ResponseTiming {
127 type Err = anyhow::Error;
128
129 fn from_str(value: &str) -> Result<Self, Self::Err> {
130 match value {
131 "wait-all" => Ok(Self::WaitAll),
132 "return-selected" => Ok(Self::ReturnSelected),
133 other => {
134 anyhow::bail!("invalid response timing {other:?}; use wait-all or return-selected")
135 }
136 }
137 }
138}
139
140impl AppConfig {
141 pub fn defaults() -> Self {
142 Self {
143 bind_addr: DEFAULT_BIND_ADDR
144 .parse()
145 .expect("default bind addr is valid"),
146 primary_url: String::new(),
147 candidate_url: String::new(),
148 secondary_url: String::new(),
149 enable_secondary: false,
150 return_target: ReturnTarget::Primary,
151 return_fallback: ReturnFallback::None,
152 response_timing: ResponseTiming::WaitAll,
153 max_body_capture_bytes: DEFAULT_MAX_BODY_CAPTURE_BYTES,
154 max_request_body_bytes: DEFAULT_MAX_REQUEST_BODY_BYTES,
155 redact_headers: strings(DEFAULT_REDACT_HEADERS),
156 redact_json_paths: Vec::new(),
157 redact_json_path_patterns: Vec::new(),
158 redact_query_params: strings(DEFAULT_REDACT_QUERY_PARAMS),
159 ignore_json_paths: strings(DEFAULT_IGNORE_JSON_PATHS),
160 ignore_json_path_patterns: Vec::new(),
161 ignore_headers: strings(DEFAULT_IGNORE_HEADERS),
162 ignore_stderr: false,
163 target_timeout_ms: DEFAULT_TARGET_TIMEOUT_MS,
164 storage_path: PathBuf::from(DEFAULT_HTTP_STORAGE_PATH),
165 review_state_path: PathBuf::from(DEFAULT_REVIEW_STATE_PATH),
166 cors_origins: strings(DEFAULT_CORS_ORIGINS),
167 admin_token: None,
168 retention_max_runs: None,
169 retention_max_bytes: None,
170 }
171 }
172
173 pub fn apply_shared_config(&mut self, config: &MoonlightConfig) {
174 if let Some(storage) = &config.storage {
175 if let Some(path) = &storage.path {
176 self.storage_path = path.clone();
177 }
178 if let Some(path) = &storage.review_state_path {
179 self.review_state_path = path.clone();
180 }
181 }
182 if let Some(comparison) = &config.comparison {
183 if let Some(value) = comparison.max_body_capture_bytes {
184 self.max_body_capture_bytes = value;
185 }
186 if let Some(value) = comparison.target_timeout_ms {
187 self.target_timeout_ms = normalize_timeout(value);
188 }
189 extend(&mut self.ignore_json_paths, &comparison.ignore_json_paths);
190 extend(
191 &mut self.ignore_json_path_patterns,
192 &comparison.ignore_json_path_patterns,
193 );
194 extend(&mut self.ignore_headers, &comparison.ignore_headers);
195 if let Some(value) = comparison.ignore_stderr {
196 self.ignore_stderr = value;
197 }
198 extend(&mut self.redact_headers, &comparison.redact_headers);
199 extend(&mut self.redact_json_paths, &comparison.redact_json_paths);
200 extend(
201 &mut self.redact_json_path_patterns,
202 &comparison.redact_json_path_patterns,
203 );
204 extend(
205 &mut self.redact_query_params,
206 &comparison.redact_query_params,
207 );
208 }
209 }
210
211 pub fn apply_http_config(&mut self, config: &HttpConfig) -> anyhow::Result<()> {
212 if let Some(value) = &config.bind_addr {
213 self.bind_addr = value.parse()?;
214 }
215 if let Some(value) = &config.primary_url {
216 self.primary_url = normalize_base_url(value);
217 }
218 if let Some(value) = &config.candidate_url {
219 self.candidate_url = normalize_base_url(value);
220 }
221 if let Some(value) = &config.secondary_url {
222 self.secondary_url = normalize_base_url(value);
223 self.enable_secondary = true;
224 }
225 if let Some(value) = config.enable_secondary {
226 self.enable_secondary = value;
227 }
228 if let Some(value) = &config.return_target {
229 self.return_target = value.parse()?;
230 }
231 if let Some(value) = &config.return_fallback {
232 self.return_fallback = value.parse()?;
233 }
234 if let Some(value) = &config.response_timing {
235 self.response_timing = value.parse()?;
236 }
237 if let Some(value) = config.max_request_body_bytes {
238 self.max_request_body_bytes = value;
239 }
240 extend(&mut self.cors_origins, &config.cors_origins);
241 if let Some(value) = &config.admin_token {
242 self.admin_token = nonempty(value);
243 }
244 if let Some(value) = config.retention_max_runs {
245 self.retention_max_runs = Some(value);
246 }
247 if let Some(value) = config.retention_max_bytes {
248 self.retention_max_bytes = Some(value);
249 }
250 Ok(())
251 }
252
253 pub fn validate_http(&self) -> anyhow::Result<()> {
254 if self.primary_url.trim().is_empty() {
255 anyhow::bail!("primary URL is required; set [http].primary_url or --primary-url");
256 }
257 if self.candidate_url.trim().is_empty() {
258 anyhow::bail!("candidate URL is required; set [http].candidate_url or --candidate-url");
259 }
260 if self.enable_secondary && self.secondary_url.trim().is_empty() {
261 anyhow::bail!(
262 "secondary URL is required when secondary is enabled; set [http].secondary_url or --secondary-url"
263 );
264 }
265 Ok(())
266 }
267}
268
269#[derive(Debug, Clone, Default, Deserialize)]
270#[serde(deny_unknown_fields)]
271pub struct MoonlightConfig {
272 #[serde(default)]
273 pub storage: Option<StorageConfig>,
274 #[serde(default)]
275 pub comparison: Option<ComparisonConfig>,
276 #[serde(default)]
277 pub cli: Option<CliConfig>,
278 #[serde(default)]
279 pub http: Option<HttpConfig>,
280}
281
282impl MoonlightConfig {
283 pub fn load(path: &Path) -> anyhow::Result<Self> {
284 let content = fs::read_to_string(path)
285 .map_err(|error| anyhow::anyhow!("failed to read {}: {error}", path.display()))?;
286 toml::from_str(&content)
287 .map_err(|error| anyhow::anyhow!("invalid {}: {error}", path.display()))
288 }
289}
290
291#[derive(Debug, Clone, Default, Deserialize)]
292#[serde(deny_unknown_fields)]
293pub struct StorageConfig {
294 pub path: Option<PathBuf>,
295 pub review_state_path: Option<PathBuf>,
296}
297
298#[derive(Debug, Clone, Default, Deserialize)]
299#[serde(deny_unknown_fields)]
300pub struct ComparisonConfig {
301 pub max_body_capture_bytes: Option<usize>,
302 #[serde(default)]
303 pub ignore_json_paths: Vec<String>,
304 #[serde(default)]
305 pub ignore_json_path_patterns: Vec<String>,
306 #[serde(default)]
307 pub ignore_headers: Vec<String>,
308 pub ignore_stderr: Option<bool>,
309 pub target_timeout_ms: Option<u64>,
310 #[serde(default)]
311 pub redact_headers: Vec<String>,
312 #[serde(default)]
313 pub redact_json_paths: Vec<String>,
314 #[serde(default)]
315 pub redact_json_path_patterns: Vec<String>,
316 #[serde(default)]
317 pub redact_query_params: Vec<String>,
318}
319
320#[derive(Debug, Clone, Default, Deserialize)]
321#[serde(deny_unknown_fields)]
322pub struct CliConfig {
323 #[serde(default)]
324 pub run: Option<CliRunConfig>,
325 #[serde(default)]
326 pub batch: Option<CliBatchConfig>,
327}
328
329#[derive(Debug, Clone, Default, Deserialize)]
330#[serde(deny_unknown_fields)]
331pub struct CliRunConfig {
332 #[serde(flatten)]
333 pub targets: CliTargetConfig,
334 pub serial_targets: Option<bool>,
335 pub quiet: Option<bool>,
336 pub compact: Option<bool>,
337}
338
339#[derive(Debug, Clone, Default, Deserialize)]
340#[serde(deny_unknown_fields)]
341pub struct CliTargetConfig {
342 pub primary: Option<String>,
343 pub candidate: Option<String>,
344 pub secondary: Option<String>,
345 pub primary_argv: Option<Vec<String>>,
346 pub candidate_argv: Option<Vec<String>>,
347 pub secondary_argv: Option<Vec<String>>,
348}
349
350#[derive(Debug, Clone, Default, Deserialize)]
351#[serde(deny_unknown_fields)]
352pub struct CliBatchConfig {
353 pub input: Option<PathBuf>,
354 pub jobs: Option<usize>,
355 pub quiet: Option<bool>,
356 pub emit_runs: Option<bool>,
357 pub serial_targets: Option<bool>,
358}
359
360#[derive(Debug, Clone, Default, Deserialize)]
361#[serde(deny_unknown_fields)]
362pub struct HttpConfig {
363 pub bind_addr: Option<String>,
364 pub primary_url: Option<String>,
365 pub candidate_url: Option<String>,
366 pub secondary_url: Option<String>,
367 pub enable_secondary: Option<bool>,
368 pub return_target: Option<String>,
369 pub return_fallback: Option<String>,
370 pub response_timing: Option<String>,
371 pub max_request_body_bytes: Option<usize>,
372 #[serde(default)]
373 pub cors_origins: Vec<String>,
374 pub admin_token: Option<String>,
375 pub retention_max_runs: Option<usize>,
376 pub retention_max_bytes: Option<u64>,
377}
378
379pub fn load_optional_config(
380 path: Option<&Path>,
381 no_config: bool,
382) -> anyhow::Result<MoonlightConfig> {
383 if no_config {
384 return Ok(MoonlightConfig::default());
385 }
386
387 match path {
388 Some(path) => MoonlightConfig::load(path),
389 None => {
390 let default_path = Path::new(DEFAULT_CONFIG_PATH);
391 if default_path.exists() {
392 MoonlightConfig::load(default_path)
393 } else {
394 Ok(MoonlightConfig::default())
395 }
396 }
397 }
398}
399
400pub fn extend(target: &mut Vec<String>, values: &[String]) {
401 target.extend(values.iter().filter_map(|value| nonempty(value)));
402}
403
404pub fn normalize_base_url(value: &str) -> String {
405 value.trim_end_matches('/').to_string()
406}
407
408pub fn nonempty(value: &str) -> Option<String> {
409 let trimmed = value.trim();
410 (!trimmed.is_empty()).then(|| trimmed.to_string())
411}
412
413pub fn normalize_timeout(value: u64) -> u64 {
414 if value == 0 {
415 DEFAULT_TARGET_TIMEOUT_MS
416 } else {
417 value
418 }
419}
420
421fn strings(values: &[&str]) -> Vec<String> {
422 values.iter().map(|value| (*value).to_string()).collect()
423}
424
425#[cfg(test)]
426mod tests {
427 use super::*;
428 use tempfile::tempdir;
429
430 #[test]
431 fn response_timing_parses_kebab_case() {
432 assert_eq!(
433 "return-selected".parse::<ResponseTiming>().unwrap(),
434 ResponseTiming::ReturnSelected
435 );
436 assert!("return_selected".parse::<ResponseTiming>().is_err());
437 }
438
439 #[test]
440 fn app_defaults_require_http_targets() {
441 let config = AppConfig::defaults();
442 assert!(config.validate_http().is_err());
443 }
444
445 #[test]
446 fn shared_config_extends_default_lists() {
447 let parsed: MoonlightConfig = toml::from_str(
448 r#"
449 [comparison]
450 ignore_headers = ["x-generated"]
451 redact_json_paths = ["$.secret"]
452 "#,
453 )
454 .unwrap();
455 let mut config = AppConfig::defaults();
456 config.apply_shared_config(&parsed);
457
458 assert!(config.ignore_headers.contains(&"date".to_string()));
459 assert!(config.ignore_headers.contains(&"x-generated".to_string()));
460 assert_eq!(config.redact_json_paths, vec!["$.secret".to_string()]);
461 }
462
463 #[test]
464 fn http_config_secondary_url_enables_secondary() {
465 let parsed: MoonlightConfig = toml::from_str(
466 r#"
467 [http]
468 primary_url = "http://primary/"
469 candidate_url = "http://candidate/"
470 secondary_url = "http://secondary/"
471 "#,
472 )
473 .unwrap();
474 let mut config = AppConfig::defaults();
475 config
476 .apply_http_config(parsed.http.as_ref().unwrap())
477 .unwrap();
478
479 assert_eq!(config.primary_url, "http://primary");
480 assert_eq!(config.candidate_url, "http://candidate");
481 assert_eq!(config.secondary_url, "http://secondary");
482 assert!(config.enable_secondary);
483 config.validate_http().unwrap();
484 }
485
486 #[test]
487 fn serialized_config_omits_admin_token_and_uses_ignore_names() {
488 let mut config = AppConfig::defaults();
489 config.admin_token = Some("secret".to_string());
490 let json = serde_json::to_value(config).unwrap();
491
492 assert!(json.get("admin_token").is_none());
493 assert!(json.get("ignore_json_paths").is_some());
494 assert!(json.get("ignore_headers").is_some());
495 assert!(json.get("ignored_json_paths").is_none());
496 }
497
498 #[test]
499 fn explicit_missing_config_path_fails() {
500 let dir = tempdir().unwrap();
501 let path = dir.path().join("missing.conf");
502
503 assert!(load_optional_config(Some(&path), false).is_err());
504 }
505}