Skip to main content

devops_validate/
validator.rs

1//! YAML type detection and per-format validation functions.
2//!
3//! ## Main entry point
4//!
5//! [`validate_auto`] — auto-detects the YAML format and dispatches to the
6//! appropriate validator. Handles multi-document YAML (`---` separator).
7//!
8//! ## Per-format validators
9//!
10//! - [`validate_k8s_manifest`] — all Kubernetes resource kinds
11//! - [`validate_gitlab_ci`], [`validate_github_actions`]
12//! - [`validate_docker_compose`]
13//! - [`validate_prometheus`], [`validate_alertmanager`]
14//! - [`validate_helm_values`], [`validate_ansible`]
15//!
16//! ## Helper functions
17//!
18//! - [`parse_yaml`] — parse YAML string into [`serde_json::Value`]
19//! - [`detect_yaml_type`] — detect format from parsed content
20
21use devops_models::models::ansible::AnsiblePlay;
22use devops_models::models::docker_compose::DockerCompose;
23use devops_models::models::github_actions::GitHubActions;
24use devops_models::models::gitlab::GitLabCI;
25use devops_models::models::helm::HelmValues;
26use devops_models::models::k8s::*;
27use devops_models::models::k8s_networking::{K8sIngress, K8sNetworkPolicy};
28use devops_models::models::k8s_rbac::{K8sRole, K8sRoleBinding, K8sServiceAccount};
29use devops_models::models::k8s_storage::K8sPVC;
30use devops_models::models::k8s_workloads::{K8sCronJob, K8sDaemonSet, K8sHPA, K8sJob, K8sStatefulSet};
31use devops_models::models::prometheus::{AlertmanagerConfig, PrometheusConfig};
32use devops_models::models::validation::{
33    ConfigValidator, Diagnostic, ValidationResult, YamlType,
34};
35
36/// Parse a YAML string into a [`serde_json::Value`].
37///
38/// Accepts both mapping (object) and array top-level documents.
39/// Scalar YAML (a bare string, number, or boolean) is rejected because
40/// none of the DevOps formats use a scalar root.
41///
42/// # Errors
43///
44/// Returns `Err(String)` when:
45/// - The input is not valid YAML syntax.
46/// - The YAML root is a scalar (null, bool, number, or string) rather than
47///   a mapping or array.
48///
49/// # Example
50///
51/// ```rust
52/// use devops_validate::validator::parse_yaml;
53///
54/// let data = parse_yaml("key: value").unwrap();
55/// assert!(data.is_object());
56///
57/// assert!(parse_yaml("just a string").is_err());
58/// ```
59pub fn parse_yaml(content: &str) -> Result<serde_json::Value, String> {
60    let value: serde_json::Value =
61        serde_yaml::from_str(content).map_err(|e| format!("YAML parse error: {e}"))?;
62    if !value.is_object() && !value.is_array() {
63        return Err(format!(
64            "YAML must be a mapping or array, got: {}",
65            value_type_name(&value)
66        ));
67    }
68    Ok(value)
69}
70
71/// Detect the format of a parsed YAML document.
72///
73/// Uses explicit markers first (`apiVersion`/`kind` for Kubernetes, `$schema`
74/// in the validator), then structural heuristics for Docker Compose, GitLab CI,
75/// GitHub Actions, Prometheus, Alertmanager, Helm, Ansible, and OpenAPI.
76/// Returns [`YamlType::Generic`] when no format is recognised.
77///
78/// # Example
79///
80/// ```rust
81/// use devops_validate::validator::{parse_yaml, detect_yaml_type};
82/// use devops_models::models::validation::YamlType;
83///
84/// let data = parse_yaml("apiVersion: apps/v1\nkind: Deployment\nmetadata:\n  name: x\nspec:\n  selector:\n    matchLabels:\n      app: x\n  template:\n    metadata:\n      labels:\n        app: x\n    spec:\n      containers: []").unwrap();
85/// assert_eq!(detect_yaml_type(&data), YamlType::K8sDeployment);
86/// ```
87pub fn detect_yaml_type(data: &serde_json::Value) -> YamlType {
88    // Top-level array — could be Ansible Playbook
89    if data.is_array() && AnsiblePlay::looks_like_playbook(data) {
90        return YamlType::Ansible;
91    }
92
93    let obj = match data.as_object() {
94        Some(o) => o,
95        None => return YamlType::Generic,
96    };
97
98    // K8s resources have apiVersion + kind
99    if obj.contains_key("apiVersion") && obj.contains_key("kind") {
100        let kind = obj.get("kind").and_then(|v| v.as_str()).unwrap_or("");
101        return match kind {
102            "Deployment" => YamlType::K8sDeployment,
103            "Service" => YamlType::K8sService,
104            "ConfigMap" => YamlType::K8sConfigMap,
105            "Secret" => YamlType::K8sSecret,
106            "Ingress" => YamlType::K8sIngress,
107            "HorizontalPodAutoscaler" => YamlType::K8sHPA,
108            "CronJob" => YamlType::K8sCronJob,
109            "Job" => YamlType::K8sJob,
110            "PersistentVolumeClaim" => YamlType::K8sPVC,
111            "NetworkPolicy" => YamlType::K8sNetworkPolicy,
112            "StatefulSet" => YamlType::K8sStatefulSet,
113            "DaemonSet" => YamlType::K8sDaemonSet,
114            "Role" => YamlType::K8sRole,
115            "ClusterRole" => YamlType::K8sClusterRole,
116            "RoleBinding" => YamlType::K8sRoleBinding,
117            "ClusterRoleBinding" => YamlType::K8sClusterRoleBinding,
118            "ServiceAccount" => YamlType::K8sServiceAccount,
119            _ => YamlType::K8sGeneric,
120        };
121    }
122
123    // Docker Compose: has `services` key with sub-objects
124    if obj.contains_key("services")
125        && let Some(svcs) = obj.get("services").and_then(|v| v.as_object()) {
126            // Distinguish from generic YAML with a "services" key —
127            // Compose services have sub-objects, not arrays or scalars.
128            let looks_like_compose = svcs.values().any(|v| v.is_object());
129            if looks_like_compose {
130                return YamlType::DockerCompose;
131            }
132        }
133
134    // GitHub Actions: has `on` + `jobs`
135    if obj.contains_key("on") && obj.contains_key("jobs") {
136        return YamlType::GitHubActions;
137    }
138
139    // GitLab CI has stages and/or jobs with script keys
140    if obj.contains_key("stages") || obj.values().any(|v| v.get("script").is_some()) {
141        return YamlType::GitLabCI;
142    }
143
144    // Prometheus: has global.scrape_interval or scrape_configs
145    if obj.contains_key("scrape_configs")
146        || obj
147            .get("global")
148            .and_then(|g| g.get("scrape_interval"))
149            .is_some()
150    {
151        return YamlType::Prometheus;
152    }
153
154    // Alertmanager: has route + receivers
155    if obj.contains_key("route") && obj.contains_key("receivers") {
156        return YamlType::Alertmanager;
157    }
158
159    // Helm values.yaml: heuristic detection
160    if HelmValues::looks_like_helm(data) {
161        return YamlType::HelmValues;
162    }
163
164    // OpenAPI has openapi or swagger key
165    if obj.contains_key("openapi") || obj.contains_key("swagger") {
166        return YamlType::OpenAPI;
167    }
168
169    YamlType::Generic
170}
171
172/// Validate a Kubernetes manifest (all supported resource kinds) via serde.
173///
174/// Detects the resource `kind` from the YAML and dispatches to the appropriate
175/// typed struct validator.  For known kinds this provides structural validation
176/// (required fields, type checks) plus semantic warnings (replicas=1,
177/// missing resource limits, `:latest` image tags, etc.).
178///
179/// Unknown `kind` values are accepted as valid with a warning.
180///
181/// # Example
182///
183/// ```rust
184/// use devops_validate::validator::validate_k8s_manifest;
185///
186/// let yaml = r#"
187/// apiVersion: apps/v1
188/// kind: Deployment
189/// metadata:
190///   name: test
191/// spec:
192///   replicas: 1
193///   selector:
194///     matchLabels:
195///       app: test
196///   template:
197///     metadata:
198///       labels:
199///         app: test
200///     spec:
201///       containers:
202///         - name: app
203///           image: test:latest
204/// "#;
205///
206/// let result = validate_k8s_manifest(yaml);
207/// assert!(result.valid);
208/// assert!(!result.warnings.is_empty()); // warns on replicas=1 and :latest
209/// ```
210pub fn validate_k8s_manifest(content: &str) -> ValidationResult {
211    let data = match parse_yaml(content) {
212        Ok(d) => d,
213        Err(e) => {
214            return ValidationResult {
215                valid: false,
216                errors: vec![e],
217                warnings: vec![],
218                diagnostics: vec![],
219                hints: vec![],
220                yaml_type: None,
221            }
222        }
223    };
224
225    let kind = data
226        .get("kind")
227        .and_then(|v| v.as_str())
228        .unwrap_or("")
229        .to_string();
230
231    // Collect heuristic warnings before consuming `data`.
232    let extra_warnings = collect_k8s_warnings(&data);
233
234    match kind.as_str() {
235        "Deployment" => match serde_json::from_value::<K8sDeployment>(data) {
236            Ok(dep) => {
237                let mut warnings = extra_warnings;
238                warnings.extend(dep.validate());
239                ValidationResult {
240                    valid: true,
241                    errors: vec![],
242                    diagnostics: vec![],
243                    warnings,
244                    hints: vec![],
245                    yaml_type: Some(YamlType::K8sDeployment),
246                }
247            }
248            Err(e) => ValidationResult {
249                valid: false,
250                errors: format_serde_errors(&e),
251                warnings: vec![],
252                diagnostics: vec![],
253                hints: vec![],
254                yaml_type: Some(YamlType::K8sDeployment),
255            },
256        },
257        "Service" => match serde_json::from_value::<K8sService>(data) {
258            Ok(svc) => {
259                let mut warnings = extra_warnings;
260                warnings.extend(validate_service_semantics(&svc));
261                ValidationResult {
262                    valid: true,
263                    errors: vec![],
264                    diagnostics: vec![],
265                    warnings,
266                    hints: vec![],
267                    yaml_type: Some(YamlType::K8sService),
268                }
269            }
270            Err(e) => ValidationResult {
271                valid: false,
272                errors: format_serde_errors(&e),
273                diagnostics: vec![],
274                warnings: vec![],
275                hints: vec![],
276                yaml_type: Some(YamlType::K8sService),
277            },
278        },
279        "ConfigMap" => match serde_json::from_value::<K8sConfigMap>(data) {
280            Ok(cm) => {
281                let mut warnings = extra_warnings;
282                if cm.data.is_empty() && cm.binary_data.is_none() {
283                    warnings.push("ConfigMap has no data and no binaryData".to_string());
284                }
285                ValidationResult {
286                    valid: true,
287                    errors: vec![],
288                    diagnostics: vec![],
289                    warnings,
290                    hints: vec![],
291                    yaml_type: Some(YamlType::K8sConfigMap),
292                }
293            }
294            Err(e) => ValidationResult {
295                valid: false,
296                errors: format_serde_errors(&e),
297                diagnostics: vec![],
298                warnings: vec![],
299                hints: vec![],
300                yaml_type: Some(YamlType::K8sConfigMap),
301            },
302        },
303        "Secret" => match serde_json::from_value::<K8sSecret>(data) {
304            Ok(sec) => {
305                let mut warnings = extra_warnings;
306                warnings.extend(validate_secret_semantics(&sec));
307                ValidationResult {
308                    valid: true,
309                    errors: vec![],
310                    diagnostics: vec![],
311                    warnings,
312                    hints: vec![],
313                    yaml_type: Some(YamlType::K8sSecret),
314                }
315            }
316            Err(e) => ValidationResult {
317                valid: false,
318                errors: format_serde_errors(&e),
319                diagnostics: vec![],
320                warnings: vec![],
321                hints: vec![],
322                yaml_type: Some(YamlType::K8sSecret),
323            },
324        },
325        // ── New K8s kinds via ConfigValidator trait ──────────────────────
326        "Ingress" => validate_k8s_with_trait::<K8sIngress>(data, YamlType::K8sIngress),
327        "HorizontalPodAutoscaler" => validate_k8s_with_trait::<K8sHPA>(data, YamlType::K8sHPA),
328        "CronJob" => validate_k8s_with_trait::<K8sCronJob>(data, YamlType::K8sCronJob),
329        "Job" => validate_k8s_with_trait::<K8sJob>(data, YamlType::K8sJob),
330        "PersistentVolumeClaim" => validate_k8s_with_trait::<K8sPVC>(data, YamlType::K8sPVC),
331        "NetworkPolicy" => {
332            validate_k8s_with_trait::<K8sNetworkPolicy>(data, YamlType::K8sNetworkPolicy)
333        }
334        "StatefulSet" => {
335            validate_k8s_with_trait::<K8sStatefulSet>(data, YamlType::K8sStatefulSet)
336        }
337        "DaemonSet" => validate_k8s_with_trait::<K8sDaemonSet>(data, YamlType::K8sDaemonSet),
338        "Role" | "ClusterRole" => validate_k8s_with_trait::<K8sRole>(data, YamlType::K8sRole),
339        "RoleBinding" | "ClusterRoleBinding" => {
340            validate_k8s_with_trait::<K8sRoleBinding>(data, YamlType::K8sRoleBinding)
341        }
342        "ServiceAccount" => {
343            validate_k8s_with_trait::<K8sServiceAccount>(data, YamlType::K8sServiceAccount)
344        }
345        // ── Unknown K8s kind → generic K8s validation ──
346        _ => validate_k8s_generic(data, &kind),
347    }
348}
349
350/// Validate a K8s resource using its ConfigValidator trait implementation.
351fn validate_k8s_with_trait<T>(data: serde_json::Value, yaml_type: YamlType) -> ValidationResult
352where
353    T: serde::de::DeserializeOwned + ConfigValidator,
354{
355    match serde_json::from_value::<T>(data) {
356        Ok(resource) => resource.validate(),
357        Err(e) => ValidationResult {
358            valid: false,
359            errors: format_serde_errors(&e),
360            warnings: vec![],
361            diagnostics: vec![],
362            hints: vec![],
363            yaml_type: Some(yaml_type),
364        },
365    }
366}
367
368/// Validate unknown K8s kinds with a basic structure check.
369fn validate_k8s_generic(data: serde_json::Value, kind: &str) -> ValidationResult {
370    let mut warnings = vec![format!(
371        "Kind '{}' has no specific validator — only basic structure checked",
372        kind
373    )];
374    let obj = data.as_object();
375    if let Some(o) = obj {
376        if !o.contains_key("metadata") {
377            warnings
378                .push(format!("Kind '{}' is missing 'metadata' — unusual for a K8s resource", kind));
379        }
380        if !o.contains_key("spec") && !o.contains_key("data") && !o.contains_key("rules") {
381            warnings.push(format!("Kind '{}' has no 'spec', 'data', or 'rules' field", kind));
382        }
383    }
384    ValidationResult {
385        valid: true,
386        errors: vec![],
387        diagnostics: vec![],
388        warnings,
389        hints: vec![],
390        yaml_type: Some(YamlType::K8sGeneric),
391    }
392}
393
394/// Validate a GitLab CI pipeline YAML
395pub fn validate_gitlab_ci(content: &str) -> ValidationResult {
396    let data = match parse_yaml(content) {
397        Ok(d) => d,
398        Err(e) => {
399            return ValidationResult {
400                valid: false,
401                errors: vec![e],
402                warnings: vec![],
403                diagnostics: vec![],
404                hints: vec![],
405                yaml_type: Some(YamlType::GitLabCI),
406            }
407        }
408    };
409
410    match GitLabCI::from_value(&data) {
411        Ok(ci) => {
412            let warnings = ci.validate();
413            ValidationResult {
414                valid: true,
415                errors: vec![],
416                warnings,
417                diagnostics: vec![],
418                hints: vec![],
419                yaml_type: Some(YamlType::GitLabCI),
420            }
421        }
422        Err(e) => ValidationResult {
423            valid: false,
424            errors: vec![e],
425            warnings: vec![],
426            diagnostics: vec![],
427            hints: vec![],
428            yaml_type: Some(YamlType::GitLabCI),
429        },
430    }
431}
432
433/// Validate a Docker Compose file
434pub fn validate_docker_compose(content: &str) -> ValidationResult {
435    let data = match parse_yaml(content) {
436        Ok(d) => d,
437        Err(e) => {
438            return ValidationResult {
439                valid: false,
440                errors: vec![e],
441                warnings: vec![],
442                diagnostics: vec![],
443                hints: vec![],
444                yaml_type: Some(YamlType::DockerCompose),
445            }
446        }
447    };
448
449    match DockerCompose::from_value(data) {
450        Ok(compose) => compose.validate(),
451        Err(e) => ValidationResult {
452            valid: false,
453            errors: vec![e],
454            warnings: vec![],
455            diagnostics: vec![],
456            hints: vec![],
457            yaml_type: Some(YamlType::DockerCompose),
458        },
459    }
460}
461
462/// Validate a GitHub Actions workflow
463pub fn validate_github_actions(content: &str) -> ValidationResult {
464    let data = match parse_yaml(content) {
465        Ok(d) => d,
466        Err(e) => {
467            return ValidationResult {
468                valid: false,
469                errors: vec![e],
470                warnings: vec![],
471                diagnostics: vec![],
472                hints: vec![],
473                yaml_type: Some(YamlType::GitHubActions),
474            }
475        }
476    };
477
478    match GitHubActions::from_value(&data) {
479        Ok(actions) => actions.validate(),
480        Err(e) => ValidationResult {
481            valid: false,
482            errors: vec![e],
483            warnings: vec![],
484            diagnostics: vec![],
485            hints: vec![],
486            yaml_type: Some(YamlType::GitHubActions),
487        },
488    }
489}
490
491/// Validate a Prometheus configuration
492pub fn validate_prometheus(content: &str) -> ValidationResult {
493    let data = match parse_yaml(content) {
494        Ok(d) => d,
495        Err(e) => {
496            return ValidationResult {
497                valid: false,
498                errors: vec![e],
499                warnings: vec![],
500                diagnostics: vec![],
501                hints: vec![],
502                yaml_type: Some(YamlType::Prometheus),
503            }
504        }
505    };
506
507    match PrometheusConfig::from_value(data) {
508        Ok(config) => config.validate(),
509        Err(e) => ValidationResult {
510            valid: false,
511            errors: vec![e],
512            warnings: vec![],
513            diagnostics: vec![],
514            hints: vec![],
515            yaml_type: Some(YamlType::Prometheus),
516        },
517    }
518}
519
520/// Validate an Alertmanager configuration
521pub fn validate_alertmanager(content: &str) -> ValidationResult {
522    let data = match parse_yaml(content) {
523        Ok(d) => d,
524        Err(e) => {
525            return ValidationResult {
526                valid: false,
527                errors: vec![e],
528                warnings: vec![],
529                diagnostics: vec![],
530                hints: vec![],
531                yaml_type: Some(YamlType::Alertmanager),
532            }
533        }
534    };
535
536    match AlertmanagerConfig::from_value(data) {
537        Ok(config) => config.validate(),
538        Err(e) => ValidationResult {
539            valid: false,
540            errors: vec![e],
541            warnings: vec![],
542            diagnostics: vec![],
543            hints: vec![],
544            yaml_type: Some(YamlType::Alertmanager),
545        },
546    }
547}
548
549/// Validate a Helm values.yaml file
550pub fn validate_helm_values(content: &str) -> ValidationResult {
551    let data = match parse_yaml(content) {
552        Ok(d) => d,
553        Err(e) => {
554            return ValidationResult {
555                valid: false,
556                errors: vec![e],
557                warnings: vec![],
558                diagnostics: vec![],
559                hints: vec![],
560                yaml_type: Some(YamlType::HelmValues),
561            }
562        }
563    };
564
565    match HelmValues::from_value(&data) {
566        Ok(values) => values.validate(),
567        Err(e) => ValidationResult {
568            valid: false,
569            errors: vec![e],
570            warnings: vec![],
571            diagnostics: vec![],
572            hints: vec![],
573            yaml_type: Some(YamlType::HelmValues),
574        },
575    }
576}
577
578/// Validate an Ansible playbook
579pub fn validate_ansible(content: &str) -> ValidationResult {
580    let data = match parse_yaml(content) {
581        Ok(d) => d,
582        Err(e) => {
583            return ValidationResult {
584                valid: false,
585                errors: vec![e],
586                warnings: vec![],
587                diagnostics: vec![],
588                hints: vec![],
589                yaml_type: Some(YamlType::Ansible),
590            }
591        }
592    };
593
594    // Ansible playbooks are top-level arrays
595    let playbook: Vec<AnsiblePlay> = match serde_json::from_value(data) {
596        Ok(p) => p,
597        Err(e) => {
598            return ValidationResult {
599                valid: false,
600                errors: vec![format!("Failed to parse Ansible playbook: {e}")],
601                warnings: vec![],
602                diagnostics: vec![],
603                hints: vec![],
604                yaml_type: Some(YamlType::Ansible),
605            }
606        }
607    };
608
609    playbook.validate()
610}
611
612/// Auto-detect the YAML format and validate it.
613///
614/// This is the **main entry point** for one-call validation.  It:
615///
616/// 1. Splits multi-document YAML files (separated by `---`) and validates
617///    each document independently, merging the results.
618/// 2. Calls [`detect_yaml_type`] on the parsed content.
619/// 3. Dispatches to the appropriate per-format validator.
620///
621/// The returned [`ValidationResult`] is always populated — if parsing
622/// fails, `valid` is `false` and `errors` contains the parse error.
623///
624/// # Example
625///
626/// ```rust
627/// use devops_validate::validator::validate_auto;
628///
629/// // Valid multi-replica deployment — no errors, but possible warnings
630/// let yaml = r#"
631/// apiVersion: apps/v1
632/// kind: Deployment
633/// metadata:
634///   name: my-app
635/// spec:
636///   replicas: 3
637///   selector:
638///     matchLabels:
639///       app: my-app
640///   template:
641///     metadata:
642///       labels:
643///         app: my-app
644///     spec:
645///       containers:
646///         - name: app
647///           image: my-app:v1.0.0
648/// "#;
649///
650/// let result = validate_auto(yaml);
651/// assert!(result.valid);
652/// assert_eq!(result.yaml_type.unwrap().to_string(), "K8s Deployment");
653/// ```
654pub fn validate_auto(content: &str) -> ValidationResult {
655    // Check for multi-document YAML
656    let documents = split_yaml_documents(content);
657    if documents.len() > 1 {
658        return validate_multi_document(&documents);
659    }
660
661    let data = match parse_yaml(content) {
662        Ok(d) => d,
663        Err(e) => {
664            return ValidationResult {
665                valid: false,
666                errors: vec![e],
667                warnings: vec![],
668                diagnostics: vec![],
669                hints: vec![],
670                yaml_type: None,
671            }
672        }
673    };
674
675    let yaml_type = detect_yaml_type(&data);
676    match yaml_type {
677        YamlType::K8sDeployment
678        | YamlType::K8sService
679        | YamlType::K8sConfigMap
680        | YamlType::K8sSecret
681        | YamlType::K8sIngress
682        | YamlType::K8sHPA
683        | YamlType::K8sCronJob
684        | YamlType::K8sJob
685        | YamlType::K8sPVC
686        | YamlType::K8sNetworkPolicy
687        | YamlType::K8sStatefulSet
688        | YamlType::K8sDaemonSet
689        | YamlType::K8sRole
690        | YamlType::K8sClusterRole
691        | YamlType::K8sRoleBinding
692        | YamlType::K8sClusterRoleBinding
693        | YamlType::K8sServiceAccount
694        | YamlType::K8sGeneric => validate_k8s_manifest(content),
695        YamlType::GitLabCI => validate_gitlab_ci(content),
696        YamlType::DockerCompose => validate_docker_compose(content),
697        YamlType::GitHubActions => validate_github_actions(content),
698        YamlType::Prometheus => validate_prometheus(content),
699        YamlType::Alertmanager => validate_alertmanager(content),
700        YamlType::HelmValues => validate_helm_values(content),
701        YamlType::Ansible => validate_ansible(content),
702        YamlType::OpenAPI => ValidationResult {
703            valid: true,
704            errors: vec![],
705            warnings: vec!["OpenAPI validation not yet implemented — parsed OK".to_string()],
706            diagnostics: vec![],
707            hints: vec![],
708            yaml_type: Some(YamlType::OpenAPI),
709        },
710        YamlType::Generic => ValidationResult {
711            valid: true,
712            errors: vec![],
713            warnings: vec!["Generic YAML — no schema validation applied".to_string()],
714            diagnostics: vec![],
715            hints: vec![],
716            yaml_type: Some(YamlType::Generic),
717        },
718    }
719}
720
721// ═══════════════════════════════════════════════════════════════════════════
722// Multi-document YAML support
723// ═══════════════════════════════════════════════════════════════════════════
724
725/// Split a YAML string into individual documents.
726fn split_yaml_documents(content: &str) -> Vec<String> {
727    let mut documents = Vec::new();
728    let mut current = String::new();
729
730    for line in content.lines() {
731        if line.trim() == "---" && !current.trim().is_empty() {
732            documents.push(current.clone());
733            current.clear();
734        } else if line.trim() != "---" {
735            current.push_str(line);
736            current.push('\n');
737        }
738    }
739    if !current.trim().is_empty() {
740        documents.push(current);
741    }
742    documents
743}
744
745/// Validate a multi-document YAML file and merge results.
746fn validate_multi_document(documents: &[String]) -> ValidationResult {
747    let mut all_errors = Vec::new();
748    let mut all_warnings = Vec::new();
749    let mut all_diagnostics = Vec::new();
750    let mut all_valid = true;
751    let mut types_seen = Vec::new();
752
753    for (i, doc) in documents.iter().enumerate() {
754        let prefix = format!("[doc {}] ", i + 1);
755        let result = validate_auto(doc.trim());
756
757        if !result.valid {
758            all_valid = false;
759        }
760        for e in &result.errors {
761            all_errors.push(format!("{}{}", prefix, e));
762        }
763        for w in &result.warnings {
764            all_warnings.push(format!("{}{}", prefix, w));
765        }
766        for d in &result.diagnostics {
767            all_diagnostics.push(Diagnostic {
768                severity: d.severity.clone(),
769                message: format!("{}{}", prefix, d.message),
770                path: d.path.as_ref().map(|p| format!("{}{}", prefix, p)),
771            });
772        }
773        if let Some(t) = &result.yaml_type {
774            types_seen.push(format!("{}", t));
775        }
776    }
777
778    let type_summary = if types_seen.is_empty() {
779        "Generic".to_string()
780    } else {
781        types_seen.join(", ")
782    };
783
784    all_warnings.insert(
785        0,
786        format!(
787            "Multi-document YAML: {} documents detected ({})",
788            documents.len(),
789            type_summary
790        ),
791    );
792
793    ValidationResult {
794        valid: all_valid,
795        errors: all_errors,
796        warnings: all_warnings,
797        diagnostics: all_diagnostics,
798        hints: vec![],
799        yaml_type: Some(YamlType::Generic), // multi-doc is treated as generic container
800    }
801}
802
803// ═══════════════════════════════════════════════════════════════════════════
804// Semantic warning helpers
805// ═══════════════════════════════════════════════════════════════════════════
806
807/// Extended heuristic warnings for valid-but-risky K8s Deployments
808fn collect_k8s_warnings(data: &serde_json::Value) -> Vec<String> {
809    let mut warnings = Vec::new();
810    let kind = data.get("kind").and_then(|v| v.as_str()).unwrap_or("");
811
812    if kind == "Deployment" {
813        let spec = data.get("spec");
814        let replicas = spec
815            .and_then(|s| s.get("replicas"))
816            .and_then(|r| r.as_u64())
817            .unwrap_or(1);
818
819        if replicas == 1 {
820            let ns = data
821                .get("metadata")
822                .and_then(|m| m.get("namespace"))
823                .and_then(|n| n.as_str())
824                .unwrap_or("default");
825            warnings.push(format!(
826                "replicas=1 in namespace '{}' — consider >=2 for high availability",
827                ns
828            ));
829        }
830
831        if let Some(containers) = spec
832            .and_then(|s| s.get("template"))
833            .and_then(|t| t.get("spec"))
834            .and_then(|s| s.get("containers"))
835            .and_then(|c| c.as_array())
836        {
837            for c in containers {
838                let name = c.get("name").and_then(|n| n.as_str()).unwrap_or("?");
839
840                // Resource limits
841                let has_limits = c
842                    .get("resources")
843                    .and_then(|r| r.get("limits"))
844                    .is_some();
845                if !has_limits {
846                    warnings.push(format!(
847                        "Container '{}' has no resource limits — may cause OOM kills",
848                        name
849                    ));
850                }
851
852                // Probes
853                let has_liveness = c.get("livenessProbe").is_some();
854                let has_readiness = c.get("readinessProbe").is_some();
855                if !has_liveness {
856                    warnings.push(format!(
857                        "Container '{}' has no livenessProbe — Kubernetes won't detect hangs",
858                        name
859                    ));
860                }
861                if !has_readiness {
862                    warnings.push(format!(
863                        "Container '{}' has no readinessProbe — traffic may be sent to unready pods",
864                        name
865                    ));
866                }
867
868                // Image tag: `:latest` or no tag
869                if let Some(image) = c.get("image").and_then(|i| i.as_str())
870                    && (image.ends_with(":latest") || !image.contains(':')) {
871                        warnings.push(format!(
872                            "Container '{}': image '{}' uses ':latest' or no tag — pin a specific version for reproducibility",
873                            name, image
874                        ));
875                    }
876
877                // imagePullPolicy in production
878                if let Some(policy) = c.get("imagePullPolicy").and_then(|p| p.as_str())
879                    && policy == "Never" {
880                        warnings.push(format!(
881                            "Container '{}': imagePullPolicy=Never — image must be pre-loaded on every node",
882                            name
883                        ));
884                    }
885            }
886        }
887    }
888
889    warnings
890}
891
892/// Semantic warnings for K8s Service
893fn validate_service_semantics(svc: &K8sService) -> Vec<String> {
894    let mut warnings = Vec::new();
895    if let Some(svc_type) = &svc.spec.service_type {
896        if svc_type == "LoadBalancer" {
897            warnings.push("type=LoadBalancer creates a cloud load balancer — ensure this is intentional (cost implications)".to_string());
898        }
899        if svc_type == "NodePort" {
900            for port in &svc.spec.ports {
901                if port.port > 32767 {
902                    warnings.push(format!(
903                        "Port {} exceeds default NodePort range (30000-32767)",
904                        port.port
905                    ));
906                }
907            }
908        }
909    }
910    if svc.spec.selector.is_empty() {
911        warnings.push("Service has an empty selector — will match no pods".to_string());
912    }
913    warnings
914}
915
916/// Semantic warnings for K8s Secret
917fn validate_secret_semantics(sec: &K8sSecret) -> Vec<String> {
918    let mut warnings = Vec::new();
919    if sec.data.is_empty() && sec.string_data.is_none() {
920        warnings.push("Secret has no data and no stringData".to_string());
921    }
922    // Check if 'data' values look like valid base64
923    for (key, val) in &sec.data {
924        if base64_decode_check(val).is_err() {
925            warnings.push(format!(
926                "Secret key '{}': value does not appear to be valid base64",
927                key
928            ));
929        }
930    }
931    warnings
932}
933
934/// Quick check if a string is valid base64.
935fn base64_decode_check(s: &str) -> Result<(), ()> {
936    // Simple validation: base64 chars + padding
937    let stripped = s.trim();
938    if stripped.is_empty() {
939        return Ok(());
940    }
941    let base64_chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=\n\r ";
942    if stripped.chars().all(|c| base64_chars.contains(c)) {
943        Ok(())
944    } else {
945        Err(())
946    }
947}
948
949// ═══════════════════════════════════════════════════════════════════════════
950// Helpers
951// ═══════════════════════════════════════════════════════════════════════════
952
953fn value_type_name(v: &serde_json::Value) -> &'static str {
954    match v {
955        serde_json::Value::Null => "null",
956        serde_json::Value::Bool(_) => "boolean",
957        serde_json::Value::Number(_) => "number",
958        serde_json::Value::String(_) => "string",
959        serde_json::Value::Array(_) => "array",
960        serde_json::Value::Object(_) => "object",
961    }
962}
963
964fn format_serde_errors(e: &serde_json::Error) -> Vec<String> {
965    vec![e.to_string()]
966}
967
968// ═══════════════════════════════════════════════════════════════════════════
969// Tests
970// ═══════════════════════════════════════════════════════════════════════════
971
972#[cfg(test)]
973mod tests {
974    use super::*;
975
976    // ── Detection ──────────────────────────────────────────────────────
977
978    #[test]
979    fn detect_k8s_deployment() {
980        let yaml = r#"apiVersion: apps/v1
981kind: Deployment
982metadata:
983  name: test
984spec:
985  replicas: 1
986  selector:
987    matchLabels:
988      app: test
989  template:
990    metadata:
991      labels:
992        app: test
993    spec:
994      containers:
995        - name: app
996          image: nginx:1.25"#;
997        let data = parse_yaml(yaml).unwrap();
998        assert_eq!(detect_yaml_type(&data), YamlType::K8sDeployment);
999    }
1000
1001    #[test]
1002    fn detect_k8s_ingress() {
1003        let yaml = r#"apiVersion: networking.k8s.io/v1
1004kind: Ingress
1005metadata:
1006  name: test
1007spec:
1008  rules: []"#;
1009        let data = parse_yaml(yaml).unwrap();
1010        assert_eq!(detect_yaml_type(&data), YamlType::K8sIngress);
1011    }
1012
1013    #[test]
1014    fn detect_k8s_generic_unknown_kind() {
1015        let yaml = r#"apiVersion: custom.io/v1
1016kind: MyCustomResource
1017metadata:
1018  name: test
1019spec:
1020  foo: bar"#;
1021        let data = parse_yaml(yaml).unwrap();
1022        assert_eq!(detect_yaml_type(&data), YamlType::K8sGeneric);
1023    }
1024
1025    #[test]
1026    fn detect_docker_compose() {
1027        let yaml = r#"services:
1028  web:
1029    image: nginx
1030    ports:
1031      - "8080:80"
1032  db:
1033    image: postgres:15"#;
1034        let data = parse_yaml(yaml).unwrap();
1035        assert_eq!(detect_yaml_type(&data), YamlType::DockerCompose);
1036    }
1037
1038    #[test]
1039    fn detect_github_actions() {
1040        let yaml = r#"name: CI
1041on: [push]
1042jobs:
1043  build:
1044    runs-on: ubuntu-latest
1045    steps:
1046      - uses: actions/checkout@v4"#;
1047        let data = parse_yaml(yaml).unwrap();
1048        assert_eq!(detect_yaml_type(&data), YamlType::GitHubActions);
1049    }
1050
1051    #[test]
1052    fn detect_gitlab_ci() {
1053        let yaml = r#"stages:
1054  - build
1055  - test
1056build_job:
1057  stage: build
1058  script:
1059    - echo hello"#;
1060        let data = parse_yaml(yaml).unwrap();
1061        assert_eq!(detect_yaml_type(&data), YamlType::GitLabCI);
1062    }
1063
1064    #[test]
1065    fn detect_prometheus() {
1066        let yaml = r#"global:
1067  scrape_interval: 15s
1068scrape_configs:
1069  - job_name: prometheus
1070    static_configs:
1071      - targets: ['localhost:9090']"#;
1072        let data = parse_yaml(yaml).unwrap();
1073        assert_eq!(detect_yaml_type(&data), YamlType::Prometheus);
1074    }
1075
1076    #[test]
1077    fn detect_alertmanager() {
1078        let yaml = r#"route:
1079  receiver: default
1080receivers:
1081  - name: default"#;
1082        let data = parse_yaml(yaml).unwrap();
1083        assert_eq!(detect_yaml_type(&data), YamlType::Alertmanager);
1084    }
1085
1086    // ── Validation ─────────────────────────────────────────────────────
1087
1088    #[test]
1089    fn validate_valid_deployment() {
1090        let yaml = r#"apiVersion: apps/v1
1091kind: Deployment
1092metadata:
1093  name: myapp
1094  labels:
1095    app: myapp
1096spec:
1097  replicas: 3
1098  selector:
1099    matchLabels:
1100      app: myapp
1101  template:
1102    metadata:
1103      labels:
1104        app: myapp
1105    spec:
1106      containers:
1107        - name: app
1108          image: myapp:v1.2.3
1109          ports:
1110            - containerPort: 8080
1111          resources:
1112            limits:
1113              memory: "128Mi"
1114              cpu: "500m"
1115            requests:
1116              memory: "64Mi"
1117              cpu: "250m"
1118          livenessProbe:
1119            httpGet:
1120              path: /healthz
1121              port: 8080
1122          readinessProbe:
1123            httpGet:
1124              path: /ready
1125              port: 8080"#;
1126        let result = validate_auto(yaml);
1127        assert!(result.valid, "Expected valid, got errors: {:?}", result.errors);
1128    }
1129
1130    #[test]
1131    fn validate_hpa_min_gt_max() {
1132        let yaml = r#"apiVersion: autoscaling/v2
1133kind: HorizontalPodAutoscaler
1134metadata:
1135  name: test
1136spec:
1137  scaleTargetRef:
1138    apiVersion: apps/v1
1139    kind: Deployment
1140    name: test
1141  minReplicas: 10
1142  maxReplicas: 5"#;
1143        let result = validate_auto(yaml);
1144        assert!(!result.valid);
1145        assert!(result.errors.iter().any(|e| e.contains("minReplicas")));
1146    }
1147
1148    #[test]
1149    fn validate_cronjob_invalid_schedule() {
1150        let yaml = r#"apiVersion: batch/v1
1151kind: CronJob
1152metadata:
1153  name: test
1154spec:
1155  schedule: "not a cron"
1156  jobTemplate:
1157    spec:
1158      template:
1159        metadata:
1160          labels:
1161            app: test
1162        spec:
1163          containers:
1164            - name: job
1165              image: busybox
1166          restartPolicy: OnFailure"#;
1167        let result = validate_auto(yaml);
1168        assert!(!result.valid);
1169        assert!(result.errors.iter().any(|e| e.contains("cron schedule")));
1170    }
1171
1172    #[test]
1173    fn validate_docker_compose_no_image() {
1174        let yaml = r#"services:
1175  web:
1176    ports:
1177      - "8080:80""#;
1178        let result = validate_auto(yaml);
1179        assert!(!result.valid);
1180        assert!(result.errors.iter().any(|e| e.contains("image") || e.contains("build")));
1181    }
1182
1183    #[test]
1184    fn validate_github_actions_no_on() {
1185        let yaml = r#"name: CI
1186jobs:
1187  build:
1188    runs-on: ubuntu-latest
1189    steps:
1190      - run: echo hello"#;
1191        // This still detects as GitLabCI due to heuristics, but let's test explicit
1192        let result = validate_github_actions(yaml);
1193        assert!(!result.valid);
1194        assert!(result.errors.iter().any(|e| e.contains("'on'")));
1195    }
1196
1197    #[test]
1198    fn validate_prometheus_duplicate_jobs() {
1199        let yaml = r#"scrape_configs:
1200  - job_name: myapp
1201    static_configs:
1202      - targets: ['localhost:9090']
1203  - job_name: myapp
1204    static_configs:
1205      - targets: ['localhost:8080']"#;
1206        let result = validate_prometheus(yaml);
1207        assert!(!result.valid);
1208        assert!(result.errors.iter().any(|e| e.contains("Duplicate job_name")));
1209    }
1210
1211    #[test]
1212    fn validate_alertmanager_missing_receiver() {
1213        let yaml = r#"route:
1214  receiver: missing
1215receivers:
1216  - name: default"#;
1217        let result = validate_alertmanager(yaml);
1218        assert!(!result.valid);
1219        assert!(result.errors.iter().any(|e| e.contains("not defined")));
1220    }
1221
1222    #[test]
1223    fn validate_k8s_generic_unknown_kind() {
1224        let yaml = r#"apiVersion: custom.io/v1
1225kind: MyWidget
1226metadata:
1227  name: test
1228spec:
1229  replicas: 1"#;
1230        let result = validate_auto(yaml);
1231        assert!(result.valid);
1232        assert!(result.warnings.iter().any(|w| w.contains("no specific validator")));
1233    }
1234
1235    // ── Multi-document ─────────────────────────────────────────────────
1236
1237    #[test]
1238    fn validate_multi_document_yaml() {
1239        let yaml = r#"apiVersion: v1
1240kind: ConfigMap
1241metadata:
1242  name: test
1243data:
1244  key: value
1245---
1246apiVersion: v1
1247kind: Service
1248metadata:
1249  name: test
1250spec:
1251  selector:
1252    app: test
1253  ports:
1254    - port: 80"#;
1255        let result = validate_auto(yaml);
1256        assert!(result.valid);
1257        assert!(result.warnings.iter().any(|w| w.contains("Multi-document")));
1258    }
1259
1260    #[test]
1261    fn split_documents() {
1262        let yaml = "a: 1\n---\nb: 2\n---\nc: 3";
1263        let docs = split_yaml_documents(yaml);
1264        assert_eq!(docs.len(), 3);
1265    }
1266
1267    // ── Secret validation ──────────────────────────────────────────────
1268
1269    #[test]
1270    fn validate_secret_invalid_base64() {
1271        let yaml = r#"apiVersion: v1
1272kind: Secret
1273metadata:
1274  name: test
1275data:
1276  password: "not-valid-base64!@#""#;
1277        let result = validate_auto(yaml);
1278        assert!(result.valid); // structurally valid
1279        assert!(result.warnings.iter().any(|w| w.contains("base64")));
1280    }
1281}