mockforge_core/
conditions.rs

1//! Condition evaluation system for override rules
2//!
3//! This module provides support for conditional application of overrides based on
4//! JSONPath and XPath queries, as well as other conditional expressions.
5
6use jsonpath::Selector;
7use roxmltree::{Document, Node};
8use serde_json::Value;
9use std::collections::HashMap;
10use thiserror::Error;
11
12/// Errors that can occur during condition evaluation
13#[derive(Debug, Error)]
14pub enum ConditionError {
15    #[error("Invalid JSONPath expression: {0}")]
16    InvalidJsonPath(String),
17
18    #[error("Invalid XPath expression: {0}")]
19    InvalidXPath(String),
20
21    #[error("Invalid XML: {0}")]
22    InvalidXml(String),
23
24    #[error("Unsupported condition type: {0}")]
25    UnsupportedCondition(String),
26
27    #[error("Condition evaluation failed: {0}")]
28    EvaluationFailed(String),
29}
30
31/// Context for evaluating conditions
32#[derive(Debug, Clone)]
33pub struct ConditionContext {
34    /// Request body (JSON)
35    pub request_body: Option<Value>,
36    /// Response body (JSON)
37    pub response_body: Option<Value>,
38    /// Request body as XML string
39    pub request_xml: Option<String>,
40    /// Response body as XML string
41    pub response_xml: Option<String>,
42    /// Request headers
43    pub headers: HashMap<String, String>,
44    /// Query parameters
45    pub query_params: HashMap<String, String>,
46    /// Request path
47    pub path: String,
48    /// HTTP method
49    pub method: String,
50    /// Operation ID
51    pub operation_id: Option<String>,
52    /// Tags
53    pub tags: Vec<String>,
54}
55
56impl Default for ConditionContext {
57    fn default() -> Self {
58        Self::new()
59    }
60}
61
62impl ConditionContext {
63    pub fn new() -> Self {
64        Self {
65            request_body: None,
66            response_body: None,
67            request_xml: None,
68            response_xml: None,
69            headers: HashMap::new(),
70            query_params: HashMap::new(),
71            path: String::new(),
72            method: String::new(),
73            operation_id: None,
74            tags: Vec::new(),
75        }
76    }
77
78    pub fn with_request_body(mut self, body: Value) -> Self {
79        self.request_body = Some(body);
80        self
81    }
82
83    pub fn with_response_body(mut self, body: Value) -> Self {
84        self.response_body = Some(body);
85        self
86    }
87
88    pub fn with_request_xml(mut self, xml: String) -> Self {
89        self.request_xml = Some(xml);
90        self
91    }
92
93    pub fn with_response_xml(mut self, xml: String) -> Self {
94        self.response_xml = Some(xml);
95        self
96    }
97
98    pub fn with_headers(mut self, headers: HashMap<String, String>) -> Self {
99        self.headers = headers;
100        self
101    }
102
103    pub fn with_query_params(mut self, params: HashMap<String, String>) -> Self {
104        self.query_params = params;
105        self
106    }
107
108    pub fn with_path(mut self, path: String) -> Self {
109        self.path = path;
110        self
111    }
112
113    pub fn with_method(mut self, method: String) -> Self {
114        self.method = method;
115        self
116    }
117
118    pub fn with_operation_id(mut self, operation_id: String) -> Self {
119        self.operation_id = Some(operation_id);
120        self
121    }
122
123    pub fn with_tags(mut self, tags: Vec<String>) -> Self {
124        self.tags = tags;
125        self
126    }
127}
128
129/// Evaluate a condition expression
130pub fn evaluate_condition(
131    condition: &str,
132    context: &ConditionContext,
133) -> Result<bool, ConditionError> {
134    let condition = condition.trim();
135
136    if condition.is_empty() {
137        return Ok(true); // Empty condition always evaluates to true
138    }
139
140    // Handle logical operators
141    if let Some(and_conditions) = condition.strip_prefix("AND(") {
142        if let Some(inner) = and_conditions.strip_suffix(")") {
143            return evaluate_and_condition(inner, context);
144        }
145    }
146
147    if let Some(or_conditions) = condition.strip_prefix("OR(") {
148        if let Some(inner) = or_conditions.strip_suffix(")") {
149            return evaluate_or_condition(inner, context);
150        }
151    }
152
153    if let Some(not_condition) = condition.strip_prefix("NOT(") {
154        if let Some(inner) = not_condition.strip_suffix(")") {
155            return evaluate_not_condition(inner, context);
156        }
157    }
158
159    // Handle JSONPath queries
160    if condition.starts_with("$.") || condition.starts_with("$[") {
161        return evaluate_jsonpath(condition, context);
162    }
163
164    // Handle XPath queries
165    if condition.starts_with("/") || condition.starts_with("//") {
166        return evaluate_xpath(condition, context);
167    }
168
169    // Handle simple comparisons
170    evaluate_simple_condition(condition, context)
171}
172
173/// Evaluate AND condition with multiple sub-conditions
174fn evaluate_and_condition(
175    conditions: &str,
176    context: &ConditionContext,
177) -> Result<bool, ConditionError> {
178    let parts: Vec<&str> = conditions.split(',').map(|s| s.trim()).collect();
179
180    for part in parts {
181        if !evaluate_condition(part, context)? {
182            return Ok(false);
183        }
184    }
185
186    Ok(true)
187}
188
189/// Evaluate OR condition with multiple sub-conditions
190fn evaluate_or_condition(
191    conditions: &str,
192    context: &ConditionContext,
193) -> Result<bool, ConditionError> {
194    let parts: Vec<&str> = conditions.split(',').map(|s| s.trim()).collect();
195
196    for part in parts {
197        if evaluate_condition(part, context)? {
198            return Ok(true);
199        }
200    }
201
202    Ok(false)
203}
204
205/// Evaluate NOT condition
206fn evaluate_not_condition(
207    condition: &str,
208    context: &ConditionContext,
209) -> Result<bool, ConditionError> {
210    Ok(!evaluate_condition(condition, context)?)
211}
212
213/// Evaluate JSONPath query
214fn evaluate_jsonpath(query: &str, context: &ConditionContext) -> Result<bool, ConditionError> {
215    // Determine if this is a request or response query
216    let (_is_request, json_value) = if query.starts_with("$.request.") {
217        let _query = query.replace("$.request.", "$.");
218        (true, &context.request_body)
219    } else if query.starts_with("$.response.") {
220        let _query = query.replace("$.response.", "$.");
221        (false, &context.response_body)
222    } else {
223        // Default to response body if not specified
224        (false, &context.response_body)
225    };
226
227    let Some(json_value) = json_value else {
228        return Ok(false); // No body to query
229    };
230
231    match Selector::new(query) {
232        Ok(selector) => {
233            let results: Vec<_> = selector.find(json_value).collect();
234            Ok(!results.is_empty())
235        }
236        Err(_) => Err(ConditionError::InvalidJsonPath(query.to_string())),
237    }
238}
239
240/// Evaluate XPath query
241fn evaluate_xpath(query: &str, context: &ConditionContext) -> Result<bool, ConditionError> {
242    // Determine if this is a request or response query
243    let (_is_request, xml_content) = if query.starts_with("/request/") {
244        let _query = query.replace("/request/", "/");
245        (true, &context.request_xml)
246    } else if query.starts_with("/response/") {
247        let _query = query.replace("/response/", "/");
248        (false, &context.response_xml)
249    } else {
250        // Default to response XML if not specified
251        (false, &context.response_xml)
252    };
253
254    let Some(xml_content) = xml_content else {
255        println!("Debug - No XML content available for query: {}", query);
256        return Ok(false); // No XML content to query
257    };
258
259    println!("Debug - Evaluating XPath '{}' against XML content: {}", query, xml_content);
260
261    match Document::parse(xml_content) {
262        Ok(doc) => {
263            // Simple XPath evaluation - check if any nodes match
264            let root = doc.root_element();
265            println!("Debug - XML root element: {}", root.tag_name().name());
266            let matches = evaluate_xpath_simple(&root, query);
267            println!("Debug - XPath result: {}", matches);
268            Ok(matches)
269        }
270        Err(e) => {
271            println!("Debug - Failed to parse XML: {}", e);
272            Err(ConditionError::InvalidXml(xml_content.clone()))
273        }
274    }
275}
276
277/// Simple XPath evaluator (basic implementation)
278fn evaluate_xpath_simple(node: &Node, xpath: &str) -> bool {
279    // This is a simplified XPath implementation
280    // For production use, consider a more complete XPath library
281
282    // Handle descendant-or-self axis: //element (check this FIRST before stripping //)
283    if let Some(element_name) = xpath.strip_prefix("//") {
284        println!(
285            "Debug - Checking descendant-or-self for element '{}' on node '{}'",
286            element_name,
287            node.tag_name().name()
288        );
289        if node.tag_name().name() == element_name {
290            println!("Debug - Found match: {} == {}", node.tag_name().name(), element_name);
291            return true;
292        }
293        // Check descendants
294        for child in node.children() {
295            if child.is_element() {
296                println!("Debug - Checking child element: {}", child.tag_name().name());
297                if evaluate_xpath_simple(&child, &format!("//{}", element_name)) {
298                    return true;
299                }
300            }
301        }
302        return false; // If no descendant found, return false
303    }
304
305    let xpath = xpath.trim_start_matches('/');
306
307    if xpath.is_empty() {
308        return true;
309    }
310
311    // Handle attribute queries: element[@attribute='value']
312    if let Some((element_part, attr_part)) = xpath.split_once('[') {
313        if let Some(attr_query) = attr_part.strip_suffix(']') {
314            if let Some((attr_name, attr_value)) = attr_query.split_once("='") {
315                if let Some(expected_value) = attr_value.strip_suffix('\'') {
316                    if let Some(attr_val) = attr_name.strip_prefix('@') {
317                        if node.tag_name().name() == element_part {
318                            if let Some(attr) = node.attribute(attr_val) {
319                                return attr == expected_value;
320                            }
321                        }
322                    }
323                }
324            }
325        }
326        return false;
327    }
328
329    // Handle element name matching with optional predicates
330    if let Some((element_name, rest)) = xpath.split_once('/') {
331        if node.tag_name().name() == element_name {
332            if rest.is_empty() {
333                return true;
334            }
335            // Check child elements recursively
336            for child in node.children() {
337                if child.is_element() && evaluate_xpath_simple(&child, rest) {
338                    return true;
339                }
340            }
341        }
342    } else if node.tag_name().name() == xpath {
343        return true;
344    }
345
346    // Handle text content queries: element/text()
347    if let Some(text_query) = xpath.strip_suffix("/text()") {
348        if node.tag_name().name() == text_query {
349            return node.text().is_some_and(|t| !t.trim().is_empty());
350        }
351    }
352
353    false
354}
355
356/// Evaluate simple conditions like header checks, query param checks, etc.
357fn evaluate_simple_condition(
358    condition: &str,
359    context: &ConditionContext,
360) -> Result<bool, ConditionError> {
361    // Handle header conditions: header[name]=value
362    if let Some(header_condition) = condition.strip_prefix("header[") {
363        if let Some((header_name, expected_value)) = header_condition.split_once("]=") {
364            let expected_value = expected_value.trim();
365            if let Some(actual_value) = context.headers.get(header_name) {
366                return Ok(actual_value == expected_value);
367            }
368            return Ok(false);
369        }
370    }
371
372    // Handle query parameter conditions: query[name]=value
373    if let Some(query_condition) = condition.strip_prefix("query[") {
374        if let Some((param_name, expected_value)) = query_condition.split_once("]=") {
375            let expected_value = expected_value.trim();
376            if let Some(actual_value) = context.query_params.get(param_name) {
377                return Ok(actual_value == expected_value);
378            }
379            return Ok(false);
380        }
381    }
382
383    // Handle method conditions: method=POST
384    if let Some(method_condition) = condition.strip_prefix("method=") {
385        return Ok(context.method == method_condition);
386    }
387
388    // Handle path conditions: path=/api/users
389    if let Some(path_condition) = condition.strip_prefix("path=") {
390        return Ok(context.path == path_condition);
391    }
392
393    // Handle tag conditions: has_tag[admin]
394    if let Some(tag_condition) = condition.strip_prefix("has_tag[") {
395        if let Some(tag) = tag_condition.strip_suffix("]") {
396            return Ok(context.tags.contains(&tag.to_string()));
397        }
398    }
399
400    // Handle operation conditions: operation=getUser
401    if let Some(op_condition) = condition.strip_prefix("operation=") {
402        if let Some(operation_id) = &context.operation_id {
403            return Ok(operation_id == op_condition);
404        }
405        return Ok(false);
406    }
407
408    Err(ConditionError::UnsupportedCondition(condition.to_string()))
409}
410
411#[cfg(test)]
412mod tests {
413    use super::*;
414    use serde_json::json;
415
416    #[test]
417    fn test_jsonpath_condition() {
418        let context = ConditionContext::new().with_response_body(json!({
419            "user": {
420                "name": "John",
421                "role": "admin"
422            },
423            "items": [1, 2, 3]
424        }));
425
426        // Test simple path existence
427        assert!(evaluate_condition("$.user", &context).unwrap());
428
429        // Test specific value matching
430        assert!(evaluate_condition("$.user.role", &context).unwrap());
431
432        // Test array access
433        assert!(evaluate_condition("$.items[0]", &context).unwrap());
434
435        // Test non-existent path
436        assert!(!evaluate_condition("$.nonexistent", &context).unwrap());
437    }
438
439    #[test]
440    fn test_simple_conditions() {
441        let mut headers = HashMap::new();
442        headers.insert("authorization".to_string(), "Bearer token123".to_string());
443
444        let mut query_params = HashMap::new();
445        query_params.insert("limit".to_string(), "10".to_string());
446
447        let context = ConditionContext::new()
448            .with_headers(headers)
449            .with_query_params(query_params)
450            .with_method("POST".to_string())
451            .with_path("/api/users".to_string());
452
453        // Test header condition
454        assert!(evaluate_condition("header[authorization]=Bearer token123", &context).unwrap());
455        assert!(!evaluate_condition("header[authorization]=Bearer wrong", &context).unwrap());
456
457        // Test query parameter condition
458        assert!(evaluate_condition("query[limit]=10", &context).unwrap());
459        assert!(!evaluate_condition("query[limit]=20", &context).unwrap());
460
461        // Test method condition
462        assert!(evaluate_condition("method=POST", &context).unwrap());
463        assert!(!evaluate_condition("method=GET", &context).unwrap());
464
465        // Test path condition
466        assert!(evaluate_condition("path=/api/users", &context).unwrap());
467        assert!(!evaluate_condition("path=/api/posts", &context).unwrap());
468    }
469
470    #[test]
471    fn test_logical_conditions() {
472        let context = ConditionContext::new()
473            .with_method("POST".to_string())
474            .with_path("/api/users".to_string());
475
476        // Test AND condition
477        assert!(evaluate_condition("AND(method=POST,path=/api/users)", &context).unwrap());
478        assert!(!evaluate_condition("AND(method=GET,path=/api/users)", &context).unwrap());
479
480        // Test OR condition
481        assert!(evaluate_condition("OR(method=POST,path=/api/posts)", &context).unwrap());
482        assert!(!evaluate_condition("OR(method=GET,path=/api/posts)", &context).unwrap());
483
484        // Test NOT condition
485        assert!(!evaluate_condition("NOT(method=POST)", &context).unwrap());
486        assert!(evaluate_condition("NOT(method=GET)", &context).unwrap());
487    }
488
489    #[test]
490    fn test_xpath_condition() {
491        let xml_content = r#"
492            <user id="123">
493                <name>John Doe</name>
494                <role>admin</role>
495                <preferences>
496                    <theme>dark</theme>
497                    <notifications>true</notifications>
498                </preferences>
499            </user>
500        "#;
501
502        let context = ConditionContext::new().with_response_xml(xml_content.to_string());
503
504        // Test basic element existence
505        assert!(evaluate_condition("/user", &context).unwrap());
506
507        // Test nested element
508        assert!(evaluate_condition("/user/name", &context).unwrap());
509
510        // Test attribute query
511        assert!(evaluate_condition("/user[@id='123']", &context).unwrap());
512        assert!(!evaluate_condition("/user[@id='456']", &context).unwrap());
513
514        // Test text content
515        assert!(evaluate_condition("/user/name/text()", &context).unwrap());
516
517        // Test descendant axis
518        assert!(evaluate_condition("//theme", &context).unwrap());
519
520        // Test non-existent element
521        assert!(!evaluate_condition("/nonexistent", &context).unwrap());
522    }
523}