Skip to main content

devops_models/models/
validation.rs

1//! Validation types and traits for configuration file validation.
2//!
3//! This module provides:
4//! - [`Severity`] - Error severity levels (Error, Warning, Info, Hint)
5//! - [`Diagnostic`] - Individual validation findings with path info
6//! - [`ValidationResult`] - Complete validation outcome with errors, warnings, diagnostics
7//! - [`YamlType`] - Detected YAML configuration type (K8s, GitLab CI, etc.)
8//! - [`ConfigValidator`] - Trait for implementing configuration validators
9
10use serde::{Deserialize, Serialize};
11
12/// Severity level for validation diagnostics.
13///
14/// Variants are ordered from most to least severe:
15/// `Error` < `Warning` < `Info` < `Hint`
16#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
17#[serde(rename_all = "lowercase")]
18pub enum Severity {
19    /// Structure is invalid — must be fixed.
20    Error,
21    /// Valid but risky — likely a mistake.
22    Warning,
23    /// Best-practice suggestion.
24    Info,
25    /// Optional improvement hint.
26    Hint,
27}
28
29/// A single validation diagnostic with severity and optional path.
30///
31/// # Example
32///
33/// ```
34/// use devops_models::models::validation::{Diagnostic, Severity};
35///
36/// let diag = Diagnostic {
37///     severity: Severity::Warning,
38///     message: "replicas=1 may cause availability issues".to_string(),
39///     path: Some("spec > replicas".to_string()),
40/// };
41/// ```
42#[derive(Debug, Clone, Serialize, Deserialize)]
43pub struct Diagnostic {
44    /// How serious this finding is.
45    pub severity: Severity,
46    /// Human-readable description of the issue.
47    pub message: String,
48    /// JSON-pointer-like path to the offending field (e.g. `"spec > template > spec > containers > 0"`).
49    #[serde(default)]
50    pub path: Option<String>,
51}
52
53/// Result of validating a YAML configuration file.
54///
55/// Contains both simple string lists (errors, warnings) for easy display
56/// and rich diagnostics for detailed UI rendering.
57#[derive(Debug, Clone, Serialize, Deserialize)]
58pub struct ValidationResult {
59    /// Whether the configuration passed structural validation (no errors).
60    pub valid: bool,
61    /// Error messages (structural issues that make the config invalid).
62    pub errors: Vec<String>,
63    /// Warning messages (valid but risky configurations).
64    pub warnings: Vec<String>,
65    /// Optimization hints (Info/Hint severity — best-practice suggestions).
66    #[serde(default)]
67    pub hints: Vec<String>,
68    /// Rich diagnostics (superset of errors+warnings+hints, includes Info/Hint).
69    #[serde(default)]
70    pub diagnostics: Vec<Diagnostic>,
71    /// The detected YAML type, if recognized.
72    #[serde(default)]
73    pub yaml_type: Option<YamlType>,
74}
75
76impl ValidationResult {
77    /// Convenience: build from a list of Diagnostics.
78    pub fn from_diagnostics(yaml_type: YamlType, diags: Vec<Diagnostic>) -> Self {
79        let errors: Vec<String> = diags
80            .iter()
81            .filter(|d| d.severity == Severity::Error)
82            .map(|d| d.message.clone())
83            .collect();
84        let warnings: Vec<String> = diags
85            .iter()
86            .filter(|d| d.severity == Severity::Warning)
87            .map(|d| d.message.clone())
88            .collect();
89        let hints: Vec<String> = diags
90            .iter()
91            .filter(|d| matches!(d.severity, Severity::Info | Severity::Hint))
92            .map(|d| d.message.clone())
93            .collect();
94        let valid = errors.is_empty();
95        Self {
96            valid,
97            errors,
98            warnings,
99            hints,
100            diagnostics: diags,
101            yaml_type: Some(yaml_type),
102        }
103    }
104}
105
106/// Detected YAML configuration type.
107///
108/// Used by [`ValidationResult::yaml_type`] to report which format was
109/// recognised.  Variant names are self-documenting (e.g. `K8sDeployment`,
110/// `GitLabCI`, `DockerCompose`).
111#[allow(missing_docs)]
112#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
113#[serde(rename_all = "lowercase")]
114pub enum YamlType {
115    // ── Kubernetes ──
116    K8sDeployment,
117    K8sService,
118    K8sConfigMap,
119    K8sSecret,
120    K8sIngress,
121    K8sHPA,
122    K8sCronJob,
123    K8sJob,
124    K8sPVC,
125    K8sNetworkPolicy,
126    K8sStatefulSet,
127    K8sDaemonSet,
128    K8sRole,
129    K8sClusterRole,
130    K8sRoleBinding,
131    K8sClusterRoleBinding,
132    K8sServiceAccount,
133    /// Any resource with apiVersion+kind that we don't have a specific model for.
134    K8sGeneric,
135    // ── CI/CD ──
136    GitLabCI,
137    GitHubActions,
138    // ── Container orchestration ──
139    DockerCompose,
140    // ── Monitoring ──
141    Prometheus,
142    Alertmanager,
143    // ── Configuration ──
144    HelmValues,
145    Ansible,
146    // ── API ──
147    OpenAPI,
148    // ── Fallback ──
149    Generic,
150}
151
152impl std::fmt::Display for YamlType {
153    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
154        match self {
155            Self::K8sDeployment => write!(f, "K8s Deployment"),
156            Self::K8sService => write!(f, "K8s Service"),
157            Self::K8sConfigMap => write!(f, "K8s ConfigMap"),
158            Self::K8sSecret => write!(f, "K8s Secret"),
159            Self::K8sIngress => write!(f, "K8s Ingress"),
160            Self::K8sHPA => write!(f, "K8s HPA"),
161            Self::K8sCronJob => write!(f, "K8s CronJob"),
162            Self::K8sJob => write!(f, "K8s Job"),
163            Self::K8sPVC => write!(f, "K8s PVC"),
164            Self::K8sNetworkPolicy => write!(f, "K8s NetworkPolicy"),
165            Self::K8sStatefulSet => write!(f, "K8s StatefulSet"),
166            Self::K8sDaemonSet => write!(f, "K8s DaemonSet"),
167            Self::K8sRole => write!(f, "K8s Role"),
168            Self::K8sClusterRole => write!(f, "K8s ClusterRole"),
169            Self::K8sRoleBinding => write!(f, "K8s RoleBinding"),
170            Self::K8sClusterRoleBinding => write!(f, "K8s ClusterRoleBinding"),
171            Self::K8sServiceAccount => write!(f, "K8s ServiceAccount"),
172            Self::K8sGeneric => write!(f, "K8s (generic)"),
173            Self::GitLabCI => write!(f, "GitLab CI"),
174            Self::GitHubActions => write!(f, "GitHub Actions"),
175            Self::DockerCompose => write!(f, "Docker Compose"),
176            Self::Prometheus => write!(f, "Prometheus"),
177            Self::Alertmanager => write!(f, "Alertmanager"),
178            Self::HelmValues => write!(f, "Helm Values"),
179            Self::Ansible => write!(f, "Ansible Playbook"),
180            Self::OpenAPI => write!(f, "OpenAPI"),
181            Self::Generic => write!(f, "Generic YAML"),
182        }
183    }
184}
185
186/// Trait that all configuration validators implement.
187///
188/// Adding a new config type involves:
189/// 1. Creating a struct that deserializes from YAML via serde
190/// 2. Implementing this trait with structure and semantic validation
191/// 3. Registering the type in `yaml_validator::detect_yaml_type()`
192///
193/// # Example
194///
195/// ```rust,ignore
196/// use shared::models::validation::{ConfigValidator, Diagnostic, Severity, YamlType};
197///
198/// struct MyConfig {
199///     name: String,
200/// }
201///
202/// impl ConfigValidator for MyConfig {
203///     fn yaml_type(&self) -> YamlType {
204///         YamlType::Generic
205///     }
206///
207///     fn validate_structure(&self) -> Vec<Diagnostic> {
208///         if self.name.is_empty() {
209///             vec![Diagnostic {
210///                 severity: Severity::Error,
211///                 message: "name cannot be empty".to_string(),
212///                 path: Some("name".to_string()),
213///             }]
214///         } else {
215///             vec![]
216///         }
217///     }
218///
219///     fn validate_semantics(&self) -> Vec<Diagnostic> {
220///         vec![]
221///     }
222/// }
223/// ```
224pub trait ConfigValidator {
225    /// The YAML type this validator handles.
226    fn yaml_type(&self) -> YamlType;
227
228    /// Structural validation — errors mean the config is invalid.
229    ///
230    /// Returns diagnostics for issues that make the configuration
231    /// syntactically or structurally invalid (missing required fields,
232    /// wrong types, etc.).
233    fn validate_structure(&self) -> Vec<Diagnostic>;
234
235    /// Semantic validation — best-practice warnings, hints, and info.
236    ///
237    /// Returns diagnostics for issues that are technically valid but
238    /// may indicate misconfiguration or deviate from best practices
239    /// (e.g., replicas=1, missing resource limits).
240    fn validate_semantics(&self) -> Vec<Diagnostic>;
241
242    /// Full validation: structure + semantics combined.
243    fn validate(&self) -> ValidationResult {
244        let mut diags = self.validate_structure();
245        diags.extend(self.validate_semantics());
246        ValidationResult::from_diagnostics(self.yaml_type(), diags)
247    }
248}
249
250/// Output of the YAML auto-repair pipeline.
251///
252/// Returned by [`devops_validate::repair::repair_yaml`](../../../devops_validate/repair/fn.repair_yaml.html).
253#[derive(Debug, Clone, Serialize, Deserialize)]
254pub struct RepairResult {
255    /// `true` if no issues remain after applying fixes.
256    pub valid: bool,
257    /// The repaired YAML string (may equal input if nothing was fixed).
258    pub repaired_yaml: String,
259    /// Issues that could not be auto-fixed.
260    pub errors: Vec<String>,
261    /// Fixes that were applied (human-readable log).
262    pub warnings: Vec<String>,
263    /// Fields that were ambiguous and need LLM assistance.
264    pub llm_fields: Vec<String>,
265    /// Single-sentence summary of the repair outcome.
266    pub summary: String,
267}
268
269/// Record of a single deterministic fix applied during repair.
270#[allow(missing_docs)]
271#[derive(Debug, Clone, Serialize, Deserialize)]
272pub struct FixRecord {
273    pub field_path: String,
274    pub old_value: serde_json::Value,
275    pub new_value: serde_json::Value,
276    pub reason: String,
277}
278
279/// A field that the repair pipeline could not fix automatically.
280#[allow(missing_docs)]
281#[derive(Debug, Clone, Serialize, Deserialize)]
282pub struct AmbiguityQuestion {
283    pub field_path: String,
284    pub question: String,
285    pub options: Vec<String>,
286    #[serde(default)]
287    pub default: Option<String>,
288}
289
290/// Stateful session tracking an interactive validation + repair workflow.
291#[allow(missing_docs)]
292#[derive(Debug, Clone, Serialize, Deserialize)]
293pub struct ValidationSession {
294    pub session_id: String,
295    pub yaml_original: String,
296    pub yaml_current: String,
297    pub fixes_applied: Vec<FixRecord>,
298    pub pending_ambiguities: Vec<AmbiguityQuestion>,
299    pub status: SessionStatus,
300    pub created_at: String,
301}
302
303/// Lifecycle status of a [`ValidationSession`].
304#[allow(missing_docs)]
305#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
306#[serde(rename_all = "lowercase")]
307pub enum SessionStatus {
308    InProgress,
309    WaitingUser,
310    Completed,
311}
312
313#[cfg(test)]
314mod tests {
315    use super::*;
316
317    #[test]
318    fn test_severity_ordering() {
319        // Enum variants are ordered by declaration: Error < Warning < Info < Hint
320        // So Error is the "smallest" (least severe in terms of code)
321        // but semantically Error is the most severe issue
322        assert!(Severity::Error < Severity::Warning);
323        assert!(Severity::Warning < Severity::Info);
324        assert!(Severity::Info < Severity::Hint);
325    }
326
327    #[test]
328    fn test_severity_serde_roundtrip() {
329        let sev = Severity::Warning;
330        let json = serde_json::to_string(&sev).unwrap();
331        assert_eq!(json, r#""warning""#);
332        let parsed: Severity = serde_json::from_str(&json).unwrap();
333        assert_eq!(parsed, Severity::Warning);
334    }
335
336    #[test]
337    fn test_yaml_type_display() {
338        assert_eq!(format!("{}", YamlType::K8sDeployment), "K8s Deployment");
339        assert_eq!(format!("{}", YamlType::DockerCompose), "Docker Compose");
340        assert_eq!(format!("{}", YamlType::GitLabCI), "GitLab CI");
341        assert_eq!(format!("{}", YamlType::GitHubActions), "GitHub Actions");
342        assert_eq!(format!("{}", YamlType::Prometheus), "Prometheus");
343        assert_eq!(format!("{}", YamlType::Generic), "Generic YAML");
344    }
345
346    #[test]
347    fn test_yaml_type_serde_roundtrip() {
348        let yt = YamlType::K8sService;
349        let json = serde_json::to_string(&yt).unwrap();
350        let parsed: YamlType = serde_json::from_str(&json).unwrap();
351        assert_eq!(parsed, YamlType::K8sService);
352    }
353
354    #[test]
355    fn test_validation_result_from_diagnostics() {
356        let diags = vec![
357            Diagnostic {
358                severity: Severity::Error,
359                message: "Test error".to_string(),
360                path: Some("spec".to_string()),
361            },
362            Diagnostic {
363                severity: Severity::Warning,
364                message: "Test warning".to_string(),
365                path: None,
366            },
367            Diagnostic {
368                severity: Severity::Info,
369                message: "Info message".to_string(),
370                path: None,
371            },
372        ];
373
374        let result = ValidationResult::from_diagnostics(YamlType::K8sDeployment, diags);
375
376        assert!(!result.valid);
377        assert_eq!(result.errors.len(), 1);
378        assert_eq!(result.warnings.len(), 1);
379        assert_eq!(result.hints.len(), 1);
380        assert_eq!(result.diagnostics.len(), 3);
381        assert_eq!(result.yaml_type, Some(YamlType::K8sDeployment));
382    }
383
384    #[test]
385    fn test_validation_result_valid_when_no_errors() {
386        let diags = vec![
387            Diagnostic {
388                severity: Severity::Warning,
389                message: "Warning only".to_string(),
390                path: None,
391            },
392        ];
393
394        let result = ValidationResult::from_diagnostics(YamlType::DockerCompose, diags);
395
396        assert!(result.valid);
397        assert!(result.errors.is_empty());
398        assert_eq!(result.warnings.len(), 1);
399    }
400
401    #[test]
402    fn test_diagnostic_path_default() {
403        let diag = Diagnostic {
404            severity: Severity::Error,
405            message: "Error".to_string(),
406            path: None,
407        };
408        assert!(diag.path.is_none());
409
410        let diag_with_path = Diagnostic {
411            severity: Severity::Error,
412            message: "Error".to_string(),
413            path: Some("spec > containers > 0".to_string()),
414        };
415        assert_eq!(diag_with_path.path, Some("spec > containers > 0".to_string()));
416    }
417
418    #[test]
419    fn test_session_status_serde() {
420        let status = SessionStatus::InProgress;
421        let json = serde_json::to_string(&status).unwrap();
422        // serde rename_all = "lowercase" converts InProgress -> "inprogress"
423        assert_eq!(json, r#""inprogress""#);
424        let parsed: SessionStatus = serde_json::from_str(&json).unwrap();
425        assert_eq!(parsed, SessionStatus::InProgress);
426    }
427}