1use crate::error::{Result, TemplateError};
10use serde_json::Value;
11use std::collections::HashSet;
12
13#[derive(Clone)]
21pub struct TemplateValidator {
22 required_fields: HashSet<String>,
24 required_sections: HashSet<String>,
26 pub(crate) rules: Vec<ValidationRule>,
28 format: OutputFormat,
30 schema: Option<Value>,
32 toml_options: TomlValidationOptions,
34}
35
36#[derive(Debug, Clone, Default)]
38pub struct TomlValidationOptions {
39 pub allow_inline_tables: bool,
41 pub allow_multiline_strings: bool,
43 pub max_nesting_depth: Option<usize>,
45 pub max_array_length: Option<usize>,
47 pub max_string_length: Option<usize>,
49}
50
51#[derive(Debug, Clone, PartialEq, Default)]
53pub enum OutputFormat {
54 #[default]
56 Toml,
57 Json,
59 Yaml,
61 Auto,
63}
64
65impl Default for TemplateValidator {
66 fn default() -> Self {
67 Self {
68 required_fields: HashSet::new(),
69 required_sections: HashSet::new(),
70 rules: Vec::new(),
71 format: OutputFormat::Toml,
72 schema: None,
73 toml_options: TomlValidationOptions::default(),
74 }
75 }
76}
77
78impl TemplateValidator {
79 pub fn new() -> Self {
81 Self::default()
82 }
83
84 pub fn require_fields<I, S>(mut self, fields: I) -> Self
89 where
90 I: IntoIterator<Item = S>,
91 S: Into<String>,
92 {
93 for field in fields {
94 self.required_fields.insert(field.into());
95 }
96 self
97 }
98
99 pub fn require_sections<I, S>(mut self, sections: I) -> Self
104 where
105 I: IntoIterator<Item = S>,
106 S: Into<String>,
107 {
108 for section in sections {
109 self.required_sections.insert(section.into());
110 }
111 self
112 }
113
114 pub fn with_rule(mut self, rule: ValidationRule) -> Self {
116 self.rules.push(rule);
117 self
118 }
119
120 pub fn format(mut self, format: OutputFormat) -> Self {
122 self.format = format;
123 self
124 }
125
126 pub fn with_schema(mut self, schema: Value) -> Self {
131 self.schema = Some(schema);
132 self
133 }
134
135 pub fn with_toml_options(mut self, options: TomlValidationOptions) -> Self {
137 self.toml_options = options;
138 self
139 }
140
141 pub fn validate(&self, output: &str, template_name: &str) -> Result<()> {
147 let format = if matches!(self.format, OutputFormat::Auto) {
149 self.detect_format(output)
150 } else {
151 self.format.clone()
152 };
153
154 self.validate_format(&format, output, template_name)?;
156
157 let parsed = self.parse_content(&format, output, template_name)?;
159
160 self.validate_required_fields(&parsed, template_name)?;
162
163 if matches!(format, OutputFormat::Toml) {
165 self.validate_required_sections(&parsed, template_name)?;
166 }
167
168 if let Some(schema) = &self.schema {
170 self.validate_schema(&parsed, schema, template_name)?;
171 }
172
173 for rule in &self.rules {
175 rule.validate(&parsed, template_name)?;
176 }
177
178 if matches!(format, OutputFormat::Toml) {
180 self.validate_toml_structure(&parsed, template_name)?;
181 }
182
183 Ok(())
184 }
185
186 fn detect_format(&self, content: &str) -> OutputFormat {
188 let trimmed = content.trim();
189
190 if trimmed.starts_with('{') && trimmed.ends_with('}') {
191 OutputFormat::Json
192 } else if trimmed.starts_with('[') && trimmed.contains('=') {
193 OutputFormat::Toml
194 } else if trimmed.contains(": ") && !trimmed.contains('=') {
195 OutputFormat::Yaml
196 } else {
197 OutputFormat::Toml }
199 }
200
201 fn validate_format(
203 &self,
204 format: &OutputFormat,
205 content: &str,
206 template_name: &str,
207 ) -> Result<()> {
208 match format {
209 OutputFormat::Toml => self.validate_toml(content, template_name),
210 OutputFormat::Json => self.validate_json(content, template_name),
211 OutputFormat::Yaml => self.validate_yaml(content, template_name),
212 OutputFormat::Auto => Ok(()), }
214 }
215
216 fn validate_toml(&self, content: &str, template_name: &str) -> Result<()> {
218 toml::from_str::<Value>(content).map_err(|e| {
219 TemplateError::ValidationError(format!(
220 "Invalid TOML format in template '{}': {}",
221 template_name, e
222 ))
223 })?;
224 Ok(())
225 }
226
227 fn validate_json(&self, content: &str, template_name: &str) -> Result<()> {
229 serde_json::from_str::<Value>(content).map_err(|e| {
230 TemplateError::ValidationError(format!(
231 "Invalid JSON format in template '{}': {}",
232 template_name, e
233 ))
234 })?;
235 Ok(())
236 }
237
238 fn validate_yaml(&self, content: &str, template_name: &str) -> Result<()> {
240 serde_yaml::from_str::<Value>(content).map_err(|e| {
241 TemplateError::ValidationError(format!(
242 "Invalid YAML format in template '{}': {}",
243 template_name, e
244 ))
245 })?;
246 Ok(())
247 }
248
249 fn parse_content(
251 &self,
252 format: &OutputFormat,
253 content: &str,
254 template_name: &str,
255 ) -> Result<Value> {
256 match format {
257 OutputFormat::Toml => toml::from_str::<Value>(content).map_err(|e| {
258 TemplateError::ValidationError(format!(
259 "Failed to parse TOML in template '{}': {}",
260 template_name, e
261 ))
262 }),
263 OutputFormat::Json => serde_json::from_str::<Value>(content).map_err(|e| {
264 TemplateError::ValidationError(format!(
265 "Failed to parse JSON in template '{}': {}",
266 template_name, e
267 ))
268 }),
269 OutputFormat::Yaml => serde_yaml::from_str::<Value>(content).map_err(|e| {
270 TemplateError::ValidationError(format!(
271 "Failed to parse YAML in template '{}': {}",
272 template_name, e
273 ))
274 }),
275 OutputFormat::Auto => {
276 if let Ok(value) = toml::from_str::<Value>(content) {
278 Ok(value)
279 } else if let Ok(value) = serde_json::from_str::<Value>(content) {
280 Ok(value)
281 } else if let Ok(value) = serde_yaml::from_str::<Value>(content) {
282 Ok(value)
283 } else {
284 Err(TemplateError::ValidationError(format!(
285 "Could not parse template '{}' as TOML, JSON, or YAML",
286 template_name
287 )))
288 }
289 }
290 }
291 }
292
293 fn validate_required_fields(&self, parsed: &Value, template_name: &str) -> Result<()> {
295 for field_path in &self.required_fields {
296 if !self.field_exists(parsed, field_path) {
297 return Err(TemplateError::ValidationError(format!(
298 "Required field '{}' missing in template '{}'",
299 field_path, template_name
300 )));
301 }
302 }
303 Ok(())
304 }
305
306 fn validate_required_sections(&self, parsed: &Value, template_name: &str) -> Result<()> {
308 let obj = parsed.as_object().ok_or_else(|| {
309 TemplateError::ValidationError(format!(
310 "Template '{}' must be a TOML object for section validation",
311 template_name
312 ))
313 })?;
314
315 for section in &self.required_sections {
316 if !obj.contains_key(section) {
317 return Err(TemplateError::ValidationError(format!(
318 "Required section '{}' missing in template '{}'",
319 section, template_name
320 )));
321 }
322 }
323 Ok(())
324 }
325
326 fn validate_schema(&self, parsed: &Value, schema: &Value, template_name: &str) -> Result<()> {
328 if let (Some(obj), Some(schema_obj)) = (parsed.as_object(), schema.as_object()) {
330 if let Some(required) = schema_obj.get("required").and_then(|v| v.as_array()) {
332 for prop in required {
333 if let Some(prop_str) = prop.as_str() {
334 if !obj.contains_key(prop_str) {
335 return Err(TemplateError::ValidationError(format!(
336 "Schema validation failed: required property '{}' missing in template '{}'",
337 prop_str, template_name
338 )));
339 }
340 }
341 }
342 }
343
344 if let Some(properties) = schema_obj.get("properties").and_then(|v| v.as_object()) {
346 for (prop_name, prop_schema) in properties {
347 if let Some(prop_value) = obj.get(prop_name) {
348 self.validate_property_type(
349 prop_value,
350 prop_schema,
351 prop_name,
352 template_name,
353 )?;
354 }
355 }
356 }
357 }
358
359 Ok(())
360 }
361
362 fn validate_property_type(
364 &self,
365 value: &Value,
366 schema: &Value,
367 prop_name: &str,
368 template_name: &str,
369 ) -> Result<()> {
370 if let Some(expected_type) = schema.get("type").and_then(|v| v.as_str()) {
371 match expected_type {
372 "string" => {
373 if !value.is_string() {
374 return Err(TemplateError::ValidationError(format!(
375 "Schema validation failed: property '{}' must be string in template '{}'",
376 prop_name, template_name
377 )));
378 }
379 }
380 "number" => {
381 if !value.is_number() {
382 return Err(TemplateError::ValidationError(format!(
383 "Schema validation failed: property '{}' must be number in template '{}'",
384 prop_name, template_name
385 )));
386 }
387 }
388 "boolean" => {
389 if !value.is_boolean() {
390 return Err(TemplateError::ValidationError(format!(
391 "Schema validation failed: property '{}' must be boolean in template '{}'",
392 prop_name, template_name
393 )));
394 }
395 }
396 "array" => {
397 if !value.is_array() {
398 return Err(TemplateError::ValidationError(format!(
399 "Schema validation failed: property '{}' must be array in template '{}'",
400 prop_name, template_name
401 )));
402 }
403 }
404 "object" => {
405 if !value.is_object() {
406 return Err(TemplateError::ValidationError(format!(
407 "Schema validation failed: property '{}' must be object in template '{}'",
408 prop_name, template_name
409 )));
410 }
411 }
412 _ => {} }
414 }
415
416 Ok(())
417 }
418
419 fn field_exists(&self, value: &Value, field_path: &str) -> bool {
421 let parts: Vec<&str> = field_path.split('.').collect();
422 let mut current = value;
423
424 for part in parts {
425 match current {
426 Value::Object(obj) => {
427 if let Some(next) = obj.get(part) {
428 current = next;
429 } else {
430 return false;
431 }
432 }
433 _ => return false,
434 }
435 }
436
437 true
438 }
439
440 fn validate_toml_structure(&self, parsed: &Value, template_name: &str) -> Result<()> {
442 self.validate_toml_nesting(parsed, 0, template_name)?;
443 self.validate_toml_sizes(parsed, template_name)?;
444 Ok(())
445 }
446
447 fn validate_toml_nesting(
449 &self,
450 value: &Value,
451 depth: usize,
452 template_name: &str,
453 ) -> Result<()> {
454 if let Some(max_depth) = self.toml_options.max_nesting_depth {
455 if depth > max_depth {
456 return Err(TemplateError::ValidationError(format!(
457 "TOML nesting depth exceeds maximum {} in template '{}'",
458 max_depth, template_name
459 )));
460 }
461 }
462
463 match value {
464 Value::Object(obj) => {
465 for (_, value) in obj {
466 self.validate_toml_nesting(value, depth + 1, template_name)?;
467 }
468 }
469 Value::Array(arr) => {
470 for value in arr {
471 self.validate_toml_nesting(value, depth + 1, template_name)?;
472 }
473 }
474 _ => {}
475 }
476
477 Ok(())
478 }
479
480 fn validate_toml_sizes(&self, value: &Value, template_name: &str) -> Result<()> {
482 match value {
483 Value::Array(arr) => {
484 if let Some(max_len) = self.toml_options.max_array_length {
485 if arr.len() > max_len {
486 return Err(TemplateError::ValidationError(format!(
487 "Array length {} exceeds maximum {} in template '{}'",
488 arr.len(),
489 max_len,
490 template_name
491 )));
492 }
493 }
494 }
495 Value::String(s) => {
496 if let Some(max_len) = self.toml_options.max_string_length {
497 if s.len() > max_len {
498 return Err(TemplateError::ValidationError(format!(
499 "String length {} exceeds maximum {} in template '{}'",
500 s.len(),
501 max_len,
502 template_name
503 )));
504 }
505 }
506 }
507 Value::Object(obj) => {
508 for (_, value) in obj {
509 self.validate_toml_sizes(value, template_name)?;
510 }
511 for value in obj.values() {
512 self.validate_toml_sizes(value, template_name)?;
513 }
514 }
515 _ => {}
516 }
517
518 Ok(())
519 }
520}
521
522pub enum ValidationRule {
524 ServiceName,
526 Semver,
528 Environment { allowed: Vec<String> },
530 OtelConfig,
532 Custom { name: String },
534}
535
536impl ValidationRule {
537 pub fn validate(&self, parsed: &Value, template_name: &str) -> Result<()> {
543 match self {
544 ValidationRule::ServiceName => Self::validate_service_name(parsed, template_name),
545 ValidationRule::Semver => Self::validate_semver(parsed, template_name),
546 ValidationRule::Environment { allowed } => {
547 Self::validate_environment(parsed, template_name, allowed)
548 }
549 ValidationRule::OtelConfig => Self::validate_otel_config(parsed, template_name),
550 ValidationRule::Custom { .. } => {
551 Ok(())
554 }
555 }
556 }
557
558 fn validate_service_name(parsed: &Value, template_name: &str) -> Result<()> {
559 if let Some(service_name) = parsed
560 .get("service")
561 .and_then(|v| v.get("name"))
562 .and_then(|v| v.as_str())
563 {
564 if !service_name
565 .chars()
566 .all(|c| c.is_alphanumeric() || c == '-' || c == '_')
567 {
568 return Err(TemplateError::ValidationError(format!(
569 "Service name '{}' in template '{}' contains invalid characters (only alphanumeric, '-', '_' allowed)",
570 service_name, template_name
571 )));
572 }
573
574 if service_name.len() > 63 {
575 return Err(TemplateError::ValidationError(format!(
576 "Service name '{}' in template '{}' is too long (max 63 characters)",
577 service_name, template_name
578 )));
579 }
580 }
581 Ok(())
582 }
583
584 fn validate_semver(parsed: &Value, template_name: &str) -> Result<()> {
585 if let Some(version) = parsed
586 .get("meta")
587 .and_then(|v| v.get("version"))
588 .and_then(|v| v.as_str())
589 {
590 let semver_regex =
592 regex::Regex::new(r"^\d+\.\d+\.\d+(-[a-zA-Z0-9.-]+)?(\+[a-zA-Z0-9.-]+)?$")
593 .map_err(|_| {
594 TemplateError::ValidationError("Failed to compile semver regex".to_string())
595 })?;
596
597 if !semver_regex.is_match(version) {
598 return Err(TemplateError::ValidationError(format!(
599 "Version '{}' in template '{}' does not follow semver format (x.y.z)",
600 version, template_name
601 )));
602 }
603 }
604 Ok(())
605 }
606
607 fn validate_environment(parsed: &Value, template_name: &str, allowed: &[String]) -> Result<()> {
608 if let Some(env) = parsed
609 .get("meta")
610 .and_then(|v| v.get("environment"))
611 .and_then(|v| v.as_str())
612 {
613 if !allowed.contains(&env.to_string()) {
614 return Err(TemplateError::ValidationError(format!(
615 "Environment '{}' in template '{}' not in allowed list: {:?}",
616 env, template_name, allowed
617 )));
618 }
619 }
620 Ok(())
621 }
622
623 fn validate_otel_config(parsed: &Value, template_name: &str) -> Result<()> {
624 if let Some(otel) = parsed.get("otel") {
625 let required_fields = ["endpoint", "service_name"];
626
627 for field in &required_fields {
628 if otel.get(*field).is_none() {
629 return Err(TemplateError::ValidationError(format!(
630 "Required OTEL field '{}' missing in template '{}'",
631 field, template_name
632 )));
633 }
634 }
635
636 if let Some(endpoint) = otel.get("endpoint").and_then(|v| v.as_str()) {
638 if !endpoint.starts_with("http://") && !endpoint.starts_with("https://") {
639 return Err(TemplateError::ValidationError(format!(
640 "OTEL endpoint '{}' in template '{}' must start with http:// or https://",
641 endpoint, template_name
642 )));
643 }
644 }
645 }
646 Ok(())
647 }
648}
649
650impl std::fmt::Debug for ValidationRule {
651 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
652 match self {
653 ValidationRule::ServiceName => write!(f, "ServiceName"),
654 ValidationRule::Semver => write!(f, "Semver"),
655 ValidationRule::Environment { allowed } => write!(f, "Environment({:?})", allowed),
656 ValidationRule::OtelConfig => write!(f, "OtelConfig"),
657 ValidationRule::Custom { name } => write!(f, "Custom({})", name),
658 }
659 }
660}
661
662impl Clone for ValidationRule {
663 fn clone(&self) -> Self {
664 match self {
665 ValidationRule::ServiceName => ValidationRule::ServiceName,
666 ValidationRule::Semver => ValidationRule::Semver,
667 ValidationRule::Environment { allowed } => ValidationRule::Environment {
668 allowed: allowed.clone(),
669 },
670 ValidationRule::OtelConfig => ValidationRule::OtelConfig,
671 ValidationRule::Custom { name } => ValidationRule::Custom { name: name.clone() },
672 }
673 }
674}
675
676pub mod rules {
678 use super::*;
679
680 pub fn service_name() -> ValidationRule {
682 ValidationRule::ServiceName
683 }
684
685 pub fn semver() -> ValidationRule {
687 ValidationRule::Semver
688 }
689
690 pub fn environment(allowed: Vec<&str>) -> ValidationRule {
692 ValidationRule::Environment {
693 allowed: allowed.iter().map(|s| s.to_string()).collect(),
694 }
695 }
696
697 pub fn otel_config() -> ValidationRule {
699 ValidationRule::OtelConfig
700 }
701
702 pub fn custom(name: &str) -> ValidationRule {
704 ValidationRule::Custom {
705 name: name.to_string(),
706 }
707 }
708}
709
710pub struct SchemaValidator {
712 schema: Value,
713}
714
715impl SchemaValidator {
716 pub fn new(schema: Value) -> Self {
721 Self { schema }
722 }
723
724 pub fn validate(&self, content: &str, template_name: &str) -> Result<()> {
726 let parsed: Value = serde_json::from_str(content).map_err(|e| {
727 TemplateError::ValidationError(format!(
728 "Failed to parse content for schema validation in template '{}': {}",
729 template_name, e
730 ))
731 })?;
732
733 self.validate_against_schema(&parsed, &self.schema, template_name)
736 }
737
738 fn validate_against_schema(
740 &self,
741 value: &Value,
742 schema: &Value,
743 template_name: &str,
744 ) -> Result<()> {
745 if let Some(expected_type) = schema.get("type").and_then(|v| v.as_str()) {
747 match expected_type {
748 "object" => {
749 if !value.is_object() {
750 return Err(TemplateError::ValidationError(format!(
751 "Schema validation failed in template '{}': expected object",
752 template_name
753 )));
754 }
755 }
756 "array" => {
757 if !value.is_array() {
758 return Err(TemplateError::ValidationError(format!(
759 "Schema validation failed in template '{}': expected array",
760 template_name
761 )));
762 }
763 }
764 "string" => {
765 if !value.is_string() {
766 return Err(TemplateError::ValidationError(format!(
767 "Schema validation failed in template '{}': expected string",
768 template_name
769 )));
770 }
771 }
772 "number" => {
773 if !value.is_number() {
774 return Err(TemplateError::ValidationError(format!(
775 "Schema validation failed in template '{}': expected number",
776 template_name
777 )));
778 }
779 }
780 "boolean" => {
781 if !value.is_boolean() {
782 return Err(TemplateError::ValidationError(format!(
783 "Schema validation failed in template '{}': expected boolean",
784 template_name
785 )));
786 }
787 }
788 _ => {}
789 }
790 }
791
792 if let (Value::Object(obj), Value::Object(schema_obj)) = (value, schema) {
794 if let Some(required) = schema_obj.get("required").and_then(|v| v.as_array()) {
795 for prop in required {
796 if let Some(prop_str) = prop.as_str() {
797 if !obj.contains_key(prop_str) {
798 return Err(TemplateError::ValidationError(format!(
799 "Schema validation failed: required property '{}' missing in template '{}'",
800 prop_str, template_name
801 )));
802 }
803 }
804 }
805 }
806 }
807
808 Ok(())
809 }
810}
811
812#[cfg(test)]
813mod tests {
814 use super::*;
815
816 #[test]
817 fn test_toml_validation() {
818 let validator = TemplateValidator::new()
819 .require_fields(vec!["service.name", "meta.version"])
820 .require_sections(vec!["service", "meta"]);
821
822 let valid_toml = r#"
823[service]
824name = "my-service"
825
826[meta]
827version = "1.0.0"
828 "#;
829
830 assert!(validator.validate(valid_toml, "test").is_ok());
831
832 let invalid_toml = r#"
833[service]
834# missing name field
835 "#;
836
837 assert!(validator.validate(invalid_toml, "test").is_err());
838 }
839
840 #[test]
841 fn test_custom_validation_rules() {
842 let validator = TemplateValidator::new()
843 .with_rule(rules::service_name())
844 .with_rule(rules::semver());
845
846 let valid_content = r#"
847[service]
848name = "my-service"
849
850[meta]
851version = "1.0.0"
852 "#;
853
854 assert!(validator.validate(valid_content, "test").is_ok());
855
856 let invalid_content = r#"
857[service]
858name = "my service!" # invalid characters
859
860[meta]
861version = "not-semver"
862 "#;
863
864 assert!(validator.validate(invalid_content, "test").is_err());
865 }
866
867 #[test]
868 fn test_format_detection() {
869 let validator = TemplateValidator::new().format(OutputFormat::Auto);
870
871 assert!(validator.validate("name = \"test\"", "test").is_ok()); assert!(validator.validate("{\"name\": \"test\"}", "test").is_ok()); }
874}