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}