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