1use serde::{Deserialize, Serialize};
6use std::path::PathBuf;
7
8#[derive(Debug, Clone, Serialize, Deserialize)]
10pub struct ConfigWarning {
11 pub var: String,
13 pub message: String,
15 pub severity: Severity,
17}
18
19impl ConfigWarning {
20 pub fn warning(var: impl Into<String>, message: impl Into<String>) -> Self {
22 Self {
23 var: var.into(),
24 message: message.into(),
25 severity: Severity::Warning,
26 }
27 }
28
29 pub fn error(var: impl Into<String>, message: impl Into<String>) -> Self {
31 Self {
32 var: var.into(),
33 message: message.into(),
34 severity: Severity::Error,
35 }
36 }
37
38 pub fn info(var: impl Into<String>, message: impl Into<String>) -> Self {
40 Self {
41 var: var.into(),
42 message: message.into(),
43 severity: Severity::Info,
44 }
45 }
46}
47
48impl std::fmt::Display for ConfigWarning {
49 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
50 write!(f, "[{}] {}: {}", self.severity, self.var, self.message)
51 }
52}
53
54#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
56#[serde(rename_all = "lowercase")]
57pub enum Severity {
58 Info,
60 Warning,
62 Error,
64}
65
66impl std::fmt::Display for Severity {
67 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
68 match self {
69 Severity::Info => write!(f, "INFO"),
70 Severity::Warning => write!(f, "WARN"),
71 Severity::Error => write!(f, "ERROR"),
72 }
73 }
74}
75
76#[derive(Debug, Clone, Default)]
78pub struct ConfigToValidate {
79 pub daemon_timeout_ms: Option<u64>,
81 pub zstd_level: Option<i32>,
83 pub ssh_key_path: Option<PathBuf>,
85 pub mock_ssh: bool,
87 pub test_mode: bool,
89 pub circuit_failure_threshold: Option<u32>,
91 pub circuit_reset_timeout_sec: Option<u64>,
93 pub log_level: Option<String>,
95}
96
97pub fn validate_config(config: &ConfigToValidate) -> Vec<ConfigWarning> {
102 let mut warnings = Vec::new();
103
104 if let Some(timeout_ms) = config.daemon_timeout_ms {
106 if timeout_ms < 100 {
107 warnings.push(ConfigWarning::warning(
108 "RCH_DAEMON_TIMEOUT_MS",
109 "Timeout less than 100ms may cause premature failures",
110 ));
111 }
112 if timeout_ms > 60000 {
113 warnings.push(ConfigWarning::warning(
114 "RCH_DAEMON_TIMEOUT_MS",
115 "Timeout greater than 60s may cause unresponsive behavior",
116 ));
117 }
118 }
119
120 if let Some(level) = config.zstd_level {
122 if level > 19 {
123 warnings.push(ConfigWarning::warning(
124 "RCH_TRANSFER_ZSTD_LEVEL",
125 "Zstd level > 19 uses excessive CPU for minimal gain",
126 ));
127 }
128 if level < 1 {
129 warnings.push(ConfigWarning::warning(
130 "RCH_TRANSFER_ZSTD_LEVEL",
131 "Zstd level < 1 is invalid, using default",
132 ));
133 }
134 }
135
136 if let Some(ref key_path) = config.ssh_key_path
138 && !config.mock_ssh
139 {
140 if !key_path.exists() {
141 warnings.push(ConfigWarning::error(
142 "RCH_SSH_KEY",
143 format!("SSH key not found: {:?}", key_path),
144 ));
145 } else if key_path.is_dir() {
146 warnings.push(ConfigWarning::error(
147 "RCH_SSH_KEY",
148 format!("SSH key path is a directory, not a file: {:?}", key_path),
149 ));
150 } else if key_path.is_symlink() {
151 match key_path.canonicalize() {
153 Ok(canonical) => {
154 if !canonical.is_file() {
155 warnings.push(ConfigWarning::error(
156 "RCH_SSH_KEY",
157 format!(
158 "SSH key symlink {:?} resolves to non-file: {:?}",
159 key_path, canonical
160 ),
161 ));
162 }
163 }
166 Err(e) => {
167 warnings.push(ConfigWarning::error(
168 "RCH_SSH_KEY",
169 format!("SSH key symlink {:?} cannot be resolved: {}", key_path, e),
170 ));
171 }
172 }
173 } else if !key_path.is_file() {
174 warnings.push(ConfigWarning::error(
176 "RCH_SSH_KEY",
177 format!("SSH key path is not a regular file: {:?}", key_path),
178 ));
179 }
180 }
185
186 if config.mock_ssh && !config.test_mode {
188 warnings.push(ConfigWarning::warning(
189 "RCH_MOCK_SSH",
190 "Mock SSH enabled outside test mode - builds won't actually compile remotely",
191 ));
192 }
193
194 if let Some(threshold) = config.circuit_failure_threshold {
196 if threshold == 0 {
197 warnings.push(ConfigWarning::warning(
198 "RCH_CIRCUIT_FAILURE_THRESHOLD",
199 "Threshold of 0 means circuit will never open",
200 ));
201 }
202 if threshold > 100 {
203 warnings.push(ConfigWarning::warning(
204 "RCH_CIRCUIT_FAILURE_THRESHOLD",
205 "Very high threshold may delay failure detection",
206 ));
207 }
208 }
209
210 if let Some(timeout_sec) = config.circuit_reset_timeout_sec {
211 if timeout_sec < 5 {
212 warnings.push(ConfigWarning::warning(
213 "RCH_CIRCUIT_RESET_TIMEOUT_SEC",
214 "Reset timeout < 5s may cause rapid circuit state changes",
215 ));
216 }
217 if timeout_sec > 600 {
218 warnings.push(ConfigWarning::warning(
219 "RCH_CIRCUIT_RESET_TIMEOUT_SEC",
220 "Reset timeout > 10 minutes may delay recovery",
221 ));
222 }
223 }
224
225 if let Some(ref level) = config.log_level {
227 let valid_levels = ["trace", "debug", "info", "warn", "error", "off"];
228 if !valid_levels.contains(&level.to_lowercase().as_str()) {
229 warnings.push(ConfigWarning::error(
230 "RCH_LOG_LEVEL",
231 format!(
232 "Invalid log level '{}', expected one of: {:?}",
233 level, valid_levels
234 ),
235 ));
236 }
237 }
238
239 warnings
240}
241
242pub fn has_errors(warnings: &[ConfigWarning]) -> bool {
244 warnings
245 .iter()
246 .any(|w| matches!(w.severity, Severity::Error))
247}
248
249pub fn filter_by_severity(warnings: &[ConfigWarning], severity: Severity) -> Vec<&ConfigWarning> {
251 warnings.iter().filter(|w| w.severity == severity).collect()
252}
253
254#[cfg(test)]
255mod tests {
256 use super::*;
257
258 #[test]
259 fn test_validate_low_timeout() {
260 let config = ConfigToValidate {
261 daemon_timeout_ms: Some(50),
262 ..Default::default()
263 };
264 let warnings = validate_config(&config);
265 assert!(warnings.iter().any(|w| w.var == "RCH_DAEMON_TIMEOUT_MS"));
266 }
267
268 #[test]
269 fn test_validate_high_zstd_level() {
270 let config = ConfigToValidate {
271 zstd_level: Some(20),
272 ..Default::default()
273 };
274 let warnings = validate_config(&config);
275 assert!(warnings.iter().any(|w| w.var == "RCH_TRANSFER_ZSTD_LEVEL"));
276 }
277
278 #[test]
279 fn test_validate_mock_ssh_without_test_mode() {
280 let config = ConfigToValidate {
281 mock_ssh: true,
282 test_mode: false,
283 ..Default::default()
284 };
285 let warnings = validate_config(&config);
286 assert!(warnings.iter().any(|w| w.var == "RCH_MOCK_SSH"));
287 }
288
289 #[test]
290 fn test_validate_mock_ssh_with_test_mode() {
291 let config = ConfigToValidate {
292 mock_ssh: true,
293 test_mode: true,
294 ..Default::default()
295 };
296 let warnings = validate_config(&config);
297 assert!(!warnings.iter().any(|w| w.var == "RCH_MOCK_SSH"));
298 }
299
300 #[test]
301 fn test_validate_missing_ssh_key() {
302 let config = ConfigToValidate {
303 ssh_key_path: Some(PathBuf::from("/nonexistent/path/key")),
304 mock_ssh: false,
305 ..Default::default()
306 };
307 let warnings = validate_config(&config);
308 assert!(
309 warnings
310 .iter()
311 .any(|w| w.var == "RCH_SSH_KEY" && matches!(w.severity, Severity::Error))
312 );
313 }
314
315 #[test]
316 fn test_validate_missing_ssh_key_with_mock() {
317 let config = ConfigToValidate {
318 ssh_key_path: Some(PathBuf::from("/nonexistent/path/key")),
319 mock_ssh: true,
320 test_mode: true,
321 ..Default::default()
322 };
323 let warnings = validate_config(&config);
324 assert!(!warnings.iter().any(|w| w.var == "RCH_SSH_KEY"));
326 }
327
328 #[test]
329 fn test_validate_circuit_breaker_zero_threshold() {
330 let config = ConfigToValidate {
331 circuit_failure_threshold: Some(0),
332 ..Default::default()
333 };
334 let warnings = validate_config(&config);
335 assert!(
336 warnings
337 .iter()
338 .any(|w| w.var == "RCH_CIRCUIT_FAILURE_THRESHOLD")
339 );
340 }
341
342 #[test]
343 fn test_validate_invalid_log_level() {
344 let config = ConfigToValidate {
345 log_level: Some("verbose".to_string()),
346 ..Default::default()
347 };
348 let warnings = validate_config(&config);
349 assert!(
350 warnings
351 .iter()
352 .any(|w| w.var == "RCH_LOG_LEVEL" && matches!(w.severity, Severity::Error))
353 );
354 }
355
356 #[test]
357 fn test_validate_valid_log_level() {
358 let config = ConfigToValidate {
359 log_level: Some("debug".to_string()),
360 ..Default::default()
361 };
362 let warnings = validate_config(&config);
363 assert!(!warnings.iter().any(|w| w.var == "RCH_LOG_LEVEL"));
364 }
365
366 #[test]
367 fn test_has_errors() {
368 let warnings = vec![
369 ConfigWarning::warning("A", "warning"),
370 ConfigWarning::info("B", "info"),
371 ];
372 assert!(!has_errors(&warnings));
373
374 let warnings_with_error = vec![
375 ConfigWarning::warning("A", "warning"),
376 ConfigWarning::error("B", "error"),
377 ];
378 assert!(has_errors(&warnings_with_error));
379 }
380
381 #[test]
382 fn test_filter_by_severity() {
383 let warnings = vec![
384 ConfigWarning::warning("A", "warning1"),
385 ConfigWarning::error("B", "error1"),
386 ConfigWarning::warning("C", "warning2"),
387 ConfigWarning::info("D", "info1"),
388 ];
389
390 let errors = filter_by_severity(&warnings, Severity::Error);
391 assert_eq!(errors.len(), 1);
392 assert_eq!(errors[0].var, "B");
393
394 let warnings_only = filter_by_severity(&warnings, Severity::Warning);
395 assert_eq!(warnings_only.len(), 2);
396 }
397}