Skip to main content

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;
9
10use crate::error::BuildError;
11use crate::validation::expression_evaluator::ExpressionEvaluator;
12use crate::validation::{
13    ConstraintActions, ContentItem, FailureAction, ValidationContext, ValidationFailure,
14    ValidationResult, ValidationRule,
15};
16
17/// Core constraint validation engine
18pub struct ConstraintEngine {
19    /// Template environment for processing constraint expressions.
20    /// Compiled error-message templates are stored in the environment itself
21    /// (keyed by their source), which owns them soundly.
22    template_env: Environment<'static>,
23}
24
25impl ConstraintEngine {
26    /// Create a new constraint engine
27    pub fn new() -> Self {
28        let mut env = Environment::new();
29
30        // Register helper functions similar to sphinx-needs filter functions
31        env.add_function("has_tag", |tags: Vec<String>, tag: String| -> bool {
32            tags.contains(&tag)
33        });
34
35        env.add_function("in_list", |value: String, list: Vec<String>| -> bool {
36            list.contains(&value)
37        });
38
39        env.add_function("not_empty", |value: String| -> bool { !value.is_empty() });
40
41        Self { template_env: env }
42    }
43
44    /// Process all constraints for a given content item
45    pub fn process_constraints(
46        &mut self,
47        item: &ContentItem,
48        context: &ValidationContext,
49    ) -> Result<(ContentItem, Vec<ValidationFailure>), BuildError> {
50        let mut modified_item = item.clone();
51        let mut failures = Vec::new();
52
53        for constraint_name in &modified_item.constraints.clone() {
54            if let Some(constraint_def) = context.config.constraints.get(constraint_name) {
55                // Process each check in the constraint
56                for (check_name, expression) in &constraint_def.checks {
57                    let rule = ValidationRule {
58                        name: format!("{}::{}", constraint_name, check_name),
59                        description: constraint_def.description.clone(),
60                        constraint: expression.clone(),
61                        severity: constraint_def.severity,
62                        actions: context
63                            .config
64                            .constraint_failed_options
65                            .get(&constraint_def.severity.to_string())
66                            .cloned()
67                            .unwrap_or_default(),
68                        error_template: constraint_def.error_message.clone(),
69                    };
70
71                    let result = self.validate_constraint(&rule, &modified_item)?;
72
73                    if !result.passed {
74                        let failure =
75                            ValidationFailure::new(rule, result, modified_item.id.clone());
76                        failures.push(failure);
77                    }
78                }
79            }
80        }
81
82        // Apply actions for failures
83        if !failures.is_empty() {
84            self.apply_failure_actions(&mut modified_item, &failures)?;
85        }
86
87        Ok((modified_item, failures))
88    }
89
90    /// Process all constraints for a given content item (mutable version)
91    pub fn process_constraints_mut(
92        &mut self,
93        item: &mut ContentItem,
94        context: &ValidationContext,
95    ) -> Result<Vec<ValidationFailure>, BuildError> {
96        let mut failures = Vec::new();
97
98        for constraint_name in &item.constraints.clone() {
99            if let Some(constraint_def) = context.config.constraints.get(constraint_name) {
100                // Process each check in the constraint
101                for (check_name, expression) in &constraint_def.checks {
102                    let rule = ValidationRule {
103                        name: format!("{}::{}", constraint_name, check_name),
104                        description: constraint_def.description.clone(),
105                        constraint: expression.clone(),
106                        severity: constraint_def.severity,
107                        actions: context
108                            .config
109                            .constraint_failed_options
110                            .get(&constraint_def.severity.to_string())
111                            .cloned()
112                            .unwrap_or_default(),
113                        error_template: constraint_def.error_message.clone(),
114                    };
115
116                    let result = self.validate_constraint(&rule, item)?;
117
118                    if !result.passed {
119                        let failure = ValidationFailure::new(rule, result, item.id.clone());
120                        failures.push(failure);
121                    }
122                }
123            }
124        }
125
126        // Apply actions for failures
127        if !failures.is_empty() {
128            self.apply_failure_actions(item, &failures)?;
129        }
130
131        Ok(failures)
132    }
133
134    /// Validate a single constraint expression against an item
135    pub fn validate_constraint(
136        &mut self,
137        rule: &ValidationRule,
138        item: &ContentItem,
139    ) -> Result<ValidationResult, BuildError> {
140        // Use the expression evaluator for constraint evaluation
141        match ExpressionEvaluator::evaluate(&rule.constraint, item) {
142            Ok(passed) => {
143                if passed {
144                    Ok(ValidationResult::success())
145                } else {
146                    let error_message = self.generate_error_message(rule, item)?;
147                    Ok(ValidationResult::failure(error_message))
148                }
149            }
150            Err(e) => Err(BuildError::ValidationError(format!(
151                "Failed to evaluate constraint '{}': {}",
152                rule.constraint, e
153            ))),
154        }
155    }
156
157    /// Apply actions based on validation failures
158    fn apply_failure_actions(
159        &self,
160        item: &mut ContentItem,
161        failures: &[ValidationFailure],
162    ) -> Result<(), BuildError> {
163        for failure in failures {
164            let actions = &failure.rule.actions;
165
166            // Apply on_fail actions
167            for action in &actions.on_fail {
168                match action {
169                    FailureAction::Warn => {
170                        log::warn!(
171                            "Constraint validation failed for item '{}': {} (rule: {})",
172                            item.id,
173                            failure
174                                .result
175                                .error_message
176                                .as_deref()
177                                .unwrap_or("Unknown error"),
178                            failure.rule.name
179                        );
180                    }
181                    FailureAction::Break => {
182                        return Err(BuildError::ValidationError(format!(
183                            "Critical constraint validation failed for item '{}': {} (rule: {})",
184                            item.id,
185                            failure
186                                .result
187                                .error_message
188                                .as_deref()
189                                .unwrap_or("Unknown error"),
190                            failure.rule.name
191                        )));
192                    }
193                    FailureAction::Style => {
194                        // Style action is handled below
195                    }
196                }
197            }
198
199            // Apply style changes
200            if !actions.style_changes.is_empty() || actions.on_fail.contains(&FailureAction::Style)
201            {
202                self.apply_style_changes(item, actions);
203            }
204        }
205
206        Ok(())
207    }
208
209    /// Apply style changes to a content item
210    fn apply_style_changes(&self, item: &mut ContentItem, actions: &ConstraintActions) {
211        let new_styles = actions.style_changes.join(", ");
212
213        if actions.force_style || item.style.is_none() {
214            item.style = Some(new_styles);
215        } else if let Some(existing_style) = &item.style {
216            if !new_styles.is_empty() {
217                item.style = Some(format!("{}, {}", existing_style, new_styles));
218            }
219        }
220    }
221
222    /// Create template context from content item
223    fn create_template_context(&self, item: &ContentItem) -> minijinja::Value {
224        let mut item_data = HashMap::new();
225
226        // Add basic fields
227        item_data.insert("id".to_string(), item.id.clone().into());
228        item_data.insert("title".to_string(), item.title.clone().into());
229        item_data.insert("content".to_string(), item.content.clone().into());
230
231        // Add metadata fields
232        for (key, value) in &item.metadata {
233            item_data.insert(key.clone(), Self::field_value_to_minijinja_value(value));
234        }
235
236        // Add relationships
237        for (rel_type, targets) in &item.relationships {
238            let target_values: Vec<minijinja::Value> =
239                targets.iter().map(|s| s.clone().into()).collect();
240            item_data.insert(format!("rel_{}", rel_type), target_values.into());
241        }
242
243        // Add location info
244        item_data.insert("docname".to_string(), item.location.docname.clone().into());
245        if let Some(lineno) = item.location.lineno {
246            item_data.insert("lineno".to_string(), (lineno as i64).into());
247        }
248
249        item_data.into()
250    }
251
252    /// Convert FieldValue to minijinja Value
253    fn field_value_to_minijinja_value(
254        field_value: &crate::validation::FieldValue,
255    ) -> minijinja::Value {
256        use crate::validation::FieldValue;
257
258        match field_value {
259            FieldValue::String(s) => s.clone().into(),
260            FieldValue::Integer(i) => (*i).into(),
261            FieldValue::Float(f) => (*f).into(),
262            FieldValue::Boolean(b) => (*b).into(),
263            FieldValue::Array(arr) => arr
264                .iter()
265                .map(Self::field_value_to_minijinja_value)
266                .collect::<Vec<_>>()
267                .into(),
268            FieldValue::Object(obj) => obj
269                .iter()
270                .map(|(k, v)| (k.clone(), Self::field_value_to_minijinja_value(v)))
271                .collect::<HashMap<String, minijinja::Value>>()
272                .into(),
273        }
274    }
275
276    /// Generate error message using template
277    fn generate_error_message(
278        &mut self,
279        rule: &ValidationRule,
280        item: &ContentItem,
281    ) -> Result<String, BuildError> {
282        if let Some(error_template) = &rule.error_template {
283            let context = self.create_template_context(item);
284
285            // The environment owns template storage (loader feature); the
286            // template source doubles as its name, mirroring the old cache key.
287            if self.template_env.get_template(error_template).is_err() {
288                self.template_env
289                    .add_template_owned(error_template.clone(), error_template.clone())
290                    .map_err(|e| {
291                        BuildError::ValidationError(format!(
292                            "Failed to compile constraint template '{}': {}",
293                            error_template, e
294                        ))
295                    })?;
296            }
297            let template = self
298                .template_env
299                .get_template(error_template)
300                .map_err(|e| {
301                    BuildError::ValidationError(format!(
302                        "Failed to compile constraint template '{}': {}",
303                        error_template, e
304                    ))
305                })?;
306
307            template.render(context).map_err(|e| {
308                BuildError::ValidationError(format!(
309                    "Failed to render error message template: {}",
310                    e
311                ))
312            })
313        } else {
314            Ok(format!(
315                "Constraint '{}' failed for item '{}'",
316                rule.name, item.id
317            ))
318        }
319    }
320}
321
322impl Default for ConstraintEngine {
323    fn default() -> Self {
324        Self::new()
325    }
326}
327
328// NOTE: ConstraintEngine deliberately does NOT implement the Validator /
329// ConstraintValidator traits. Earlier placeholder impls always returned
330// success, and auto-ref resolution silently picked the trait method over the
331// real inherent `validate_constraint` — wiring code would then validate
332// nothing. Call the inherent methods directly.
333
334#[cfg(test)]
335mod tests {
336    use super::*;
337    use crate::validation::{ItemLocation, ValidationSeverity};
338
339    fn create_test_item() -> ContentItem {
340        let mut metadata = HashMap::new();
341        metadata.insert(
342            "status".to_string(),
343            crate::validation::FieldValue::String("open".to_string()),
344        );
345        metadata.insert(
346            "priority".to_string(),
347            crate::validation::FieldValue::String("high".to_string()),
348        );
349
350        ContentItem {
351            id: "TEST-001".to_string(),
352            title: "Test Requirement".to_string(),
353            content: "This is a test requirement".to_string(),
354            metadata,
355            constraints: vec!["status_check".to_string()],
356            relationships: HashMap::new(),
357            location: ItemLocation {
358                docname: "requirements.rst".to_string(),
359                lineno: Some(42),
360                source_path: None,
361            },
362            style: None,
363        }
364    }
365
366    #[test]
367    fn test_error_template_renders_item_fields() {
368        let mut engine = ConstraintEngine::new();
369        let item = create_test_item();
370        let rule = ValidationRule {
371            name: "status_check::closed".to_string(),
372            description: Some("status must be closed".to_string()),
373            constraint: "status == \"closed\"".to_string(),
374            severity: ValidationSeverity::Warning,
375            actions: Default::default(),
376            error_template: Some("Item {{ id }} failed: status is {{ status }}".to_string()),
377        };
378
379        // Call the inherent method explicitly: the ConstraintValidator trait
380        // has a same-named placeholder that auto-ref resolution would pick.
381        let result = ConstraintEngine::validate_constraint(&mut engine, &rule, &item).unwrap();
382        assert!(!result.passed, "status is 'open', constraint must fail");
383        let msg = result.error_message.expect("templated message");
384        assert!(
385            msg.contains("TEST-001") && msg.contains("open"),
386            "template must render item fields, got: {msg}"
387        );
388
389        // Render twice: the second pass hits the cached template.
390        let again = ConstraintEngine::validate_constraint(&mut engine, &rule, &item).unwrap();
391        assert_eq!(again.error_message, Some(msg));
392    }
393
394    #[test]
395    fn test_template_context_creation() {
396        let engine = ConstraintEngine::new();
397        let item = create_test_item();
398
399        let context = engine.create_template_context(&item);
400
401        // Verify context contains expected fields using the correct minijinja API
402        assert!(context.get_attr("id").is_ok());
403        assert!(context.get_attr("title").is_ok());
404        assert!(context.get_attr("status").is_ok());
405        assert!(context.get_attr("priority").is_ok());
406    }
407}