sphinx_ultra/validation/
constraint_engine.rs

1//! Constraint processing engine
2//!
3//! This module provides the core constraint validation engine that processes
4//! validation rules against content items, inspired by sphinx-needs constraint system.
5
6use std::collections::HashMap;
7
8use minijinja::{Environment, Template};
9
10use crate::error::BuildError;
11use crate::validation::expression_evaluator::ExpressionEvaluator;
12use crate::validation::{
13    ActionResult, ConstraintActions, ConstraintValidator, ContentItem, FailureAction,
14    ValidationContext, ValidationFailure, ValidationResult, ValidationRule, ValidationSeverity,
15    Validator,
16};
17
18/// Core constraint validation engine
19pub struct ConstraintEngine {
20    /// Template environment for processing constraint expressions
21    template_env: Environment<'static>,
22    /// Cache for compiled templates
23    template_cache: HashMap<String, Template<'static, 'static>>,
24}
25
26impl ConstraintEngine {
27    /// Create a new constraint engine
28    pub fn new() -> Self {
29        let mut env = Environment::new();
30
31        // Register helper functions similar to sphinx-needs filter functions
32        env.add_function("has_tag", |tags: Vec<String>, tag: String| -> bool {
33            tags.contains(&tag)
34        });
35
36        env.add_function("in_list", |value: String, list: Vec<String>| -> bool {
37            list.contains(&value)
38        });
39
40        env.add_function("not_empty", |value: String| -> bool { !value.is_empty() });
41
42        Self {
43            template_env: env,
44            template_cache: HashMap::new(),
45        }
46    }
47
48    /// Process all constraints for a given content item
49    pub fn process_constraints(
50        &mut self,
51        item: &ContentItem,
52        context: &ValidationContext,
53    ) -> Result<(ContentItem, Vec<ValidationFailure>), BuildError> {
54        let mut modified_item = item.clone();
55        let mut failures = Vec::new();
56
57        for constraint_name in &modified_item.constraints.clone() {
58            if let Some(constraint_def) = context.config.constraints.get(constraint_name) {
59                // Process each check in the constraint
60                for (check_name, expression) in &constraint_def.checks {
61                    let rule = ValidationRule {
62                        name: format!("{}::{}", constraint_name, check_name),
63                        description: constraint_def.description.clone(),
64                        constraint: expression.clone(),
65                        severity: constraint_def.severity,
66                        actions: context
67                            .config
68                            .constraint_failed_options
69                            .get(&constraint_def.severity.to_string())
70                            .cloned()
71                            .unwrap_or_default(),
72                        error_template: constraint_def.error_message.clone(),
73                    };
74
75                    let result = self.validate_constraint(&rule, &modified_item)?;
76
77                    if !result.passed {
78                        let failure =
79                            ValidationFailure::new(rule, result, modified_item.id.clone());
80                        failures.push(failure);
81                    }
82                }
83            }
84        }
85
86        // Apply actions for failures
87        if !failures.is_empty() {
88            self.apply_failure_actions(&mut modified_item, &failures)?;
89        }
90
91        Ok((modified_item, failures))
92    }
93
94    /// Process all constraints for a given content item (mutable version)
95    pub fn process_constraints_mut(
96        &mut self,
97        item: &mut ContentItem,
98        context: &ValidationContext,
99    ) -> Result<Vec<ValidationFailure>, BuildError> {
100        let mut failures = Vec::new();
101
102        for constraint_name in &item.constraints.clone() {
103            if let Some(constraint_def) = context.config.constraints.get(constraint_name) {
104                // Process each check in the constraint
105                for (check_name, expression) in &constraint_def.checks {
106                    let rule = ValidationRule {
107                        name: format!("{}::{}", constraint_name, check_name),
108                        description: constraint_def.description.clone(),
109                        constraint: expression.clone(),
110                        severity: constraint_def.severity,
111                        actions: context
112                            .config
113                            .constraint_failed_options
114                            .get(&constraint_def.severity.to_string())
115                            .cloned()
116                            .unwrap_or_default(),
117                        error_template: constraint_def.error_message.clone(),
118                    };
119
120                    let result = self.validate_constraint(&rule, item)?;
121
122                    if !result.passed {
123                        let failure = ValidationFailure::new(rule, result, item.id.clone());
124                        failures.push(failure);
125                    }
126                }
127            }
128        }
129
130        // Apply actions for failures
131        if !failures.is_empty() {
132            self.apply_failure_actions(item, &failures)?;
133        }
134
135        Ok(failures)
136    }
137
138    /// Validate a single constraint expression against an item
139    pub fn validate_constraint(
140        &mut self,
141        rule: &ValidationRule,
142        item: &ContentItem,
143    ) -> Result<ValidationResult, BuildError> {
144        // Use the expression evaluator for constraint evaluation
145        match ExpressionEvaluator::evaluate(&rule.constraint, item) {
146            Ok(passed) => {
147                if passed {
148                    Ok(ValidationResult::success())
149                } else {
150                    let error_message = self.generate_error_message(rule, item)?;
151                    Ok(ValidationResult::failure(error_message))
152                }
153            }
154            Err(e) => Err(BuildError::ValidationError(format!(
155                "Failed to evaluate constraint '{}': {}",
156                rule.constraint, e
157            ))),
158        }
159    }
160
161    /// Apply actions based on validation failures
162    fn apply_failure_actions(
163        &self,
164        item: &mut ContentItem,
165        failures: &[ValidationFailure],
166    ) -> Result<(), BuildError> {
167        for failure in failures {
168            let actions = &failure.rule.actions;
169
170            // Apply on_fail actions
171            for action in &actions.on_fail {
172                match action {
173                    FailureAction::Warn => {
174                        log::warn!(
175                            "Constraint validation failed for item '{}': {} (rule: {})",
176                            item.id,
177                            failure
178                                .result
179                                .error_message
180                                .as_deref()
181                                .unwrap_or("Unknown error"),
182                            failure.rule.name
183                        );
184                    }
185                    FailureAction::Break => {
186                        return Err(BuildError::ValidationError(format!(
187                            "Critical constraint validation failed for item '{}': {} (rule: {})",
188                            item.id,
189                            failure
190                                .result
191                                .error_message
192                                .as_deref()
193                                .unwrap_or("Unknown error"),
194                            failure.rule.name
195                        )));
196                    }
197                    FailureAction::Style => {
198                        // Style action is handled below
199                    }
200                }
201            }
202
203            // Apply style changes
204            if !actions.style_changes.is_empty() || actions.on_fail.contains(&FailureAction::Style)
205            {
206                self.apply_style_changes(item, actions);
207            }
208        }
209
210        Ok(())
211    }
212
213    /// Apply style changes to a content item
214    fn apply_style_changes(&self, item: &mut ContentItem, actions: &ConstraintActions) {
215        let new_styles = actions.style_changes.join(", ");
216
217        if actions.force_style || item.style.is_none() {
218            item.style = Some(new_styles);
219        } else if let Some(existing_style) = &item.style {
220            if !new_styles.is_empty() {
221                item.style = Some(format!("{}, {}", existing_style, new_styles));
222            }
223        }
224    }
225
226    /// Get or compile a template for the given expression
227    #[allow(mismatched_lifetime_syntaxes)]
228    fn get_or_compile_template(&mut self, expression: &str) -> Result<&Template, BuildError> {
229        if !self.template_cache.contains_key(expression) {
230            let template = self
231                .template_env
232                .template_from_str(expression)
233                .map_err(|e| {
234                    BuildError::ValidationError(format!(
235                        "Failed to compile constraint template '{}': {}",
236                        expression, e
237                    ))
238                })?;
239
240            // Store template in cache
241            let owned_template = unsafe {
242                std::mem::transmute::<Template<'_, '_>, Template<'static, 'static>>(template)
243            };
244            self.template_cache
245                .insert(expression.to_string(), owned_template);
246        }
247
248        Ok(self.template_cache.get(expression).unwrap())
249    }
250
251    /// Create template context from content item
252    fn create_template_context(&self, item: &ContentItem) -> minijinja::Value {
253        let mut item_data = HashMap::new();
254
255        // Add basic fields
256        item_data.insert("id".to_string(), item.id.clone().into());
257        item_data.insert("title".to_string(), item.title.clone().into());
258        item_data.insert("content".to_string(), item.content.clone().into());
259
260        // Add metadata fields
261        for (key, value) in &item.metadata {
262            item_data.insert(key.clone(), Self::field_value_to_minijinja_value(value));
263        }
264
265        // Add relationships
266        for (rel_type, targets) in &item.relationships {
267            let target_values: Vec<minijinja::Value> =
268                targets.iter().map(|s| s.clone().into()).collect();
269            item_data.insert(format!("rel_{}", rel_type), target_values.into());
270        }
271
272        // Add location info
273        item_data.insert("docname".to_string(), item.location.docname.clone().into());
274        if let Some(lineno) = item.location.lineno {
275            item_data.insert("lineno".to_string(), (lineno as i64).into());
276        }
277
278        item_data.into()
279    }
280
281    /// Convert FieldValue to minijinja Value
282    fn field_value_to_minijinja_value(
283        field_value: &crate::validation::FieldValue,
284    ) -> minijinja::Value {
285        use crate::validation::FieldValue;
286
287        match field_value {
288            FieldValue::String(s) => s.clone().into(),
289            FieldValue::Integer(i) => (*i).into(),
290            FieldValue::Float(f) => (*f).into(),
291            FieldValue::Boolean(b) => (*b).into(),
292            FieldValue::Array(arr) => arr
293                .iter()
294                .map(Self::field_value_to_minijinja_value)
295                .collect::<Vec<_>>()
296                .into(),
297            FieldValue::Object(obj) => obj
298                .iter()
299                .map(|(k, v)| (k.clone(), Self::field_value_to_minijinja_value(v)))
300                .collect::<HashMap<String, minijinja::Value>>()
301                .into(),
302        }
303    }
304
305    /// Generate error message using template
306    fn generate_error_message(
307        &mut self,
308        rule: &ValidationRule,
309        item: &ContentItem,
310    ) -> Result<String, BuildError> {
311        if let Some(error_template) = &rule.error_template {
312            let context = self.create_template_context(item);
313            let template = self.get_or_compile_template(error_template)?;
314
315            template.render(context).map_err(|e| {
316                BuildError::ValidationError(format!(
317                    "Failed to render error message template: {}",
318                    e
319                ))
320            })
321        } else {
322            Ok(format!(
323                "Constraint '{}' failed for item '{}'",
324                rule.name, item.id
325            ))
326        }
327    }
328}
329
330impl Default for ConstraintEngine {
331    fn default() -> Self {
332        Self::new()
333    }
334}
335
336impl Validator for ConstraintEngine {
337    fn validate(&self, _context: &ValidationContext) -> ValidationResult {
338        // This is a simple implementation - in practice, you'd want to
339        // validate all constraints for the current item
340        ValidationResult::success()
341    }
342
343    fn get_validation_rules(&self) -> Vec<ValidationRule> {
344        // Return all rules from the configuration
345        Vec::new() // Placeholder
346    }
347
348    fn get_severity(&self) -> ValidationSeverity {
349        ValidationSeverity::Warning
350    }
351
352    fn supports_incremental(&self) -> bool {
353        true
354    }
355}
356
357impl ConstraintValidator for ConstraintEngine {
358    fn validate_constraint(&self, _rule: &ValidationRule, _item: &ContentItem) -> ValidationResult {
359        // This would need to be implemented with mutable access
360        // For now, return a placeholder
361        ValidationResult::success()
362    }
363
364    fn apply_actions(
365        &self,
366        _failures: &[ValidationFailure],
367        actions: &ConstraintActions,
368    ) -> ActionResult {
369        // Apply the specified actions
370        let mut warnings = Vec::new();
371        let mut errors = Vec::new();
372
373        for action in &actions.on_fail {
374            match action {
375                FailureAction::Warn => {
376                    warnings.push("Constraint validation warning".to_string());
377                }
378                FailureAction::Break => {
379                    errors.push(BuildError::ValidationError(
380                        "Constraint validation failed critically".to_string(),
381                    ));
382                }
383                FailureAction::Style => {
384                    // Style changes are applied separately
385                }
386            }
387        }
388
389        if errors.is_empty() {
390            ActionResult {
391                success: true,
392                warnings,
393                errors,
394            }
395        } else {
396            ActionResult {
397                success: false,
398                warnings,
399                errors,
400            }
401        }
402    }
403}
404
405#[cfg(test)]
406mod tests {
407    use super::*;
408    use crate::validation::ItemLocation;
409
410    fn create_test_item() -> ContentItem {
411        let mut metadata = HashMap::new();
412        metadata.insert(
413            "status".to_string(),
414            crate::validation::FieldValue::String("open".to_string()),
415        );
416        metadata.insert(
417            "priority".to_string(),
418            crate::validation::FieldValue::String("high".to_string()),
419        );
420
421        ContentItem {
422            id: "TEST-001".to_string(),
423            title: "Test Requirement".to_string(),
424            content: "This is a test requirement".to_string(),
425            metadata,
426            constraints: vec!["status_check".to_string()],
427            relationships: HashMap::new(),
428            location: ItemLocation {
429                docname: "requirements.rst".to_string(),
430                lineno: Some(42),
431                source_path: None,
432            },
433            style: None,
434        }
435    }
436
437    #[test]
438    fn test_constraint_engine_creation() {
439        let engine = ConstraintEngine::new();
440        assert!(!engine.template_cache.is_empty() || engine.template_cache.is_empty());
441        // Just test creation
442    }
443
444    #[test]
445    fn test_template_context_creation() {
446        let engine = ConstraintEngine::new();
447        let item = create_test_item();
448
449        let context = engine.create_template_context(&item);
450
451        // Verify context contains expected fields using the correct minijinja API
452        assert!(context.get_attr("id").is_ok());
453        assert!(context.get_attr("title").is_ok());
454        assert!(context.get_attr("status").is_ok());
455        assert!(context.get_attr("priority").is_ok());
456    }
457}