1use std::collections::HashMap;
8
9#[derive(Debug, Clone, PartialEq)]
11pub enum ValidationRule {
12 Required,
14 Integer,
16 Float,
18 Boolean,
20 String,
22 IntRange(i64, i64),
24 FloatRange(f64, f64),
26 LengthRange(usize, usize),
28 Enum(Vec<String>),
30 Prefix(String),
32 Suffix(String),
34 Contains(String),
36}
37
38#[derive(Debug, Clone)]
40pub struct FieldSchema {
41 pub name: String,
43 pub rules: Vec<ValidationRule>,
45 pub default: Option<String>,
47 pub description: String,
49}
50
51impl FieldSchema {
52 pub fn new(name: &str) -> Self {
54 Self {
55 name: name.to_string(),
56 rules: Vec::new(),
57 default: None,
58 description: String::new(),
59 }
60 }
61
62 pub fn with_rule(mut self, rule: ValidationRule) -> Self {
64 self.rules.push(rule);
65 self
66 }
67
68 pub fn with_default(mut self, value: &str) -> Self {
70 self.default = Some(value.to_string());
71 self
72 }
73
74 pub fn with_description(mut self, desc: &str) -> Self {
76 self.description = desc.to_string();
77 self
78 }
79
80 pub fn validate(&self, value: Option<&str>) -> ValidationResult {
82 let value = match value {
83 Some(v) => v,
84 None => match &self.default {
85 Some(d) => d.as_str(),
86 None => {
87 if self.rules.contains(&ValidationRule::Required) {
88 return ValidationResult::failed(&self.name, "required field is missing");
89 }
90 return ValidationResult::passed(&self.name);
91 }
92 },
93 };
94
95 if value.is_empty() && self.rules.contains(&ValidationRule::Required) {
96 return ValidationResult::failed(&self.name, "required field is empty");
97 }
98
99 for rule in &self.rules {
100 if let Some(msg) = check_rule(rule, value) {
101 return ValidationResult::failed(&self.name, &msg);
102 }
103 }
104
105 ValidationResult::passed(&self.name)
106 }
107}
108
109#[derive(Debug, Clone, Default)]
111pub struct ConfigSchema {
112 fields: Vec<FieldSchema>,
113}
114
115impl ConfigSchema {
116 pub fn new() -> Self {
118 Self::default()
119 }
120
121 pub fn add_field(mut self, field: FieldSchema) -> Self {
123 self.fields.push(field);
124 self
125 }
126
127 pub fn field_count(&self) -> usize {
129 self.fields.len()
130 }
131
132 pub fn validate(&self, config: &HashMap<String, String>) -> ConfigValidationReport {
134 let mut results = Vec::with_capacity(self.fields.len());
135 for field in &self.fields {
136 let value = config.get(&field.name).map(|s| s.as_str());
137 results.push(field.validate(value));
138 }
139 ConfigValidationReport { results }
140 }
141
142 pub fn validate_and_fill_defaults(
144 &self,
145 config: &HashMap<String, String>,
146 ) -> (HashMap<String, String>, ConfigValidationReport) {
147 let mut filled = config.clone();
148 for field in &self.fields {
149 if !filled.contains_key(&field.name) {
150 if let Some(default) = &field.default {
151 filled.insert(field.name.clone(), default.clone());
152 }
153 }
154 }
155 let report = self.validate(&filled);
156 (filled, report)
157 }
158
159 pub fn validate_env(&self) -> ConfigValidationReport {
163 let mut env_config: HashMap<String, String> = HashMap::new();
164 for field in &self.fields {
165 if let Ok(val) = std::env::var(&field.name) {
166 env_config.insert(field.name.clone(), val);
167 }
168 }
169 let (filled, _) = self.validate_and_fill_defaults(&env_config);
170 self.validate(&filled)
171 }
172
173 pub fn field_names(&self) -> Vec<&str> {
175 self.fields.iter().map(|f| f.name.as_str()).collect()
176 }
177}
178
179#[derive(Debug, Clone, PartialEq, Eq)]
181pub struct ValidationResult {
182 pub field: String,
184 pub passed: bool,
186 pub message: String,
188}
189
190impl ValidationResult {
191 fn passed(field: &str) -> Self {
192 Self {
193 field: field.to_string(),
194 passed: true,
195 message: String::new(),
196 }
197 }
198
199 fn failed(field: &str, message: &str) -> Self {
200 Self {
201 field: field.to_string(),
202 passed: false,
203 message: message.to_string(),
204 }
205 }
206}
207
208#[derive(Debug, Clone, PartialEq, Eq)]
210pub struct ConfigValidationReport {
211 results: Vec<ValidationResult>,
212}
213
214impl ConfigValidationReport {
215 pub fn is_valid(&self) -> bool {
217 self.results.iter().all(|r| r.passed)
218 }
219
220 pub fn failure_count(&self) -> usize {
222 self.results.iter().filter(|r| !r.passed).count()
223 }
224
225 pub fn pass_count(&self) -> usize {
227 self.results.iter().filter(|r| r.passed).count()
228 }
229
230 pub fn total(&self) -> usize {
232 self.results.len()
233 }
234
235 pub fn failures(&self) -> Vec<&ValidationResult> {
237 self.results.iter().filter(|r| !r.passed).collect()
238 }
239
240 pub fn passed_fields(&self) -> Vec<&ValidationResult> {
242 self.results.iter().filter(|r| r.passed).collect()
243 }
244
245 pub fn to_summary(&self) -> String {
247 if self.is_valid() {
248 format!("Config validation: all {} field(s) passed", self.total())
249 } else {
250 let mut out = format!(
251 "Config validation: {}/{} field(s) failed\n",
252 self.failure_count(),
253 self.total()
254 );
255 for f in self.failures() {
256 out.push_str(&format!(" X {}: {}\n", f.field, f.message));
257 }
258 out
259 }
260 }
261
262 pub fn merge(&self, other: &ConfigValidationReport) -> ConfigValidationReport {
264 let mut combined = self.results.clone();
265 combined.extend(other.results.iter().cloned());
266 ConfigValidationReport { results: combined }
267 }
268
269 pub fn filter_by_field(&self, field: &str) -> Vec<&ValidationResult> {
271 self.results.iter().filter(|r| r.field == field).collect()
272 }
273
274 pub fn all_results(&self) -> &[ValidationResult] {
276 &self.results
277 }
278}
279
280fn check_rule(rule: &ValidationRule, value: &str) -> Option<String> {
282 match rule {
283 ValidationRule::Required => {
284 if value.is_empty() {
285 Some("required field is empty".to_string())
286 } else {
287 None
288 }
289 }
290 ValidationRule::Integer => {
291 if value.parse::<i64>().is_err() {
292 Some(format!("expected integer, got '{value}'"))
293 } else {
294 None
295 }
296 }
297 ValidationRule::Float => {
298 if value.parse::<f64>().is_err() {
299 Some(format!("expected float, got '{value}'"))
300 } else {
301 None
302 }
303 }
304 ValidationRule::Boolean => {
305 if value.parse::<bool>().is_err() {
306 Some(format!("expected boolean (true/false), got '{value}'"))
307 } else {
308 None
309 }
310 }
311 ValidationRule::String => None,
312 ValidationRule::IntRange(min, max) => match value.parse::<i64>() {
313 Ok(n) if (*min..=*max).contains(&n) => None,
314 Ok(n) => Some(format!("integer {n} out of range [{min}, {max}]")),
315 Err(_) => Some(format!("expected integer for range check, got '{value}'")),
316 },
317 ValidationRule::FloatRange(min, max) => match value.parse::<f64>() {
318 Ok(n) if (*min..=*max).contains(&n) => None,
319 Ok(n) => Some(format!("float {n} out of range [{min}, {max}]")),
320 Err(_) => Some(format!("expected float for range check, got '{value}'")),
321 },
322 ValidationRule::LengthRange(min, max) => {
323 let len = value.chars().count();
324 if (*min..=*max).contains(&len) {
325 None
326 } else {
327 Some(format!("length {len} out of range [{min}, {max}]"))
328 }
329 }
330 ValidationRule::Enum(allowed) => {
331 if allowed.iter().any(|a| a == value) {
332 None
333 } else {
334 Some(format!(
335 "value '{value}' not in allowed list: {:?}",
336 allowed
337 ))
338 }
339 }
340 ValidationRule::Prefix(prefix) => {
341 if value.starts_with(prefix) {
342 None
343 } else {
344 Some(format!("value '{value}' does not start with '{prefix}'"))
345 }
346 }
347 ValidationRule::Suffix(suffix) => {
348 if value.ends_with(suffix) {
349 None
350 } else {
351 Some(format!("value '{value}' does not end with '{suffix}'"))
352 }
353 }
354 ValidationRule::Contains(substring) => {
355 if value.contains(substring) {
356 None
357 } else {
358 Some(format!("value '{value}' does not contain '{substring}'"))
359 }
360 }
361 }
362}
363
364#[cfg(test)]
365mod tests {
366 use super::*;
367
368 fn config(items: &[(&str, &str)]) -> HashMap<String, String> {
369 items
370 .iter()
371 .map(|(k, v)| (k.to_string(), v.to_string()))
372 .collect()
373 }
374
375 #[test]
376 fn validate_required_present() {
377 let schema = FieldSchema::new("port").with_rule(ValidationRule::Required);
378 let result = schema.validate(Some("8080"));
379 assert!(result.passed);
380 }
381
382 #[test]
383 fn validate_required_missing() {
384 let schema = FieldSchema::new("port").with_rule(ValidationRule::Required);
385 let result = schema.validate(None);
386 assert!(!result.passed);
387 assert!(result.message.contains("missing"));
388 }
389
390 #[test]
391 fn validate_required_empty() {
392 let schema = FieldSchema::new("port").with_rule(ValidationRule::Required);
393 let result = schema.validate(Some(""));
394 assert!(!result.passed);
395 }
396
397 #[test]
398 fn validate_integer_pass() {
399 let schema = FieldSchema::new("port").with_rule(ValidationRule::Integer);
400 assert!(schema.validate(Some("8080")).passed);
401 }
402
403 #[test]
404 fn validate_integer_fail() {
405 let schema = FieldSchema::new("port").with_rule(ValidationRule::Integer);
406 assert!(!schema.validate(Some("abc")).passed);
407 }
408
409 #[test]
410 fn validate_float_pass() {
411 let schema = FieldSchema::new("ratio").with_rule(ValidationRule::Float);
412 assert!(schema.validate(Some("0.95")).passed);
413 }
414
415 #[test]
416 fn validate_boolean_pass() {
417 let schema = FieldSchema::new("enabled").with_rule(ValidationRule::Boolean);
418 assert!(schema.validate(Some("true")).passed);
419 assert!(schema.validate(Some("false")).passed);
420 }
421
422 #[test]
423 fn validate_boolean_fail() {
424 let schema = FieldSchema::new("enabled").with_rule(ValidationRule::Boolean);
425 assert!(!schema.validate(Some("yes")).passed);
426 }
427
428 #[test]
429 fn validate_int_range_pass() {
430 let schema = FieldSchema::new("port").with_rule(ValidationRule::IntRange(1, 65535));
431 assert!(schema.validate(Some("8080")).passed);
432 }
433
434 #[test]
435 fn validate_int_range_fail() {
436 let schema = FieldSchema::new("port").with_rule(ValidationRule::IntRange(1, 65535));
437 assert!(!schema.validate(Some("99999")).passed);
438 }
439
440 #[test]
441 fn validate_length_range() {
442 let schema = FieldSchema::new("name").with_rule(ValidationRule::LengthRange(3, 10));
443 assert!(schema.validate(Some("hello")).passed);
444 assert!(!schema.validate(Some("hi")).passed);
445 assert!(!schema.validate(Some("this_is_too_long")).passed);
446 }
447
448 #[test]
449 fn validate_enum_pass() {
450 let schema = FieldSchema::new("level").with_rule(ValidationRule::Enum(vec![
451 "debug".to_string(),
452 "info".to_string(),
453 "warn".to_string(),
454 ]));
455 assert!(schema.validate(Some("info")).passed);
456 }
457
458 #[test]
459 fn validate_enum_fail() {
460 let schema = FieldSchema::new("level").with_rule(ValidationRule::Enum(vec![
461 "debug".to_string(),
462 "info".to_string(),
463 ]));
464 assert!(!schema.validate(Some("trace")).passed);
465 }
466
467 #[test]
468 fn validate_prefix() {
469 let schema = FieldSchema::new("url").with_rule(ValidationRule::Prefix("http".to_string()));
470 assert!(schema.validate(Some("http://example.com")).passed);
471 assert!(!schema.validate(Some("ftp://x")).passed);
472 }
473
474 #[test]
475 fn validate_suffix() {
476 let schema = FieldSchema::new("file").with_rule(ValidationRule::Suffix(".rs".to_string()));
477 assert!(schema.validate(Some("main.rs")).passed);
478 assert!(!schema.validate(Some("main.go")).passed);
479 }
480
481 #[test]
482 fn validate_contains() {
483 let schema =
484 FieldSchema::new("conn").with_rule(ValidationRule::Contains("://".to_string()));
485 assert!(schema.validate(Some("mysql://localhost")).passed);
486 assert!(!schema.validate(Some("localhost")).passed);
487 }
488
489 #[test]
490 fn validate_with_default() {
491 let schema = FieldSchema::new("port")
492 .with_rule(ValidationRule::Integer)
493 .with_default("8080");
494 let result = schema.validate(None);
495 assert!(result.passed);
496 }
497
498 #[test]
499 fn schema_validate_all_pass() {
500 let schema = ConfigSchema::new()
501 .add_field(FieldSchema::new("host").with_rule(ValidationRule::Required))
502 .add_field(FieldSchema::new("port").with_rule(ValidationRule::IntRange(1, 65535)));
503 let report = schema.validate(&config(&[("host", "localhost"), ("port", "8080")]));
504 assert!(report.is_valid());
505 assert_eq!(report.pass_count(), 2);
506 assert_eq!(report.failure_count(), 0);
507 }
508
509 #[test]
510 fn schema_validate_with_failures() {
511 let schema = ConfigSchema::new()
512 .add_field(FieldSchema::new("host").with_rule(ValidationRule::Required))
513 .add_field(FieldSchema::new("port").with_rule(ValidationRule::IntRange(1, 65535)));
514 let report = schema.validate(&config(&[("port", "99999")]));
515 assert!(!report.is_valid());
516 assert_eq!(report.failure_count(), 2);
517 }
518
519 #[test]
520 fn schema_fill_defaults() {
521 let schema = ConfigSchema::new().add_field(FieldSchema::new("port").with_default("8080"));
522 let (filled, report) = schema.validate_and_fill_defaults(&HashMap::new());
523 assert_eq!(filled.get("port").map(|s| s.as_str()), Some("8080"));
524 assert!(report.is_valid());
525 }
526
527 #[test]
528 fn report_to_summary_valid() {
529 let schema = ConfigSchema::new()
530 .add_field(FieldSchema::new("x").with_rule(ValidationRule::Required));
531 let report = schema.validate(&config(&[("x", "1")]));
532 assert!(report.to_summary().contains("passed"));
533 }
534
535 #[test]
536 fn report_to_summary_invalid() {
537 let schema = ConfigSchema::new()
538 .add_field(FieldSchema::new("x").with_rule(ValidationRule::Required));
539 let report = schema.validate(&HashMap::new());
540 let summary = report.to_summary();
541 assert!(summary.contains("failed"));
542 }
543
544 #[test]
545 fn report_total_and_counts() {
546 let schema = ConfigSchema::new()
547 .add_field(FieldSchema::new("a").with_rule(ValidationRule::Required))
548 .add_field(FieldSchema::new("b"));
549 let report = schema.validate(&config(&[("a", "1")]));
550 assert_eq!(report.total(), 2);
551 assert_eq!(report.pass_count(), 2);
552 assert_eq!(report.failure_count(), 0);
553 }
554
555 #[test]
556 fn field_schema_builder_chain() {
557 let schema = FieldSchema::new("url")
558 .with_rule(ValidationRule::Required)
559 .with_rule(ValidationRule::Prefix("http".to_string()))
560 .with_default("http://localhost")
561 .with_description("Service URL");
562 assert_eq!(schema.name, "url");
563 assert_eq!(schema.rules.len(), 2);
564 assert_eq!(schema.default, Some("http://localhost".to_string()));
565 assert_eq!(schema.description, "Service URL");
566 }
567
568 #[test]
569 fn config_schema_field_count() {
570 let schema = ConfigSchema::new()
571 .add_field(FieldSchema::new("a"))
572 .add_field(FieldSchema::new("b"))
573 .add_field(FieldSchema::new("c"));
574 assert_eq!(schema.field_count(), 3);
575 }
576
577 #[test]
578 fn validate_float_range() {
579 let schema = FieldSchema::new("ratio").with_rule(ValidationRule::FloatRange(0.0, 1.0));
580 assert!(schema.validate(Some("0.5")).passed);
581 assert!(!schema.validate(Some("1.5")).passed);
582 }
583
584 #[test]
585 fn validate_multiple_rules_all_pass() {
586 let schema = FieldSchema::new("port")
587 .with_rule(ValidationRule::Required)
588 .with_rule(ValidationRule::Integer)
589 .with_rule(ValidationRule::IntRange(1, 65535));
590 assert!(schema.validate(Some("8080")).passed);
591 }
592
593 #[test]
594 fn validate_multiple_rules_one_fails() {
595 let schema = FieldSchema::new("port")
596 .with_rule(ValidationRule::Required)
597 .with_rule(ValidationRule::Integer)
598 .with_rule(ValidationRule::IntRange(1, 1024));
599 let result = schema.validate(Some("8080"));
600 assert!(!result.passed);
601 assert!(result.message.contains("range"));
602 }
603
604 #[test]
605 fn report_merge_combines_results() {
606 let r1 = ValidationResult::passed("a");
607 let r2 = ValidationResult::failed("b", "err");
608 let rep1 = ConfigValidationReport { results: vec![r1] };
609 let rep2 = ConfigValidationReport { results: vec![r2] };
610 let merged = rep1.merge(&rep2);
611 assert_eq!(merged.total(), 2);
612 assert_eq!(merged.failure_count(), 1);
613 }
614
615 #[test]
616 fn report_filter_by_field() {
617 let r1 = ValidationResult::passed("port");
618 let r2 = ValidationResult::failed("host", "err");
619 let rep = ConfigValidationReport {
620 results: vec![r1, r2],
621 };
622 assert_eq!(rep.filter_by_field("port").len(), 1);
623 assert_eq!(rep.filter_by_field("missing").len(), 0);
624 }
625
626 #[test]
627 fn schema_field_names() {
628 let schema = ConfigSchema::new()
629 .add_field(FieldSchema::new("host"))
630 .add_field(FieldSchema::new("port"));
631 assert_eq!(schema.field_names(), vec!["host", "port"]);
632 }
633
634 #[test]
635 fn report_all_results() {
636 let rep = ConfigValidationReport {
637 results: vec![ValidationResult::passed("a")],
638 };
639 assert_eq!(rep.all_results().len(), 1);
640 }
641}