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
8pub 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 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 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 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 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
143struct DiffStep {
145 method: Method,
146 url: String,
147 body: Option<serde_json::Value>,
148}
149
150struct DiffResponse {
152 status: u16,
153 headers: HashMap<String, String>,
154 body: Option<serde_json::Value>,
155}
156
157fn 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
169async 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
210fn 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 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 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}