Skip to main content

momus_diff/
runner.rs

1use crate::config::DiffConfig;
2use crate::report::{DiffEntry, DiffReport};
3use anyhow::Result;
4use momus_core::ast::{Method, Step, TestPlan};
5use std::collections::HashMap;
6use std::time::Instant;
7
8/// Execute a diff run between two environments.
9///
10/// Runs the plan against both `baseline_url` and `target_url`,
11/// then compares responses field-by-field.
12pub async fn run_diff(plan: &TestPlan, config: &DiffConfig) -> Result<DiffReport> {
13    let start = Instant::now();
14
15    let client = reqwest::Client::builder()
16        .timeout(std::time::Duration::from_secs(config.timeout_secs))
17        .build()?;
18
19    tracing::info!(
20        "Running diff on '{}': baseline={}, target={}",
21        plan.name,
22        config.baseline_url,
23        config.target_url
24    );
25
26    // Collect all request steps
27    let steps = collect_diff_steps(plan);
28    if steps.is_empty() {
29        anyhow::bail!("Test plan has no request steps to diff");
30    }
31
32    let mut diffs = Vec::new();
33    let mut identical = 0u64;
34    let mut different = 0u64;
35    let mut fields_added = 0u64;
36    let mut fields_removed = 0u64;
37    let mut fields_modified = 0u64;
38
39    for step in &steps {
40        let baseline_url = format!("{}{}", config.baseline_url.trim_end_matches('/'), step.url);
41        let target_url = format!("{}{}", config.target_url.trim_end_matches('/'), step.url);
42
43        let baseline_resp = send_request(&client, &step.method, &baseline_url, &step.body).await;
44        let target_resp = send_request(&client, &step.method, &target_url, &step.body).await;
45
46        match (baseline_resp, target_resp) {
47            (Ok(baseline), Ok(target)) => {
48                let mut step_diffs = Vec::new();
49
50                // Compare status codes
51                if config.diff_status && baseline.status != target.status {
52                    step_diffs.push(DiffEntry {
53                        endpoint: step.url.clone(),
54                        method: step.method.to_string(),
55                        change_type: "modified".to_string(),
56                        field: "status".to_string(),
57                        baseline: Some(serde_json::json!(baseline.status)),
58                        target: Some(serde_json::json!(target.status)),
59                    });
60                    fields_modified += 1;
61                }
62
63                // Compare headers
64                if config.diff_headers {
65                    let all_keys: std::collections::HashSet<&str> = baseline
66                        .headers
67                        .keys()
68                        .chain(target.headers.keys())
69                        .map(|k| k.as_str())
70                        .collect();
71
72                    for key in all_keys {
73                        let bv = baseline.headers.get(key);
74                        let tv = target.headers.get(key);
75                        if bv != tv {
76                            step_diffs.push(DiffEntry {
77                                endpoint: step.url.clone(),
78                                method: step.method.to_string(),
79                                change_type: "modified".to_string(),
80                                field: format!("header.{}", key),
81                                baseline: bv.map(|v| serde_json::json!(v)),
82                                target: tv.map(|v| serde_json::json!(v)),
83                            });
84                            fields_modified += 1;
85                        }
86                    }
87                }
88
89                // Compare bodies
90                if config.diff_bodies
91                    && let (Some(b_body), Some(t_body)) = (&baseline.body, &target.body)
92                {
93                    let body_diffs = diff_json_values("$", b_body, t_body);
94                    for d in &body_diffs {
95                        match d.change_type.as_str() {
96                            "added" => fields_added += 1,
97                            "removed" => fields_removed += 1,
98                            _ => fields_modified += 1,
99                        }
100                    }
101                    step_diffs.extend(body_diffs);
102                }
103
104                if step_diffs.is_empty() {
105                    identical += 1;
106                } else {
107                    different += 1;
108                    diffs.extend(step_diffs);
109                }
110            }
111            (Err(e), _) | (_, Err(e)) => {
112                different += 1;
113                diffs.push(DiffEntry {
114                    endpoint: step.url.clone(),
115                    method: step.method.to_string(),
116                    change_type: "error".to_string(),
117                    field: "http".to_string(),
118                    baseline: None,
119                    target: Some(serde_json::json!(format!("HTTP error: {}", e))),
120                });
121            }
122        }
123    }
124
125    let elapsed = start.elapsed().as_secs_f64();
126    let total = steps.len();
127
128    Ok(DiffReport {
129        plan_name: plan.name.clone(),
130        baseline_url: config.baseline_url.clone(),
131        target_url: config.target_url.clone(),
132        total_endpoints: total,
133        identical: identical as usize,
134        different: different as usize,
135        fields_added: fields_added as usize,
136        fields_removed: fields_removed as usize,
137        fields_modified: fields_modified as usize,
138        duration_secs: elapsed,
139        diffs,
140    })
141}
142
143/// A diff step extracted from a test plan.
144struct DiffStep {
145    method: Method,
146    url: String,
147    body: Option<serde_json::Value>,
148}
149
150/// Response from a single request.
151struct DiffResponse {
152    status: u16,
153    headers: HashMap<String, String>,
154    body: Option<serde_json::Value>,
155}
156
157/// Collect all request steps from a test plan.
158fn collect_diff_steps(plan: &TestPlan) -> Vec<DiffStep> {
159    let mut steps = Vec::new();
160    collect_from_steps(&plan.steps, &mut steps);
161    steps
162}
163
164fn collect_from_steps(steps: &[Step], result: &mut Vec<DiffStep>) {
165    for step in steps {
166        match step {
167            Step::Request(req) => {
168                result.push(DiffStep {
169                    method: req.method,
170                    url: req.url.clone(),
171                    body: req.body.clone(),
172                });
173            }
174            Step::Sequence(seq) => collect_from_steps(&seq.steps, result),
175            Step::Parallel(children) => collect_from_steps(children, result),
176            _ => {}
177        }
178    }
179}
180
181/// Send an HTTP request and return the response.
182async fn send_request(
183    client: &reqwest::Client,
184    method: &Method,
185    url: &str,
186    body: &Option<serde_json::Value>,
187) -> Result<DiffResponse> {
188    let resp = match method {
189        Method::Get => client.get(url).send().await?,
190        Method::Post => {
191            let b = body.clone().unwrap_or(serde_json::json!({}));
192            client.post(url).json(&b).send().await?
193        }
194        Method::Put => {
195            let b = body.clone().unwrap_or(serde_json::json!({}));
196            client.put(url).json(&b).send().await?
197        }
198        Method::Delete => client.delete(url).send().await?,
199        Method::Patch => {
200            let b = body.clone().unwrap_or(serde_json::json!({}));
201            client.patch(url).json(&b).send().await?
202        }
203        Method::Head => client.head(url).send().await?,
204        Method::Options => client.request(reqwest::Method::OPTIONS, url).send().await?,
205    };
206
207    let status = resp.status().as_u16();
208    let headers: HashMap<String, String> = resp
209        .headers()
210        .iter()
211        .map(|(k, v)| (k.to_string(), v.to_str().unwrap_or("").to_string()))
212        .collect();
213    let body: Option<serde_json::Value> = resp.json().await.ok();
214
215    Ok(DiffResponse {
216        status,
217        headers,
218        body,
219    })
220}
221
222/// Recursively diff two JSON values, returning a list of field-level differences.
223fn diff_json_values(
224    path: &str,
225    baseline: &serde_json::Value,
226    target: &serde_json::Value,
227) -> Vec<DiffEntry> {
228    let mut diffs = Vec::new();
229
230    match (baseline, target) {
231        (serde_json::Value::Object(b_map), serde_json::Value::Object(t_map)) => {
232            // Check for removed and modified fields
233            for (key, b_val) in b_map {
234                let child_path = format!("{}.{}", path, key);
235                match t_map.get(key) {
236                    None => {
237                        diffs.push(DiffEntry {
238                            endpoint: String::new(),
239                            method: String::new(),
240                            change_type: "removed".to_string(),
241                            field: child_path,
242                            baseline: Some(b_val.clone()),
243                            target: None,
244                        });
245                    }
246                    Some(t_val) if b_val != t_val => {
247                        if (b_val.is_object() && t_val.is_object())
248                            || (b_val.is_array() && t_val.is_array())
249                        {
250                            diffs.extend(diff_json_values(&child_path, b_val, t_val));
251                        } else {
252                            diffs.push(DiffEntry {
253                                endpoint: String::new(),
254                                method: String::new(),
255                                change_type: "modified".to_string(),
256                                field: child_path,
257                                baseline: Some(b_val.clone()),
258                                target: Some(t_val.clone()),
259                            });
260                        }
261                    }
262                    _ => {}
263                }
264            }
265            // Check for added fields
266            for (key, t_val) in t_map {
267                if !b_map.contains_key(key) {
268                    let child_path = format!("{}.{}", path, key);
269                    diffs.push(DiffEntry {
270                        endpoint: String::new(),
271                        method: String::new(),
272                        change_type: "added".to_string(),
273                        field: child_path,
274                        baseline: None,
275                        target: Some(t_val.clone()),
276                    });
277                }
278            }
279        }
280        (serde_json::Value::Array(b_arr), serde_json::Value::Array(t_arr)) => {
281            let max_len = b_arr.len().max(t_arr.len());
282            for i in 0..max_len {
283                let child_path = format!("{}[{}]", path, i);
284                match (b_arr.get(i), t_arr.get(i)) {
285                    (Some(b), Some(t)) if b != t => {
286                        diffs.push(DiffEntry {
287                            endpoint: String::new(),
288                            method: String::new(),
289                            change_type: "modified".to_string(),
290                            field: child_path,
291                            baseline: Some(b.clone()),
292                            target: Some(t.clone()),
293                        });
294                    }
295                    (Some(b), None) => {
296                        diffs.push(DiffEntry {
297                            endpoint: String::new(),
298                            method: String::new(),
299                            change_type: "removed".to_string(),
300                            field: child_path,
301                            baseline: Some(b.clone()),
302                            target: None,
303                        });
304                    }
305                    (None, Some(t)) => {
306                        diffs.push(DiffEntry {
307                            endpoint: String::new(),
308                            method: String::new(),
309                            change_type: "added".to_string(),
310                            field: child_path,
311                            baseline: None,
312                            target: Some(t.clone()),
313                        });
314                    }
315                    _ => {}
316                }
317            }
318        }
319        _ => {
320            if baseline != target {
321                diffs.push(DiffEntry {
322                    endpoint: String::new(),
323                    method: String::new(),
324                    change_type: "modified".to_string(),
325                    field: path.to_string(),
326                    baseline: Some(baseline.clone()),
327                    target: Some(target.clone()),
328                });
329            }
330        }
331    }
332
333    diffs
334}
335
336#[cfg(test)]
337mod tests {
338    use super::*;
339    use momus_core::ast::*;
340    use serde_json::json;
341    use std::collections::HashMap;
342
343    #[test]
344    fn test_diff_json_values_identical() {
345        let a = json!({"name": "John", "age": 30});
346        let b = json!({"name": "John", "age": 30});
347        let diffs = diff_json_values("$", &a, &b);
348        assert!(diffs.is_empty());
349    }
350
351    #[test]
352    fn test_diff_json_values_modified() {
353        let a = json!({"name": "John", "age": 30});
354        let b = json!({"name": "Jane", "age": 30});
355        let diffs = diff_json_values("$", &a, &b);
356        assert_eq!(diffs.len(), 1);
357        assert_eq!(diffs[0].change_type, "modified");
358        assert_eq!(diffs[0].field, "$.name");
359    }
360
361    #[test]
362    fn test_diff_json_values_added() {
363        let a = json!({"name": "John"});
364        let b = json!({"name": "John", "email": "john@test.com"});
365        let diffs = diff_json_values("$", &a, &b);
366        assert_eq!(diffs.len(), 1);
367        assert_eq!(diffs[0].change_type, "added");
368        assert_eq!(diffs[0].field, "$.email");
369    }
370
371    #[test]
372    fn test_diff_json_values_removed() {
373        let a = json!({"name": "John", "age": 30});
374        let b = json!({"name": "John"});
375        let diffs = diff_json_values("$", &a, &b);
376        assert_eq!(diffs.len(), 1);
377        assert_eq!(diffs[0].change_type, "removed");
378        assert_eq!(diffs[0].field, "$.age");
379    }
380
381    #[test]
382    fn test_diff_json_values_nested() {
383        let a = json!({"user": {"name": "John", "address": {"city": "NYC"}}});
384        let b = json!({"user": {"name": "John", "address": {"city": "LA"}}});
385        let diffs = diff_json_values("$", &a, &b);
386        assert_eq!(diffs.len(), 1);
387        assert_eq!(diffs[0].field, "$.user.address.city");
388    }
389
390    #[test]
391    fn test_diff_json_values_array() {
392        let a = json!({"items": [1, 2, 3]});
393        let b = json!({"items": [1, 4, 3]});
394        let diffs = diff_json_values("$", &a, &b);
395        assert_eq!(diffs.len(), 1);
396        assert_eq!(diffs[0].field, "$.items[1]");
397    }
398
399    #[test]
400    fn test_collect_diff_steps() {
401        let plan = TestPlan {
402            name: "test".into(),
403            base_url: "http://localhost".into(),
404            default_headers: HashMap::new(),
405            steps: vec![Step::Request(RequestStep {
406                name: "r1".into(),
407                method: Method::Get,
408                url: "/health".into(),
409                headers: HashMap::new(),
410                body: None,
411                assert: vec![],
412                save_as: String::new(),
413                soft_fail: false,
414            })],
415            setup: vec![],
416            teardown: vec![],
417        };
418
419        let steps = collect_diff_steps(&plan);
420        assert_eq!(steps.len(), 1);
421    }
422}