Skip to main content

moonlight_core/
config.rs

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}