xreq_lib/
diff.rs

1use crate::req::RequestContext;
2use anyhow::Result;
3use console::{style, Style};
4use reqwest::Response;
5use serde::{Deserialize, Serialize};
6use serde_json::Value;
7use similar::{ChangeTag, TextDiff};
8use std::{collections::HashMap, fmt, io::Write, path::Path};
9use tokio::fs;
10
11#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
12pub struct DiffConfig {
13    #[serde(flatten)]
14    ctxs: HashMap<String, DiffContext>,
15}
16
17#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
18pub struct DiffContext {
19    pub request1: RequestContext,
20    pub request2: RequestContext,
21    #[serde(skip_serializing_if = "is_default_response", default)]
22    pub response: ResponseContext,
23}
24
25fn is_default_response(r: &ResponseContext) -> bool {
26    r == &ResponseContext::default()
27}
28
29#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq)]
30pub struct ResponseContext {
31    #[serde(skip_serializing_if = "Vec::is_empty")]
32    pub skip_headers: Vec<String>,
33}
34
35#[derive(Debug, PartialEq, Eq)]
36pub enum DiffResult {
37    Equal,
38    Diff(String),
39}
40
41impl ResponseContext {
42    pub fn new(skip_headers: Vec<String>) -> Self {
43        Self { skip_headers }
44    }
45}
46
47struct Line(Option<usize>);
48
49impl fmt::Display for Line {
50    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
51        match self.0 {
52            None => write!(f, "    "),
53            Some(idx) => write!(f, "{:<4}", idx + 1),
54        }
55    }
56}
57
58impl DiffConfig {
59    pub fn new_with_profile(
60        profile: String,
61        req1: RequestContext,
62        req2: RequestContext,
63        res: ResponseContext,
64    ) -> Self {
65        let ctx = DiffContext::new(req1, req2, res);
66        let mut ctxs = HashMap::new();
67        ctxs.insert(profile, ctx);
68        Self { ctxs }
69    }
70
71    pub async fn try_load(path: impl AsRef<Path>) -> Result<DiffConfig> {
72        let file = fs::read_to_string(path).await?;
73        let config: DiffConfig = serde_yaml::from_str(&file)?;
74        for (profile, ctx) in config.ctxs.iter() {
75            if !ctx.request1.params.is_object() || !ctx.request2.params.is_object() {
76                return Err(anyhow::anyhow!(
77                    "params in request1 or request2 must be an object in profile: {}",
78                    profile
79                ));
80            }
81        }
82        Ok(config)
83    }
84
85    pub fn get(&self, profile: &str) -> Result<&DiffContext> {
86        self.ctxs.get(profile).ok_or_else(|| {
87            anyhow::anyhow!(
88                "profile {} not found. Available profiles: {:?}.",
89                profile,
90                self.ctxs.keys()
91            )
92        })
93    }
94
95    pub async fn diff(&self, profile: &str) -> Result<DiffResult> {
96        let ctx = self.get(profile)?;
97
98        ctx.diff().await
99    }
100}
101
102impl DiffContext {
103    pub fn new(req1: RequestContext, req2: RequestContext, resp: ResponseContext) -> Self {
104        Self {
105            request1: req1,
106            request2: req2,
107            response: resp,
108        }
109    }
110
111    pub async fn diff(&self) -> Result<DiffResult> {
112        let res1 = self.request1.send().await?;
113        let res2 = self.request2.send().await?;
114
115        self.diff_response(res1, res2).await
116    }
117
118    async fn diff_response(&self, res1: Response, res2: Response) -> Result<DiffResult> {
119        let url1 = res1.url().to_string();
120        let url2 = res2.url().to_string();
121
122        let text1 = self.request_to_string(res1).await?;
123        let text2 = self.request_to_string(res2).await?;
124
125        if text1 != text2 {
126            let headers = format!("--- a/{}\n+++ b/{}\n", url1, url2);
127            return Ok(DiffResult::Diff(build_diff(headers, text1, text2)?));
128        }
129
130        Ok(DiffResult::Equal)
131    }
132
133    async fn request_to_string(&self, res: Response) -> Result<String> {
134        let mut buf = Vec::new();
135
136        writeln!(&mut buf, "{}", res.status()).unwrap();
137        res.headers().iter().for_each(|(k, v)| {
138            if self.response.skip_headers.iter().any(|v| v == k.as_str()) {
139                return;
140            }
141            writeln!(&mut buf, "{}: {:?}", k, v).unwrap();
142        });
143        writeln!(&mut buf).unwrap();
144
145        let mut body = res.text().await?;
146
147        if let Ok(json) = serde_json::from_str::<Value>(&body) {
148            body = serde_json::to_string_pretty(&json)?;
149        }
150
151        writeln!(&mut buf, "{}", body).unwrap();
152
153        Ok(String::from_utf8(buf)?)
154    }
155}
156
157fn build_diff(headers: String, old: String, new: String) -> Result<String> {
158    let diff = TextDiff::from_lines(&old, &new);
159    let mut buf = Vec::with_capacity(4096);
160    writeln!(&mut buf, "{}", headers).unwrap();
161    for (idx, group) in diff.grouped_ops(3).iter().enumerate() {
162        if idx > 0 {
163            writeln!(&mut buf, "{:-^1$}", "-", 80)?;
164        }
165        for op in group {
166            for change in diff.iter_inline_changes(op) {
167                let (sign, s) = match change.tag() {
168                    ChangeTag::Delete => ("-", Style::new().red()),
169                    ChangeTag::Insert => ("+", Style::new().green()),
170                    ChangeTag::Equal => (" ", Style::new().dim()),
171                };
172                write!(
173                    &mut buf,
174                    "{}{} |{}",
175                    style(Line(change.old_index())).dim(),
176                    style(Line(change.new_index())).dim(),
177                    s.apply_to(sign).bold(),
178                )?;
179                for (emphasized, value) in change.iter_strings_lossy() {
180                    if emphasized {
181                        write!(&mut buf, "{}", s.apply_to(value).underlined().on_black())?;
182                    } else {
183                        write!(&mut buf, "{}", s.apply_to(value))?;
184                    }
185                }
186                if change.missing_newline() {
187                    writeln!(&mut buf)?;
188                }
189            }
190        }
191    }
192    Ok(String::from_utf8(buf)?)
193}
194
195#[cfg(test)]
196mod tests {
197    use super::*;
198
199    #[tokio::test]
200    async fn diff_request_should_work() {
201        let config = DiffConfig::try_load("fixtures/diff.yml").await.unwrap();
202        let result = config.diff("rust").await.unwrap();
203        assert_eq!(result, DiffResult::Equal);
204    }
205}