sphinx_ultra/
validation.rs

1//! Content constraint validation system
2//!
3//! This module implements a constraint validation system inspired by sphinx-needs,
4//! providing schema-based validation, custom constraint rules, and severity-based
5//! actions for validation failures.
6
7pub mod constraint_engine;
8pub mod expression_evaluator;
9
10use std::collections::HashMap;
11use std::fmt;
12
13use serde::{Deserialize, Serialize};
14
15use crate::error::BuildError;
16
17pub use constraint_engine::ConstraintEngine;
18pub use expression_evaluator::ExpressionEvaluator;
19
20/// Represents the severity level of a validation failure
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
22#[serde(rename_all = "lowercase")]
23pub enum ValidationSeverity {
24    Info,
25    Warning,
26    Error,
27    Critical,
28}
29
30impl fmt::Display for ValidationSeverity {
31    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
32        match self {
33            ValidationSeverity::Info => write!(f, "info"),
34            ValidationSeverity::Warning => write!(f, "warning"),
35            ValidationSeverity::Error => write!(f, "error"),
36            ValidationSeverity::Critical => write!(f, "critical"),
37        }
38    }
39}
40
41/// Actions to take when a constraint validation fails
42#[derive(Debug, Clone, Serialize, Deserialize)]
43pub struct ConstraintActions {
44    /// Actions to execute on failure (warn, break)
45    pub on_fail: Vec<FailureAction>,
46    /// Style changes to apply
47    pub style_changes: Vec<String>,
48    /// Whether to force style changes (replace) or append them
49    pub force_style: bool,
50}
51
52impl Default for ConstraintActions {
53    fn default() -> Self {
54        Self {
55            on_fail: vec![FailureAction::Warn],
56            style_changes: Vec::new(),
57            force_style: false,
58        }
59    }
60}
61
62/// Specific actions to take on validation failure
63#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
64#[serde(rename_all = "lowercase")]
65pub enum FailureAction {
66    /// Log a warning
67    Warn,
68    /// Break the build (fail with error)
69    Break,
70    /// Apply style changes
71    Style,
72}
73
74/// A constraint validation rule
75#[derive(Debug, Clone, Serialize, Deserialize)]
76pub struct ValidationRule {
77    /// Unique name/identifier for this rule
78    pub name: String,
79    /// Human-readable description
80    pub description: Option<String>,
81    /// Constraint expression (Jinja2-like template)
82    pub constraint: String,
83    /// Severity level for failures
84    pub severity: ValidationSeverity,
85    /// Actions to take on failure
86    pub actions: ConstraintActions,
87    /// Error message template (supports variable substitution)
88    pub error_template: Option<String>,
89}
90
91/// Result of a single validation check
92#[derive(Debug, Clone)]
93pub struct ValidationResult {
94    /// Whether the validation passed
95    pub passed: bool,
96    /// Error message if validation failed
97    pub error_message: Option<String>,
98    /// Additional context or details
99    pub context: HashMap<String, String>,
100}
101
102impl ValidationResult {
103    /// Create a successful validation result
104    pub fn success() -> Self {
105        Self {
106            passed: true,
107            error_message: None,
108            context: HashMap::new(),
109        }
110    }
111
112    /// Create a failed validation result with message
113    pub fn failure(message: String) -> Self {
114        Self {
115            passed: false,
116            error_message: Some(message),
117            context: HashMap::new(),
118        }
119    }
120
121    /// Create a failed validation result with message and context
122    pub fn failure_with_context(message: String, context: HashMap<String, String>) -> Self {
123        Self {
124            passed: false,
125            error_message: Some(message),
126            context,
127        }
128    }
129
130    /// Add context to this result
131    pub fn with_context(mut self, key: String, value: String) -> Self {
132        self.context.insert(key, value);
133        self
134    }
135}
136
137/// Results from applying constraint actions
138#[derive(Debug)]
139pub struct ActionResult {
140    /// Whether actions were applied successfully
141    pub success: bool,
142    /// Any warnings generated during action application
143    pub warnings: Vec<String>,
144    /// Any errors that occurred
145    pub errors: Vec<BuildError>,
146}
147
148impl ActionResult {
149    pub fn success() -> Self {
150        Self {
151            success: true,
152            warnings: Vec::new(),
153            errors: Vec::new(),
154        }
155    }
156
157    pub fn failure(error: BuildError) -> Self {
158        Self {
159            success: false,
160            warnings: Vec::new(),
161            errors: vec![error],
162        }
163    }
164}
165
166/// A content item that can be validated
167#[derive(Debug, Clone, Serialize, Deserialize)]
168pub struct ContentItem {
169    /// Unique identifier
170    pub id: String,
171    /// Title/name of the item
172    pub title: String,
173    /// Content body
174    pub content: String,
175    /// Metadata fields with typed values
176    pub metadata: HashMap<String, FieldValue>,
177    /// List of constraint names that apply to this item
178    pub constraints: Vec<String>,
179    /// Relationships to other content items
180    pub relationships: HashMap<String, Vec<String>>,
181    /// Document location information
182    pub location: ItemLocation,
183    /// Current style applied to this item
184    pub style: Option<String>,
185}
186
187/// Typed field value for content item metadata
188#[derive(Debug, Clone, Serialize, Deserialize)]
189#[serde(untagged)]
190pub enum FieldValue {
191    String(String),
192    Integer(i64),
193    Float(f64),
194    Boolean(bool),
195    Array(Vec<FieldValue>),
196    Object(HashMap<String, FieldValue>),
197}
198
199impl FieldValue {
200    /// Get the value as a string
201    pub fn as_string(&self) -> Option<&str> {
202        match self {
203            FieldValue::String(s) => Some(s),
204            _ => None,
205        }
206    }
207
208    /// Get the value as an integer
209    pub fn as_integer(&self) -> Option<i64> {
210        match self {
211            FieldValue::Integer(i) => Some(*i),
212            _ => None,
213        }
214    }
215
216    /// Get the value as a boolean
217    pub fn as_boolean(&self) -> Option<bool> {
218        match self {
219            FieldValue::Boolean(b) => Some(*b),
220            _ => None,
221        }
222    }
223
224    /// Get the value as an array
225    pub fn as_array(&self) -> Option<&Vec<FieldValue>> {
226        match self {
227            FieldValue::Array(arr) => Some(arr),
228            _ => None,
229        }
230    }
231}
232
233impl fmt::Display for FieldValue {
234    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
235        match self {
236            FieldValue::String(s) => write!(f, "{}", s),
237            FieldValue::Integer(i) => write!(f, "{}", i),
238            FieldValue::Float(fl) => write!(f, "{}", fl),
239            FieldValue::Boolean(b) => write!(f, "{}", b),
240            FieldValue::Array(arr) => {
241                write!(f, "[")?;
242                for (i, item) in arr.iter().enumerate() {
243                    if i > 0 {
244                        write!(f, ", ")?;
245                    }
246                    write!(f, "{}", item)?;
247                }
248                write!(f, "]")
249            }
250            FieldValue::Object(obj) => {
251                write!(f, "{{")?;
252                for (i, (key, value)) in obj.iter().enumerate() {
253                    if i > 0 {
254                        write!(f, ", ")?;
255                    }
256                    write!(f, "{}: {}", key, value)?;
257                }
258                write!(f, "}}")
259            }
260        }
261    }
262}
263
264/// Location information for content items
265#[derive(Debug, Clone, Serialize, Deserialize)]
266pub struct ItemLocation {
267    /// Document name/path
268    pub docname: String,
269    /// Line number in the document
270    pub lineno: Option<u32>,
271    /// Optional source file path
272    pub source_path: Option<String>,
273}
274
275/// Context for validation operations
276#[derive(Debug)]
277pub struct ValidationContext<'a> {
278    /// The item being validated
279    pub current_item: &'a ContentItem,
280    /// All content items in the project
281    pub all_items: &'a HashMap<String, ContentItem>,
282    /// Global configuration values
283    pub config: &'a ValidationConfig,
284    /// Additional context variables
285    pub variables: HashMap<String, FieldValue>,
286}
287
288/// Configuration for the validation system
289#[derive(Debug, Clone, Serialize, Deserialize)]
290pub struct ValidationConfig {
291    /// Constraint definitions by name
292    pub constraints: HashMap<String, ConstraintDefinition>,
293    /// Constraint failure actions by severity
294    pub constraint_failed_options: HashMap<String, ConstraintActions>,
295    /// Global validation settings
296    pub settings: ValidationSettings,
297}
298
299impl Default for ValidationConfig {
300    fn default() -> Self {
301        let mut constraint_failed_options = HashMap::new();
302
303        // Default actions for different severity levels
304        constraint_failed_options.insert(
305            "info".to_string(),
306            ConstraintActions {
307                on_fail: vec![],
308                style_changes: vec![],
309                force_style: false,
310            },
311        );
312
313        constraint_failed_options.insert(
314            "warning".to_string(),
315            ConstraintActions {
316                on_fail: vec![FailureAction::Warn],
317                style_changes: vec!["constraint-warning".to_string()],
318                force_style: false,
319            },
320        );
321
322        constraint_failed_options.insert(
323            "error".to_string(),
324            ConstraintActions {
325                on_fail: vec![FailureAction::Warn, FailureAction::Style],
326                style_changes: vec!["constraint-error".to_string()],
327                force_style: false,
328            },
329        );
330
331        constraint_failed_options.insert(
332            "critical".to_string(),
333            ConstraintActions {
334                on_fail: vec![FailureAction::Break],
335                style_changes: vec!["constraint-critical".to_string()],
336                force_style: true,
337            },
338        );
339
340        Self {
341            constraints: HashMap::new(),
342            constraint_failed_options,
343            settings: ValidationSettings::default(),
344        }
345    }
346}
347
348/// A constraint definition with multiple checks
349#[derive(Debug, Clone, Serialize, Deserialize)]
350pub struct ConstraintDefinition {
351    /// Individual check expressions (check_0, check_1, etc.)
352    pub checks: HashMap<String, String>,
353    /// Severity level for this constraint
354    pub severity: ValidationSeverity,
355    /// Optional error message template
356    pub error_message: Option<String>,
357    /// Description of what this constraint validates
358    pub description: Option<String>,
359}
360
361/// Global validation settings
362#[derive(Debug, Clone, Serialize, Deserialize)]
363pub struct ValidationSettings {
364    /// Whether to enable constraint validation
365    pub enable_constraints: bool,
366    /// Whether to cache validation results
367    pub cache_results: bool,
368    /// Maximum number of validation errors before stopping
369    pub max_errors: Option<usize>,
370    /// Whether to continue validation after errors
371    pub continue_on_error: bool,
372}
373
374impl Default for ValidationSettings {
375    fn default() -> Self {
376        Self {
377            enable_constraints: true,
378            cache_results: true,
379            max_errors: None,
380            continue_on_error: true,
381        }
382    }
383}
384
385/// Core trait for validators
386pub trait Validator {
387    /// Validate content against rules
388    fn validate(&self, context: &ValidationContext) -> ValidationResult;
389
390    /// Get validation rules supported by this validator
391    fn get_validation_rules(&self) -> Vec<ValidationRule>;
392
393    /// Get the severity level for this validator
394    fn get_severity(&self) -> ValidationSeverity;
395
396    /// Whether this validator supports incremental validation
397    fn supports_incremental(&self) -> bool {
398        false
399    }
400}
401
402/// Trait for constraint-specific validation
403pub trait ConstraintValidator: Validator {
404    /// Validate a specific constraint rule against a content item
405    fn validate_constraint(&self, rule: &ValidationRule, item: &ContentItem) -> ValidationResult;
406
407    /// Apply actions based on validation failures
408    fn apply_actions(
409        &self,
410        failures: &[ValidationFailure],
411        actions: &ConstraintActions,
412    ) -> ActionResult;
413}
414
415/// A validation failure with detailed information
416#[derive(Debug, Clone)]
417pub struct ValidationFailure {
418    /// The validation rule that failed
419    pub rule: ValidationRule,
420    /// The result of the failed validation
421    pub result: ValidationResult,
422    /// The content item that failed validation
423    pub item_id: String,
424    /// Severity of the failure
425    pub severity: ValidationSeverity,
426}
427
428impl ValidationFailure {
429    pub fn new(rule: ValidationRule, result: ValidationResult, item_id: String) -> Self {
430        let severity = rule.severity;
431        Self {
432            rule,
433            result,
434            item_id,
435            severity,
436        }
437    }
438}
439
440#[cfg(test)]
441mod tests {
442    use super::*;
443
444    #[test]
445    fn test_validation_result_creation() {
446        let success = ValidationResult::success();
447        assert!(success.passed);
448        assert!(success.error_message.is_none());
449
450        let failure = ValidationResult::failure("Test error".to_string());
451        assert!(!failure.passed);
452        assert_eq!(failure.error_message.unwrap(), "Test error");
453    }
454
455    #[test]
456    fn test_field_value_display() {
457        let string_val = FieldValue::String("test".to_string());
458        assert_eq!(format!("{}", string_val), "test");
459
460        let int_val = FieldValue::Integer(42);
461        assert_eq!(format!("{}", int_val), "42");
462
463        let bool_val = FieldValue::Boolean(true);
464        assert_eq!(format!("{}", bool_val), "true");
465    }
466
467    #[test]
468    fn test_validation_config_defaults() {
469        let config = ValidationConfig::default();
470        assert!(config.settings.enable_constraints);
471        assert!(config.settings.cache_results);
472        assert!(config.constraint_failed_options.contains_key("critical"));
473    }
474
475    #[test]
476    fn test_content_item_creation() {
477        let item = ContentItem {
478            id: "test-001".to_string(),
479            title: "Test Item".to_string(),
480            content: "Test content".to_string(),
481            metadata: HashMap::new(),
482            constraints: vec!["test-constraint".to_string()],
483            relationships: HashMap::new(),
484            location: ItemLocation {
485                docname: "test.rst".to_string(),
486                lineno: Some(10),
487                source_path: None,
488            },
489            style: None,
490        };
491
492        assert_eq!(item.id, "test-001");
493        assert_eq!(item.constraints.len(), 1);
494    }
495}