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, 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    plan.request_steps()
160        .into_iter()
161        .map(|req| DiffStep {
162            method: req.method,
163            url: req.url.clone(),
164            body: req.body.clone(),
165        })
166        .collect()
167}
168
169/// Send an HTTP request and return the response.
170async fn send_request(
171    client: &reqwest::Client,
172    method: &Method,
173    url: &str,
174    body: &Option<serde_json::Value>,
175) -> Result<DiffResponse> {
176    let resp = match method {
177        Method::Get => client.get(url).send().await?,
178        Method::Post => {
179            let b = body.clone().unwrap_or(serde_json::json!({}));
180            client.post(url).json(&b).send().await?
181        }
182        Method::Put => {
183            let b = body.clone().unwrap_or(serde_json::json!({}));
184            client.put(url).json(&b).send().await?
185        }
186        Method::Delete => client.delete(url).send().await?,
187        Method::Patch => {
188            let b = body.clone().unwrap_or(serde_json::json!({}));
189            client.patch(url).json(&b).send().await?
190        }
191        Method::Head => client.head(url).send().await?,
192        Method::Options => client.request(reqwest::Method::OPTIONS, url).send().await?,
193    };
194
195    let status = resp.status().as_u16();
196    let headers: HashMap<String, String> = resp
197        .headers()
198        .iter()
199        .map(|(k, v)| (k.to_string(), v.to_str().unwrap_or("").to_string()))
200        .collect();
201    let body: Option<serde_json::Value> = resp.json().await.ok();
202
203    Ok(DiffResponse {
204        status,
205        headers,
206        body,
207    })
208}
209
210/// Recursively diff two JSON values, returning a list of field-level differences.
211fn diff_json_values(
212    path: &str,
213    baseline: &serde_json::Value,
214    target: &serde_json::Value,
215) -> Vec<DiffEntry> {
216    let mut diffs = Vec::new();
217
218    match (baseline, target) {
219        (serde_json::Value::Object(b_map), serde_json::Value::Object(t_map)) => {
220            // Check for removed and modified fields
221            for (key, b_val) in b_map {
222                let child_path = format!("{path}.{key}");
223                match t_map.get(key) {
224                    None => {
225                        diffs.push(DiffEntry {
226                            endpoint: String::new(),
227                            method: String::new(),
228                            change_type: "removed".to_string(),
229                            field: child_path,
230                            baseline: Some(b_val.clone()),
231                            target: None,
232                        });
233                    }
234                    Some(t_val) if b_val != t_val => {
235                        if (b_val.is_object() && t_val.is_object())
236                            || (b_val.is_array() && t_val.is_array())
237                        {
238                            diffs.extend(diff_json_values(&child_path, b_val, t_val));
239                        } else {
240                            diffs.push(DiffEntry {
241                                endpoint: String::new(),
242                                method: String::new(),
243                                change_type: "modified".to_string(),
244                                field: child_path,
245                                baseline: Some(b_val.clone()),
246                                target: Some(t_val.clone()),
247                            });
248                        }
249                    }
250                    _ => {}
251                }
252            }
253            // Check for added fields
254            for (key, t_val) in t_map {
255                if !b_map.contains_key(key) {
256                    let child_path = format!("{path}.{key}");
257                    diffs.push(DiffEntry {
258                        endpoint: String::new(),
259                        method: String::new(),
260                        change_type: "added".to_string(),
261                        field: child_path,
262                        baseline: None,
263                        target: Some(t_val.clone()),
264                    });
265                }
266            }
267        }
268        (serde_json::Value::Array(b_arr), serde_json::Value::Array(t_arr)) => {
269            let max_len = b_arr.len().max(t_arr.len());
270            for i in 0..max_len {
271                let child_path = format!("{path}[{i}]");
272                match (b_arr.get(i), t_arr.get(i)) {
273                    (Some(b), Some(t)) if b != t => {
274                        diffs.push(DiffEntry {
275                            endpoint: String::new(),
276                            method: String::new(),
277                            change_type: "modified".to_string(),
278                            field: child_path,
279                            baseline: Some(b.clone()),
280                            target: Some(t.clone()),
281                        });
282                    }
283                    (Some(b), None) => {
284                        diffs.push(DiffEntry {
285                            endpoint: String::new(),
286                            method: String::new(),
287                            change_type: "removed".to_string(),
288                            field: child_path,
289                            baseline: Some(b.clone()),
290                            target: None,
291                        });
292                    }
293                    (None, Some(t)) => {
294                        diffs.push(DiffEntry {
295                            endpoint: String::new(),
296                            method: String::new(),
297                            change_type: "added".to_string(),
298                            field: child_path,
299                            baseline: None,
300                            target: Some(t.clone()),
301                        });
302                    }
303                    _ => {}
304                }
305            }
306        }
307        _ => {
308            if baseline != target {
309                diffs.push(DiffEntry {
310                    endpoint: String::new(),
311                    method: String::new(),
312                    change_type: "modified".to_string(),
313                    field: path.to_string(),
314                    baseline: Some(baseline.clone()),
315                    target: Some(target.clone()),
316                });
317            }
318        }
319    }
320
321    diffs
322}
323
324#[cfg(test)]
325mod tests {
326    use super::*;
327    use momus_core::ast::*;
328    use serde_json::json;
329    use std::collections::HashMap;
330
331    #[test]
332    fn test_diff_json_values_identical() {
333        let a = json!({"name": "John", "age": 30});
334        let b = json!({"name": "John", "age": 30});
335        let diffs = diff_json_values("$", &a, &b);
336        assert!(diffs.is_empty());
337    }
338
339    #[test]
340    fn test_diff_json_values_modified() {
341        let a = json!({"name": "John", "age": 30});
342        let b = json!({"name": "Jane", "age": 30});
343        let diffs = diff_json_values("$", &a, &b);
344        assert_eq!(diffs.len(), 1);
345        assert_eq!(diffs[0].change_type, "modified");
346        assert_eq!(diffs[0].field, "$.name");
347    }
348
349    #[test]
350    fn test_diff_json_values_added() {
351        let a = json!({"name": "John"});
352        let b = json!({"name": "John", "email": "john@test.com"});
353        let diffs = diff_json_values("$", &a, &b);
354        assert_eq!(diffs.len(), 1);
355        assert_eq!(diffs[0].change_type, "added");
356        assert_eq!(diffs[0].field, "$.email");
357    }
358
359    #[test]
360    fn test_diff_json_values_removed() {
361        let a = json!({"name": "John", "age": 30});
362        let b = json!({"name": "John"});
363        let diffs = diff_json_values("$", &a, &b);
364        assert_eq!(diffs.len(), 1);
365        assert_eq!(diffs[0].change_type, "removed");
366        assert_eq!(diffs[0].field, "$.age");
367    }
368
369    #[test]
370    fn test_diff_json_values_nested() {
371        let a = json!({"user": {"name": "John", "address": {"city": "NYC"}}});
372        let b = json!({"user": {"name": "John", "address": {"city": "LA"}}});
373        let diffs = diff_json_values("$", &a, &b);
374        assert_eq!(diffs.len(), 1);
375        assert_eq!(diffs[0].field, "$.user.address.city");
376    }
377
378    #[test]
379    fn test_diff_json_values_array() {
380        let a = json!({"items": [1, 2, 3]});
381        let b = json!({"items": [1, 4, 3]});
382        let diffs = diff_json_values("$", &a, &b);
383        assert_eq!(diffs.len(), 1);
384        assert_eq!(diffs[0].field, "$.items[1]");
385    }
386
387    #[test]
388    fn test_collect_diff_steps() {
389        let plan = TestPlan {
390            name: "test".into(),
391            base_url: "http://localhost".into(),
392            default_headers: HashMap::new(),
393            steps: vec![Step::Request(RequestStep {
394                name: "r1".into(),
395                method: Method::Get,
396                url: "/health".into(),
397                headers: HashMap::new(),
398                body: None,
399                assert: vec![],
400                save_as: String::new(),
401                soft_fail: false,
402            })],
403            setup: vec![],
404            teardown: vec![],
405        };
406
407        let steps = collect_diff_steps(&plan);
408        assert_eq!(steps.len(), 1);
409    }
410}