Skip to main content

moonlight_core/
config.rs

1use serde::{Deserialize, Serialize};
2use std::{env, net::SocketAddr, path::PathBuf, str::FromStr};
3
4#[derive(Debug, Clone, Serialize, Deserialize)]
5pub struct AppConfig {
6    pub bind_addr: SocketAddr,
7    pub primary_url: String,
8    pub candidate_url: String,
9    pub secondary_url: String,
10    pub enable_secondary: bool,
11    pub return_target: ReturnTarget,
12    pub return_fallback: ReturnFallback,
13    pub response_timing: ResponseTiming,
14    pub max_body_capture_bytes: usize,
15    pub redact_headers: Vec<String>,
16    pub ignored_json_paths: Vec<String>,
17    pub ignored_headers: Vec<String>,
18    pub ignore_stderr: bool,
19    pub storage_path: PathBuf,
20}
21
22#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
23#[serde(rename_all = "snake_case")]
24pub enum ReturnTarget {
25    Primary,
26    Candidate,
27}
28
29impl FromStr for ReturnTarget {
30    type Err = anyhow::Error;
31
32    fn from_str(value: &str) -> Result<Self, Self::Err> {
33        match value {
34            "primary" => Ok(Self::Primary),
35            "candidate" => Ok(Self::Candidate),
36            other => {
37                anyhow::bail!("invalid MOONLIGHT_RETURN_TARGET {other:?}; use primary or candidate")
38            }
39        }
40    }
41}
42
43#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
44#[serde(rename_all = "snake_case")]
45pub enum ReturnFallback {
46    None,
47    Primary,
48}
49
50impl FromStr for ReturnFallback {
51    type Err = anyhow::Error;
52
53    fn from_str(value: &str) -> Result<Self, Self::Err> {
54        match value {
55            "none" => Ok(Self::None),
56            "primary" => Ok(Self::Primary),
57            other => {
58                anyhow::bail!("invalid MOONLIGHT_RETURN_FALLBACK {other:?}; use none or primary")
59            }
60        }
61    }
62}
63
64#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
65#[serde(rename_all = "snake_case")]
66pub enum ResponseTiming {
67    WaitAll,
68    ReturnSelected,
69}
70
71impl FromStr for ResponseTiming {
72    type Err = anyhow::Error;
73
74    fn from_str(value: &str) -> Result<Self, Self::Err> {
75        match value {
76            "wait_all" => Ok(Self::WaitAll),
77            "return_selected" => Ok(Self::ReturnSelected),
78            other => anyhow::bail!(
79                "invalid MOONLIGHT_RESPONSE_TIMING {other:?}; use wait_all or return_selected"
80            ),
81        }
82    }
83}
84
85impl AppConfig {
86    pub fn from_env() -> anyhow::Result<Self> {
87        Self::from_lookup(|key| env::var(key).ok())
88    }
89
90    fn from_lookup(get: impl Fn(&str) -> Option<String>) -> anyhow::Result<Self> {
91        Ok(Self {
92            bind_addr: env_or(&get, "MOONLIGHT_BIND_ADDR", "127.0.0.1:8080").parse()?,
93            primary_url: normalize_base_url(env_or(
94                &get,
95                "MOONLIGHT_PRIMARY_URL",
96                "http://127.0.0.1:3001",
97            )),
98            candidate_url: normalize_base_url(env_or(
99                &get,
100                "MOONLIGHT_CANDIDATE_URL",
101                "http://127.0.0.1:3002",
102            )),
103            secondary_url: normalize_base_url(env_or(
104                &get,
105                "MOONLIGHT_SECONDARY_URL",
106                "http://127.0.0.1:3003",
107            )),
108            enable_secondary: env_bool(&get, "MOONLIGHT_ENABLE_SECONDARY", true),
109            return_target: env_or(&get, "MOONLIGHT_RETURN_TARGET", "primary").parse()?,
110            return_fallback: env_or(&get, "MOONLIGHT_RETURN_FALLBACK", "none").parse()?,
111            response_timing: env_or(&get, "MOONLIGHT_RESPONSE_TIMING", "wait_all").parse()?,
112            max_body_capture_bytes: env_or(&get, "MOONLIGHT_MAX_BODY_CAPTURE_BYTES", "8192")
113                .parse()?,
114            redact_headers: env_list(
115                &get,
116                "MOONLIGHT_REDACT_HEADERS",
117                &["authorization", "cookie", "set-cookie", "x-api-key"],
118            ),
119            ignored_json_paths: env_list(
120                &get,
121                "MOONLIGHT_IGNORED_JSON_PATHS",
122                &["$.timestamp", "$.requestId", "$.traceId", "$.id"],
123            ),
124            ignored_headers: env_list(
125                &get,
126                "MOONLIGHT_IGNORED_HEADERS",
127                &[
128                    "date",
129                    "server",
130                    "set-cookie",
131                    "x-request-id",
132                    "traceparent",
133                ],
134            ),
135            ignore_stderr: env_bool(&get, "MOONLIGHT_IGNORE_STDERR", false),
136            storage_path: PathBuf::from(env_or(
137                &get,
138                "MOONLIGHT_STORAGE_PATH",
139                "data/moonlight/http-runs.jsonl",
140            )),
141        })
142    }
143}
144
145fn env_or(get: &impl Fn(&str) -> Option<String>, key: &str, default: &str) -> String {
146    get(key).unwrap_or_else(|| default.to_string())
147}
148
149fn env_bool(get: &impl Fn(&str) -> Option<String>, key: &str, default: bool) -> bool {
150    get(key)
151        .and_then(|value| match value.to_ascii_lowercase().as_str() {
152            "1" | "true" | "yes" | "on" => Some(true),
153            "0" | "false" | "no" | "off" => Some(false),
154            _ => None,
155        })
156        .unwrap_or(default)
157}
158
159fn env_list(get: &impl Fn(&str) -> Option<String>, key: &str, defaults: &[&str]) -> Vec<String> {
160    get(key)
161        .map(|value| {
162            value
163                .split(',')
164                .map(|item| item.trim().to_ascii_lowercase())
165                .filter(|item| !item.is_empty())
166                .collect()
167        })
168        .unwrap_or_else(|| defaults.iter().map(|item| item.to_string()).collect())
169}
170
171fn normalize_base_url(value: String) -> String {
172    value.trim_end_matches('/').to_string()
173}
174
175#[cfg(test)]
176mod tests {
177    use super::*;
178    use std::collections::HashMap;
179
180    fn config_from(values: &[(&str, &str)]) -> anyhow::Result<AppConfig> {
181        let values: HashMap<String, String> = values
182            .iter()
183            .map(|(key, value)| ((*key).to_string(), (*value).to_string()))
184            .collect();
185        AppConfig::from_lookup(|key| values.get(key).cloned())
186    }
187
188    #[test]
189    fn response_timing_defaults_to_wait_all() {
190        let config = config_from(&[]).unwrap();
191        assert_eq!(config.response_timing, ResponseTiming::WaitAll);
192    }
193
194    #[test]
195    fn response_timing_parses_return_selected() {
196        let config = config_from(&[("MOONLIGHT_RESPONSE_TIMING", "return_selected")]).unwrap();
197        assert_eq!(config.response_timing, ResponseTiming::ReturnSelected);
198    }
199
200    #[test]
201    fn invalid_response_timing_returns_error() {
202        let error = config_from(&[("MOONLIGHT_RESPONSE_TIMING", "fast")]).unwrap_err();
203        assert!(error
204            .to_string()
205            .contains("invalid MOONLIGHT_RESPONSE_TIMING"));
206    }
207
208    #[test]
209    fn return_target_defaults_to_primary() {
210        let config = config_from(&[]).unwrap();
211        assert_eq!(config.return_target, ReturnTarget::Primary);
212    }
213
214    #[test]
215    fn return_fallback_defaults_to_none() {
216        let config = config_from(&[]).unwrap();
217        assert_eq!(config.return_fallback, ReturnFallback::None);
218    }
219}