1use 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
17pub struct ConstraintEngine {
19 template_env: Environment<'static>,
23}
24
25impl ConstraintEngine {
26 pub fn new() -> Self {
28 let mut env = Environment::new();
29
30 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 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 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 if !failures.is_empty() {
84 self.apply_failure_actions(&mut modified_item, &failures)?;
85 }
86
87 Ok((modified_item, failures))
88 }
89
90 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 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 if !failures.is_empty() {
128 self.apply_failure_actions(item, &failures)?;
129 }
130
131 Ok(failures)
132 }
133
134 pub fn validate_constraint(
136 &mut self,
137 rule: &ValidationRule,
138 item: &ContentItem,
139 ) -> Result<ValidationResult, BuildError> {
140 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 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 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 }
196 }
197 }
198
199 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 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 fn create_template_context(&self, item: &ContentItem) -> minijinja::Value {
224 let mut item_data = HashMap::new();
225
226 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 for (key, value) in &item.metadata {
233 item_data.insert(key.clone(), Self::field_value_to_minijinja_value(value));
234 }
235
236 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 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 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 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 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#[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 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 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 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}