forge_ops_tracker/
configuration.rs1use std::collections::HashSet;
2use std::env;
3use std::time::Duration;
4
5pub struct Configuration {
9 pub dsn: Option<String>,
10 pub environment: String,
11 pub release: Option<String>,
12 pub server_name: Option<String>,
13
14 pub app_root: Option<String>,
20
21 pub enabled_environments: HashSet<String>,
22 pub queue_size: usize,
23 pub timeout: Duration,
24 pub scrub_pii: bool,
25
26 pub install_panic_hook: bool,
33
34 pub capture_source_context: bool,
42}
43
44impl Configuration {
45 pub fn new() -> Self {
49 let mut enabled_environments = HashSet::new();
50 enabled_environments.insert("production".to_string());
51 enabled_environments.insert("staging".to_string());
52
53 Configuration {
54 dsn: env::var("FORGE_OPS_DSN").ok().filter(|s| !s.is_empty()),
55 environment: env::var("FORGE_OPS_ENVIRONMENT")
56 .unwrap_or_else(|_| "development".to_string()),
57 release: env::var("FORGE_OPS_RELEASE").ok().filter(|s| !s.is_empty()),
58 server_name: safe_hostname(),
59 app_root: env::current_dir()
60 .ok()
61 .map(|p| p.to_string_lossy().into_owned()),
62 enabled_environments,
63 queue_size: 1000,
64 timeout: Duration::from_secs(2),
65 scrub_pii: true,
66 install_panic_hook: true,
67 capture_source_context: true,
68 }
69 }
70
71 pub fn api_key(&self) -> Option<String> {
73 self.parsed_dsn().and_then(|d| d.api_key)
74 }
75
76 pub fn ingestion_uri(&self) -> Option<String> {
79 self.parsed_dsn().map(|d| d.ingestion_uri)
80 }
81
82 pub fn is_enabled(&self) -> bool {
83 self.dsn.is_some()
84 && self.api_key().is_some()
85 && self.enabled_environments.contains(&self.environment)
86 }
87
88 fn parsed_dsn(&self) -> Option<ParsedDsn> {
89 self.dsn.as_deref().and_then(parse_dsn)
90 }
91}
92
93impl Default for Configuration {
94 fn default() -> Self {
95 Self::new()
96 }
97}
98
99struct ParsedDsn {
100 api_key: Option<String>,
101 ingestion_uri: String,
102}
103
104fn parse_dsn(dsn: &str) -> Option<ParsedDsn> {
109 let (scheme, rest) = dsn.split_once("://")?;
110 if scheme.is_empty() {
111 return None;
112 }
113 let (userinfo, host_and_path) = rest.split_once('@')?;
114 if userinfo.is_empty() || host_and_path.is_empty() {
115 return None;
116 }
117
118 let api_key = percent_decode(userinfo);
119 Some(ParsedDsn {
120 api_key: if api_key.is_empty() {
121 None
122 } else {
123 Some(api_key)
124 },
125 ingestion_uri: format!("{scheme}://{host_and_path}"),
126 })
127}
128
129fn percent_decode(s: &str) -> String {
132 let bytes = s.as_bytes();
133 let mut out = Vec::with_capacity(bytes.len());
134 let mut i = 0;
135 while i < bytes.len() {
136 if bytes[i] == b'%' && i + 2 < bytes.len() {
137 if let Ok(byte) = u8::from_str_radix(&s[i + 1..i + 3], 16) {
138 out.push(byte);
139 i += 3;
140 continue;
141 }
142 }
143 out.push(bytes[i]);
144 i += 1;
145 }
146 String::from_utf8_lossy(&out).into_owned()
147}
148
149fn safe_hostname() -> Option<String> {
154 std::process::Command::new("hostname")
155 .output()
156 .ok()
157 .filter(|o| o.status.success())
158 .and_then(|o| String::from_utf8(o.stdout).ok())
159 .map(|s| s.trim().to_string())
160 .filter(|s| !s.is_empty())
161}
162
163#[cfg(test)]
164mod tests {
165 use super::*;
166
167 #[test]
168 fn api_key_and_ingestion_uri() {
169 let config = Configuration {
170 dsn: Some("https://abc123@forgeops.example/api/v1/events".to_string()),
171 ..Configuration::new()
172 };
173
174 assert_eq!(config.api_key(), Some("abc123".to_string()));
175 assert_eq!(
176 config.ingestion_uri(),
177 Some("https://forgeops.example/api/v1/events".to_string())
178 );
179 }
180
181 #[test]
182 fn api_key_percent_decodes() {
183 let config = Configuration {
184 dsn: Some("https://ab%2Fc@forgeops.example/api/v1/events".to_string()),
185 ..Configuration::new()
186 };
187
188 assert_eq!(config.api_key(), Some("ab/c".to_string()));
189 }
190
191 #[test]
192 fn empty_or_malformed_dsn() {
193 for dsn in [
194 "",
195 "not-a-url",
196 "://broken",
197 "https://forgeops.example/no-userinfo",
198 ] {
199 let config = Configuration {
200 dsn: Some(dsn.to_string()),
201 ..Configuration::new()
202 };
203 assert_eq!(config.api_key(), None, "dsn = {dsn:?}");
204 assert_eq!(config.ingestion_uri(), None, "dsn = {dsn:?}");
205 }
206 }
207
208 #[test]
209 fn is_enabled_requires_dsn_api_key_and_enabled_environment() {
210 let mut config = Configuration::new();
211 config.dsn = Some("https://key@host/path".to_string());
212
213 config.environment = "production".to_string();
214 assert!(config.is_enabled());
215
216 config.environment = "development".to_string();
217 assert!(!config.is_enabled());
218
219 config.environment = "production".to_string();
220 config.dsn = None;
221 assert!(!config.is_enabled());
222 }
223
224 #[test]
225 fn defaults() {
226 let config = Configuration::new();
227 assert_eq!(config.queue_size, 1000);
228 assert_eq!(config.timeout, Duration::from_secs(2));
229 assert!(config.scrub_pii);
230 assert!(config.capture_source_context);
231 assert!(config.enabled_environments.contains("production"));
232 assert!(config.enabled_environments.contains("staging"));
233 }
234}