clnrm_core/validation/
shape.rs

1//! Configuration shape validator for dry-run mode
2//!
3//! Validates TOML configuration structure without spinning up containers.
4//! Performs fast, static validation of configuration shape and relationships.
5
6use crate::config::{
7    ExpectationsConfig, OrderExpectationConfig, OtelConfig, ScenarioConfig, ServiceConfig,
8    SpanExpectationConfig, TestConfig, VolumeConfig, WindowExpectationConfig,
9};
10use crate::error::{CleanroomError, Result};
11use glob::Pattern as GlobBuilder;
12use regex::Regex;
13use std::collections::{HashMap, HashSet};
14use std::path::Path;
15
16/// Result of shape validation
17#[derive(Debug, Clone)]
18pub struct ShapeValidationResult {
19    /// Whether validation passed
20    pub passed: bool,
21    /// List of validation errors
22    pub errors: Vec<ShapeValidationError>,
23    /// File path that was validated
24    pub file_path: String,
25}
26
27/// Shape validation error with file context
28#[derive(Debug, Clone)]
29pub struct ShapeValidationError {
30    /// Error message
31    pub message: String,
32    /// Line number (if available)
33    pub line: Option<usize>,
34    /// Error category
35    pub category: ErrorCategory,
36}
37
38/// Error categories for validation
39#[derive(Debug, Clone, PartialEq, Eq)]
40pub enum ErrorCategory {
41    /// Missing required block
42    MissingRequired,
43    /// Invalid structure
44    InvalidStructure,
45    /// Orphan reference
46    OrphanReference,
47    /// Invalid duration
48    InvalidDuration,
49    /// Circular ordering
50    CircularOrdering,
51    /// Invalid glob pattern
52    InvalidGlob,
53    /// OTEL configuration error
54    OtelError,
55}
56
57impl ShapeValidationError {
58    /// Create error with category
59    pub fn new(category: ErrorCategory, message: impl Into<String>) -> Self {
60        Self {
61            message: message.into(),
62            line: None,
63            category,
64        }
65    }
66
67    /// Set line number
68    pub fn with_line(mut self, line: usize) -> Self {
69        self.line = Some(line);
70        self
71    }
72}
73
74/// Shape validator for configuration files
75pub struct ShapeValidator {
76    /// Errors collected during validation
77    errors: Vec<ShapeValidationError>,
78}
79
80impl ShapeValidator {
81    /// Create new shape validator
82    pub fn new() -> Self {
83        Self { errors: Vec::new() }
84    }
85
86    /// Validate a configuration file
87    ///
88    /// # Errors
89    ///
90    /// Returns error if file cannot be read or parsed
91    pub fn validate_file(&mut self, path: &Path) -> Result<ShapeValidationResult> {
92        // Read and parse file
93        let content = std::fs::read_to_string(path).map_err(|e| {
94            CleanroomError::config_error(format!("Failed to read config file: {}", e))
95        })?;
96
97        // Check if template rendering is needed
98        let toml_content = if crate::is_template(&content) {
99            // Render as Tera template
100            let mut renderer = crate::TemplateRenderer::new()?;
101            let path_str = path
102                .to_str()
103                .ok_or_else(|| CleanroomError::validation_error("Invalid file path encoding"))?;
104            renderer.render_str(&content, path_str)?
105        } else {
106            content
107        };
108
109        // Parse TOML
110        let config = toml::from_str::<TestConfig>(&toml_content)
111            .map_err(|e| CleanroomError::config_error(format!("TOML parse error: {}", e)))?;
112
113        // Validate shape
114        self.validate_config(&config)?;
115
116        // Build result
117        let result = ShapeValidationResult {
118            passed: self.errors.is_empty(),
119            errors: self.errors.clone(),
120            file_path: path.to_string_lossy().into_owned(),
121        };
122
123        Ok(result)
124    }
125
126    /// Validate a parsed configuration
127    ///
128    /// # Errors
129    ///
130    /// Returns error if validation logic fails unexpectedly
131    pub fn validate_config(&mut self, config: &TestConfig) -> Result<()> {
132        // Clear previous errors
133        self.errors.clear();
134
135        // 1. Validate required blocks
136        self.validate_required_blocks(config);
137
138        // 2. Validate OTEL configuration
139        self.validate_otel_config(config);
140
141        // 3. Validate scenarios
142        self.validate_scenarios(config);
143
144        // 4. Validate service references
145        self.validate_service_references(config);
146
147        // 5. Validate duration constraints
148        self.validate_duration_constraints(config);
149
150        // 6. Validate temporal ordering
151        self.validate_temporal_ordering(config);
152
153        // 7. Validate glob patterns
154        self.validate_glob_patterns(config);
155
156        // 8. Validate container images (ENHANCED)
157        self.validate_container_images(config);
158
159        // 9. Validate port bindings (ENHANCED)
160        self.validate_port_bindings(config);
161
162        // 10. Validate volume mounts (ENHANCED)
163        self.validate_volume_mounts(config);
164
165        // 11. Validate environment variables (ENHANCED)
166        self.validate_environment_variables(config);
167
168        // 12. Validate service dependencies (ENHANCED)
169        self.validate_service_dependencies(config);
170
171        Ok(())
172    }
173
174    /// Validate required configuration blocks
175    fn validate_required_blocks(&mut self, config: &TestConfig) {
176        // Check [meta] or [test.metadata] exists
177        if config.meta.is_none() && config.test.is_none() {
178            self.errors.push(ShapeValidationError::new(
179                ErrorCategory::MissingRequired,
180                "Configuration must have either [meta] or [test.metadata] section",
181            ));
182        }
183
184        // Check meta has name and version (for v0.6.0 format)
185        if let Some(ref meta) = config.meta {
186            if meta.name.trim().is_empty() {
187                self.errors.push(ShapeValidationError::new(
188                    ErrorCategory::InvalidStructure,
189                    "[meta] section missing required 'name' field",
190                ));
191            }
192            if meta.version.trim().is_empty() {
193                self.errors.push(ShapeValidationError::new(
194                    ErrorCategory::InvalidStructure,
195                    "[meta] section missing required 'version' field",
196                ));
197            }
198        }
199
200        // Check at least one scenario exists
201        if config.scenario.is_empty() && config.steps.is_empty() {
202            self.errors.push(ShapeValidationError::new(
203                ErrorCategory::MissingRequired,
204                "Configuration must have at least one [[scenario]] or [[steps]]",
205            ));
206        }
207    }
208
209    /// Validate OTEL configuration
210    fn validate_otel_config(&mut self, config: &TestConfig) {
211        if let Some(ref otel) = config.otel {
212            self.validate_otel_exporter(otel);
213        }
214    }
215
216    /// Validate OTEL exporter configuration
217    fn validate_otel_exporter(&mut self, otel: &OtelConfig) {
218        let valid_exporters = [
219            "jaeger",
220            "otlp",
221            "otlp-http",
222            "otlp-grpc",
223            "datadog",
224            "newrelic",
225        ];
226
227        if !valid_exporters.contains(&otel.exporter.as_str()) {
228            self.errors.push(ShapeValidationError::new(
229                ErrorCategory::OtelError,
230                format!(
231                    "Invalid OTEL exporter '{}'. Valid options: {}",
232                    otel.exporter,
233                    valid_exporters.join(", ")
234                ),
235            ));
236        }
237
238        // Validate sample ratio
239        if let Some(ratio) = otel.sample_ratio {
240            if !(0.0..=1.0).contains(&ratio) {
241                self.errors.push(ShapeValidationError::new(
242                    ErrorCategory::OtelError,
243                    format!(
244                        "OTEL sample_ratio must be between 0.0 and 1.0, got {}",
245                        ratio
246                    ),
247                ));
248            }
249        }
250    }
251
252    /// Validate scenario configurations
253    fn validate_scenarios(&mut self, config: &TestConfig) {
254        for (idx, scenario) in config.scenario.iter().enumerate() {
255            if scenario.name.trim().is_empty() {
256                self.errors.push(ShapeValidationError::new(
257                    ErrorCategory::InvalidStructure,
258                    format!("Scenario {} missing required 'name' field", idx),
259                ));
260            }
261
262            if scenario.steps.is_empty() {
263                self.errors.push(ShapeValidationError::new(
264                    ErrorCategory::InvalidStructure,
265                    format!("Scenario '{}' must have at least one step", scenario.name),
266                ));
267            }
268        }
269    }
270
271    /// Validate service references
272    fn validate_service_references(&mut self, config: &TestConfig) {
273        // Build set of defined services
274        let mut defined_services = HashSet::new();
275
276        if let Some(ref services) = config.services {
277            for service_name in services.keys() {
278                defined_services.insert(service_name.clone());
279            }
280        }
281
282        if let Some(ref service) = config.service {
283            for service_name in service.keys() {
284                defined_services.insert(service_name.clone());
285            }
286        }
287
288        // Check scenario step references
289        for scenario in &config.scenario {
290            self.validate_scenario_service_refs(scenario, &defined_services);
291        }
292
293        // Check top-level step references
294        for step in &config.steps {
295            if let Some(ref service_name) = step.service {
296                if !defined_services.contains(service_name) {
297                    self.errors.push(ShapeValidationError::new(
298                        ErrorCategory::OrphanReference,
299                        format!(
300                            "Step '{}' references undefined service '{}'",
301                            step.name, service_name
302                        ),
303                    ));
304                }
305            }
306        }
307    }
308
309    /// Validate service references in a scenario
310    fn validate_scenario_service_refs(
311        &mut self,
312        scenario: &ScenarioConfig,
313        defined_services: &HashSet<String>,
314    ) {
315        for step in &scenario.steps {
316            if let Some(ref service_name) = step.service {
317                if !defined_services.contains(service_name) {
318                    self.errors.push(ShapeValidationError::new(
319                        ErrorCategory::OrphanReference,
320                        format!(
321                            "Scenario '{}' step '{}' references undefined service '{}'",
322                            scenario.name, step.name, service_name
323                        ),
324                    ));
325                }
326            }
327        }
328    }
329
330    /// Validate duration constraints
331    fn validate_duration_constraints(&mut self, config: &TestConfig) {
332        // Check span expectations
333        if let Some(ref expect) = config.expect {
334            for span in &expect.span {
335                self.validate_span_duration(span);
336            }
337
338            // Check window expectations
339            for window in &expect.window {
340                self.validate_window_duration(window);
341            }
342        }
343
344        // Check legacy OTEL validation
345        if let Some(ref otel_val) = config.otel_validation {
346            if let Some(ref spans) = otel_val.expected_spans {
347                for span in spans {
348                    if let (Some(min), Some(max)) = (span.min_duration_ms, span.max_duration_ms) {
349                        if min > max {
350                            self.errors.push(ShapeValidationError::new(
351                                ErrorCategory::InvalidDuration,
352                                format!(
353                                    "Span '{}' has invalid duration: min ({}) > max ({})",
354                                    span.name, min, max
355                                ),
356                            ));
357                        }
358                    }
359                }
360            }
361        }
362    }
363
364    /// Validate span duration constraints
365    fn validate_span_duration(&mut self, _span: &SpanExpectationConfig) {
366        // Duration validation is implicit in the structure
367        // Real duration validation would happen at runtime
368    }
369
370    /// Validate window duration constraints
371    fn validate_window_duration(&mut self, _window: &WindowExpectationConfig) {
372        // Window duration validation is implicit
373        // Real validation happens at runtime
374    }
375
376    /// Validate temporal ordering for cycles
377    fn validate_temporal_ordering(&mut self, config: &TestConfig) {
378        if let Some(ref expect) = config.expect {
379            if let Some(ref order) = expect.order {
380                self.check_ordering_cycles(order);
381            }
382        }
383
384        // Check legacy OTEL validation
385        if let Some(ref otel_val) = config.otel_validation {
386            if let Some(ref order) = otel_val.expect_order {
387                self.check_ordering_cycles(order);
388            }
389        }
390    }
391
392    /// Check for cycles in ordering constraints
393    fn check_ordering_cycles(&mut self, order: &OrderExpectationConfig) {
394        // Build adjacency graph
395        let mut graph: HashMap<String, Vec<String>> = HashMap::new();
396
397        // Add must_precede edges (A -> B means A must come before B)
398        if let Some(ref must_precede) = order.must_precede {
399            for edge in must_precede {
400                if edge.len() == 2 {
401                    let first = &edge[0];
402                    let second = &edge[1];
403                    graph.entry(first.clone()).or_default().push(second.clone());
404                }
405            }
406        }
407
408        // Add must_follow edges (A follows B means B -> A)
409        if let Some(ref must_follow) = order.must_follow {
410            for edge in must_follow {
411                if edge.len() == 2 {
412                    let first = &edge[0];
413                    let second = &edge[1];
414                    graph.entry(second.clone()).or_default().push(first.clone());
415                }
416            }
417        }
418
419        // Detect cycles using DFS
420        let mut visited = HashSet::new();
421        let mut rec_stack = HashSet::new();
422
423        for node in graph.keys() {
424            if !visited.contains(node)
425                && Self::has_cycle_dfs(node, &graph, &mut visited, &mut rec_stack)
426            {
427                self.errors.push(ShapeValidationError::new(
428                    ErrorCategory::CircularOrdering,
429                    format!(
430                        "Circular temporal ordering detected involving span '{}'",
431                        node
432                    ),
433                ));
434                break;
435            }
436        }
437    }
438
439    /// DFS cycle detection helper
440    fn has_cycle_dfs(
441        node: &str,
442        graph: &HashMap<String, Vec<String>>,
443        visited: &mut HashSet<String>,
444        rec_stack: &mut HashSet<String>,
445    ) -> bool {
446        visited.insert(node.to_string());
447        rec_stack.insert(node.to_string());
448
449        if let Some(neighbors) = graph.get(node) {
450            for neighbor in neighbors {
451                if !visited.contains(neighbor) {
452                    if Self::has_cycle_dfs(neighbor, graph, visited, rec_stack) {
453                        return true;
454                    }
455                } else if rec_stack.contains(neighbor) {
456                    return true;
457                }
458            }
459        }
460
461        rec_stack.remove(node);
462        false
463    }
464
465    /// Validate glob patterns
466    fn validate_glob_patterns(&mut self, config: &TestConfig) {
467        // Check span expectations
468        if let Some(ref expect) = config.expect {
469            self.validate_expectation_globs(expect);
470        }
471
472        // Check legacy OTEL validation
473        if let Some(ref otel_val) = config.otel_validation {
474            if let Some(ref spans) = otel_val.expected_spans {
475                for span in spans {
476                    if let Err(e) = self.validate_glob_pattern(&span.name) {
477                        self.errors.push(ShapeValidationError::new(
478                            ErrorCategory::InvalidGlob,
479                            format!("Invalid glob pattern in span '{}': {}", span.name, e),
480                        ));
481                    }
482                }
483            }
484        }
485    }
486
487    /// Validate expectations globs
488    fn validate_expectation_globs(&mut self, expect: &ExpectationsConfig) {
489        for span in &expect.span {
490            if let Err(e) = self.validate_glob_pattern(&span.name) {
491                self.errors.push(ShapeValidationError::new(
492                    ErrorCategory::InvalidGlob,
493                    format!(
494                        "Invalid glob pattern in span expectation '{}': {}",
495                        span.name, e
496                    ),
497                ));
498            }
499        }
500    }
501
502    /// Validate a single glob pattern
503    fn validate_glob_pattern(&self, pattern: &str) -> Result<()> {
504        GlobBuilder::new(pattern).map_err(|e| {
505            CleanroomError::validation_error(format!("Invalid pattern '{}': {}", pattern, e))
506        })?;
507        Ok(())
508    }
509
510    /// Get validation errors
511    pub fn errors(&self) -> &[ShapeValidationError] {
512        &self.errors
513    }
514
515    /// Check if validation passed
516    pub fn is_valid(&self) -> bool {
517        self.errors.is_empty()
518    }
519
520    // ========================================================================
521    // ENHANCED VALIDATION METHODS (v0.7.0)
522    // ========================================================================
523
524    /// Validate container image configurations
525    fn validate_container_images(&mut self, config: &TestConfig) {
526        let services = self.collect_all_services(config);
527
528        for (service_name, service) in services {
529            // Skip network services that don't require images
530            if service.plugin == "network_service" || service.plugin == "ollama" {
531                continue;
532            }
533
534            // Check image exists and is not empty
535            if let Some(ref image) = service.image {
536                if image.trim().is_empty() {
537                    self.errors.push(ShapeValidationError::new(
538                        ErrorCategory::InvalidStructure,
539                        format!(
540                            "Service '{}' has empty image. Suggestion: Use a valid image like 'alpine:latest' or 'ubuntu:20.04'",
541                            service_name
542                        ),
543                    ));
544                    continue;
545                }
546
547                // Validate image format
548                self.validate_image_format(&service_name, image);
549            }
550        }
551    }
552
553    /// Validate Docker image format
554    fn validate_image_format(&mut self, service_name: &str, image: &str) {
555        // Docker image format: [registry/][namespace/]repository[:tag|@digest]
556        // Examples:
557        // - alpine:latest
558        // - ubuntu:20.04
559        // - docker.io/library/postgres:14
560        // - ghcr.io/owner/repo:v1.0.0
561        // - localhost:5000/myimage:tag
562
563        // Check for invalid characters
564        if image.contains(' ') {
565            self.errors.push(ShapeValidationError::new(
566                ErrorCategory::InvalidStructure,
567                format!(
568                    "Service '{}' has invalid image format '{}'. Images cannot contain spaces. Example: 'alpine:latest'",
569                    service_name, image
570                ),
571            ));
572            return;
573        }
574
575        // Check for exclamation marks or other special characters
576        if image.contains('!') || image.contains('?') || image.contains('*') {
577            self.errors.push(ShapeValidationError::new(
578                ErrorCategory::InvalidStructure,
579                format!(
580                    "Service '{}' has invalid image format '{}'. Invalid characters detected. Example: 'alpine:latest'",
581                    service_name, image
582                ),
583            ));
584            return;
585        }
586
587        // Validate basic structure
588        let parts: Vec<&str> = image.split('/').collect();
589        if parts.len() > 3 {
590            self.errors.push(ShapeValidationError::new(
591                ErrorCategory::InvalidStructure,
592                format!(
593                    "Service '{}' has invalid image format '{}'. Too many path segments. Example: 'registry/namespace/repo:tag'",
594                    service_name, image
595                ),
596            ));
597        }
598    }
599
600    /// Validate port bindings across all services
601    fn validate_port_bindings(&mut self, config: &TestConfig) {
602        let mut port_usage: HashMap<u16, Vec<String>> = HashMap::new();
603        let services = self.collect_all_services(config);
604
605        for (service_name, service) in services {
606            if let Some(ref ports) = service.ports {
607                for &port in ports {
608                    // Check for reserved/system ports (1-1023)
609                    if port < 1024 {
610                        self.errors.push(ShapeValidationError::new(
611                            ErrorCategory::InvalidStructure,
612                            format!(
613                                "Service '{}' uses reserved port {}. Suggestion: Use ports >= 1024 (e.g., 8080, 9000, 3000)",
614                                service_name, port
615                            ),
616                        ));
617                    }
618
619                    // Track port usage for conflict detection
620                    port_usage
621                        .entry(port)
622                        .or_default()
623                        .push(service_name.clone());
624                }
625            }
626        }
627
628        // Check for port conflicts
629        for (port, services) in port_usage {
630            if services.len() > 1 {
631                self.errors.push(ShapeValidationError::new(
632                    ErrorCategory::InvalidStructure,
633                    format!(
634                        "Port conflict detected: port {} is used by multiple services: {}. Each service must use unique ports.",
635                        port,
636                        services.join(", ")
637                    ),
638                ));
639            }
640        }
641    }
642
643    /// Validate volume mount configurations
644    fn validate_volume_mounts(&mut self, config: &TestConfig) {
645        let services = self.collect_all_services(config);
646
647        for (service_name, service) in services {
648            if let Some(ref volumes) = service.volumes {
649                for (idx, volume) in volumes.iter().enumerate() {
650                    self.validate_single_volume(&service_name, idx, volume);
651                }
652            }
653        }
654    }
655
656    /// Validate a single volume configuration
657    fn validate_single_volume(&mut self, service_name: &str, idx: usize, volume: &VolumeConfig) {
658        // Check host path is absolute
659        if !volume.host_path.starts_with('/') {
660            self.errors.push(ShapeValidationError::new(
661                ErrorCategory::InvalidStructure,
662                format!(
663                    "Service '{}' volume {}: host path '{}' must be absolute. Suggestion: Use '/tmp/data' or '/home/user/project'",
664                    service_name, idx, volume.host_path
665                ),
666            ));
667        }
668
669        // Check container path is absolute
670        if !volume.container_path.starts_with('/') {
671            self.errors.push(ShapeValidationError::new(
672                ErrorCategory::InvalidStructure,
673                format!(
674                    "Service '{}' volume {}: container path '{}' must be absolute. Suggestion: Use '/app/data' or '/var/lib/app'",
675                    service_name, idx, volume.container_path
676                ),
677            ));
678        }
679
680        // Warn about dangerous system paths
681        let dangerous_paths = [
682            "/etc",
683            "/var",
684            "/proc",
685            "/sys",
686            "/dev",
687            "/boot",
688            "/root",
689            "/bin",
690            "/sbin",
691            "/lib",
692            "/lib64",
693            "/usr/bin",
694            "/usr/sbin",
695        ];
696
697        for dangerous in &dangerous_paths {
698            if volume.container_path.starts_with(dangerous)
699                && (volume.container_path == *dangerous
700                    || volume
701                        .container_path
702                        .starts_with(&format!("{}/", dangerous)))
703            {
704                self.errors.push(ShapeValidationError::new(
705                    ErrorCategory::InvalidStructure,
706                    format!(
707                        "Service '{}' volume {}: mounting to system path '{}' is dangerous. Suggestion: Use application paths like '/app/data'",
708                        service_name, idx, volume.container_path
709                    ),
710                ));
711            }
712        }
713    }
714
715    /// Validate environment variable configurations
716    fn validate_environment_variables(&mut self, config: &TestConfig) {
717        let services = self.collect_all_services(config);
718
719        for (service_name, service) in services {
720            if let Some(ref env) = service.env {
721                for (key, value) in env {
722                    self.validate_env_var(&service_name, key, value);
723                }
724            }
725        }
726
727        // Also validate step-level env vars
728        for step in &config.steps {
729            if let Some(ref env) = step.env {
730                for (key, value) in env {
731                    self.validate_env_var(&format!("step '{}'", step.name), key, value);
732                }
733            }
734        }
735
736        for scenario in &config.scenario {
737            for step in &scenario.steps {
738                if let Some(ref env) = step.env {
739                    for (key, value) in env {
740                        self.validate_env_var(
741                            &format!("scenario '{}' step '{}'", scenario.name, step.name),
742                            key,
743                            value,
744                        );
745                    }
746                }
747            }
748        }
749    }
750
751    /// Validate a single environment variable
752    fn validate_env_var(&mut self, context: &str, key: &str, value: &str) {
753        // Check key is not empty
754        if key.is_empty() {
755            self.errors.push(ShapeValidationError::new(
756                ErrorCategory::InvalidStructure,
757                format!(
758                    "{}: environment variable name cannot be empty. Use uppercase names like 'APP_ENV' or 'DATABASE_URL'",
759                    context
760                ),
761            ));
762            return;
763        }
764
765        // Validate key format (must start with letter or underscore, contain only alphanumeric and underscore)
766        // Regex pattern is static and known to be valid, but handle error gracefully
767        match Regex::new(r"^[A-Za-z_][A-Za-z0-9_]*$") {
768            Ok(env_var_regex) => {
769                if !env_var_regex.is_match(key) {
770                    self.errors.push(ShapeValidationError::new(
771                        ErrorCategory::InvalidStructure,
772                        format!(
773                            "{}: invalid environment variable name '{}'. Names must start with a letter or underscore and contain only alphanumeric characters and underscores. Example: 'DATABASE_URL'",
774                            context, key
775                        ),
776                    ));
777                }
778            }
779            Err(e) => {
780                // This should never happen with a static pattern, but handle gracefully
781                self.errors.push(ShapeValidationError::new(
782                    ErrorCategory::InvalidStructure,
783                    format!("{}: internal error compiling regex: {}", context, e),
784                ));
785            }
786        }
787
788        // Warn about potential hardcoded secrets
789        let sensitive_keys = [
790            "API_KEY",
791            "PASSWORD",
792            "SECRET",
793            "TOKEN",
794            "PRIVATE_KEY",
795            "CREDENTIALS",
796            "AUTH_TOKEN",
797            "ACCESS_KEY",
798            "SECRET_KEY",
799        ];
800
801        for sensitive in &sensitive_keys {
802            if key.to_uppercase().contains(sensitive)
803                && !value.is_empty()
804                && !value.starts_with('$')
805            {
806                self.errors.push(ShapeValidationError::new(
807                    ErrorCategory::InvalidStructure,
808                    format!(
809                        "{}: potential hardcoded sensitive value in '{}'. Suggestion: Use environment variable references like '${{ENV_VAR}}' or template variables",
810                        context, key
811                    ),
812                ));
813                break;
814            }
815        }
816    }
817
818    /// Validate service dependencies
819    fn validate_service_dependencies(&mut self, config: &TestConfig) {
820        let services = self.collect_all_services(config);
821
822        // Build dependency graph from health check commands
823        let mut dep_graph: HashMap<String, Vec<String>> = HashMap::new();
824
825        for (service_name, service) in &services {
826            if let Some(ref health_check) = service.health_check {
827                // Extract service dependencies from health check commands
828                let deps = self.extract_service_deps_from_command(&health_check.cmd, &services);
829                if !deps.is_empty() {
830                    dep_graph.insert(service_name.clone(), deps);
831                }
832            }
833        }
834
835        // Detect circular dependencies
836        let mut visited = HashSet::new();
837        let mut rec_stack = HashSet::new();
838
839        for service_name in dep_graph.keys() {
840            if !visited.contains(service_name)
841                && Self::has_circular_dep(service_name, &dep_graph, &mut visited, &mut rec_stack)
842            {
843                self.errors.push(ShapeValidationError::new(
844                    ErrorCategory::CircularOrdering,
845                    format!(
846                        "Circular service dependency detected involving '{}'. Services cannot depend on each other in a cycle.",
847                        service_name
848                    ),
849                ));
850                break;
851            }
852        }
853    }
854
855    /// Extract service dependencies from command
856    fn extract_service_deps_from_command(
857        &self,
858        command: &[String],
859        services: &HashMap<String, &ServiceConfig>,
860    ) -> Vec<String> {
861        let mut deps = Vec::new();
862        let cmd_str = command.join(" ");
863
864        for service_name in services.keys() {
865            if cmd_str.contains(service_name) {
866                deps.push(service_name.clone());
867            }
868        }
869
870        deps
871    }
872
873    /// Check for circular dependencies
874    fn has_circular_dep(
875        service: &str,
876        graph: &HashMap<String, Vec<String>>,
877        visited: &mut HashSet<String>,
878        rec_stack: &mut HashSet<String>,
879    ) -> bool {
880        visited.insert(service.to_string());
881        rec_stack.insert(service.to_string());
882
883        if let Some(deps) = graph.get(service) {
884            for dep in deps {
885                if !visited.contains(dep) {
886                    if Self::has_circular_dep(dep, graph, visited, rec_stack) {
887                        return true;
888                    }
889                } else if rec_stack.contains(dep) {
890                    return true;
891                }
892            }
893        }
894
895        rec_stack.remove(service);
896        false
897    }
898
899    /// Collect all services from config (both [services] and [service] tables)
900    fn collect_all_services<'a>(
901        &self,
902        config: &'a TestConfig,
903    ) -> HashMap<String, &'a ServiceConfig> {
904        let mut all_services = HashMap::new();
905
906        if let Some(ref services) = config.services {
907            for (name, service) in services {
908                all_services.insert(name.clone(), service);
909            }
910        }
911
912        if let Some(ref service) = config.service {
913            for (name, svc) in service {
914                all_services.insert(name.clone(), svc);
915            }
916        }
917
918        all_services
919    }
920}
921
922impl Default for ShapeValidator {
923    fn default() -> Self {
924        Self::new()
925    }
926}