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
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 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
181async 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
222fn 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 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 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}