pact_broker_cli/cli/
utils.rs

1use console::Style;
2
3pub fn glob_value(v: String) -> Result<String, String> {
4    match glob::Pattern::new(&v) {
5        Ok(res) => Ok(res.to_string()),
6        Err(err) => Err(format!("'{}' is not a valid glob pattern - {}", v, err)),
7    }
8}
9
10pub const RED: Style = Style::new().red();
11pub const GREEN: Style = Style::new().green();
12pub const YELLOW: Style = Style::new().yellow();
13pub const CYAN: Style = Style::new().cyan();
14
15/// A simple [`dbg!`](https://doc.rust-lang.org/std/macro.dbg.html)-like macro to help debugging `reqwest` calls.
16/// Uses `tracing::debug!` instead of `eprintln!`.
17#[macro_export]
18macro_rules! dbg_as_curl {
19    ($req:expr) => {
20        match $req {
21            tmp => {
22                match tmp.try_clone().map(|b| b.build()) {
23                    Some(Ok(req)) => tracing::debug!("{}", crate::cli::utils::AsCurl::new(&req)),
24                    Some(Err(err)) => tracing::debug!("*Error*: {}", err),
25                    None => tracing::debug!("*Error*: request not cloneable",),
26                }
27                tmp
28            }
29        }
30    };
31}
32
33/// A wrapper around a request that displays as a cURL command.
34pub struct AsCurl<'a> {
35    req: &'a reqwest::Request,
36}
37
38impl<'a> AsCurl<'a> {
39    /// Construct an instance of `AsCurl` with the given request.
40    pub fn new(req: &'a reqwest::Request) -> AsCurl<'a> {
41        Self { req }
42    }
43}
44
45impl<'a> std::fmt::Debug for AsCurl<'a> {
46    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
47        <Self as std::fmt::Display>::fmt(self, f)
48    }
49}
50
51impl<'a> std::fmt::Display for AsCurl<'a> {
52    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
53        let AsCurl { req } = *self;
54
55        write!(f, "curl ")?;
56
57        let method = req.method();
58        if method != "GET" {
59            write!(f, "-X {} ", method)?;
60        }
61
62        for (name, value) in req.headers() {
63            let value = value
64                .to_str()
65                .expect("Headers must contain only visible ASCII characters")
66                .replace("'", r"'\''");
67
68            write!(f, "--header '{}: {}' ", name, value)?;
69        }
70
71        // Body
72        if let Some(body) = req.body() {
73            // Try to get bytes if possible
74            if let Some(bytes) = body.as_bytes() {
75                let s = String::from_utf8_lossy(bytes).replace("'", r"'\''");
76                write!(f, "--data-raw '{}' ", s)?;
77            } else {
78                write!(
79                    f,
80                    "# NOTE: Body present but not shown (stream or unknown type) "
81                )?;
82            }
83        }
84
85        // URL
86        write!(f, "'{}'", req.url().to_string().replace("'", "%27"))?;
87
88        Ok(())
89    }
90}
91
92#[cfg(test)]
93mod debug_as_curl_tests {
94
95    use crate::dbg_as_curl;
96
97    fn compare(req: reqwest::RequestBuilder, result: &str) {
98        let req = dbg_as_curl!(req);
99
100        let req = req.build().unwrap();
101        assert_eq!(format!("{}", super::AsCurl::new(&req)), result);
102    }
103
104    #[test]
105    fn basic() {
106        let client = reqwest::Client::new();
107
108        compare(
109            client.get("http://example.org"),
110            "curl 'http://example.org/'",
111        );
112        compare(
113            client.get("https://example.org"),
114            "curl 'https://example.org/'",
115        );
116    }
117
118    #[test]
119    fn escape_url() {
120        let client = reqwest::Client::new();
121
122        compare(
123            client.get("https://example.org/search?q='"),
124            "curl 'https://example.org/search?q=%27'",
125        );
126    }
127
128    #[test]
129    fn bearer() {
130        let client = reqwest::Client::new();
131
132        compare(
133            client.get("https://example.org").bearer_auth("foo"),
134            "curl --header 'authorization: Bearer foo' 'https://example.org/'",
135        );
136    }
137
138    #[test]
139    fn escape_headers() {
140        let client = reqwest::Client::new();
141
142        compare(
143            client.get("https://example.org").bearer_auth("test's"),
144            r"curl --header 'authorization: Bearer test'\''s' 'https://example.org/'",
145        );
146    }
147
148    // The body cannot be included as there is not API to retrieve its content.
149    #[test]
150    fn body() {
151        let client = reqwest::Client::new();
152
153        compare(
154            client.get("https://example.org").body("test's"),
155            r"curl --data-raw 'test'\''s' 'https://example.org/'",
156        );
157    }
158}
159
160pub mod git_info {
161    use std::env;
162    use std::process::Command;
163
164    const BRANCH_ENV_VAR_NAMES: &[&str] = &[
165        "GITHUB_HEAD_REF",
166        "GITHUB_REF",
167        "BUILDKITE_BRANCH",
168        "CIRCLE_BRANCH",
169        "TRAVIS_BRANCH",
170        "GIT_BRANCH",
171        "GIT_LOCAL_BRANCH",
172        "APPVEYOR_REPO_BRANCH",
173        "CI_COMMIT_REF_NAME",
174        "BITBUCKET_BRANCH",
175        "BUILD_SOURCEBRANCHNAME",
176        "CIRRUS_BRANCH",
177    ];
178
179    const COMMIT_ENV_VAR_NAMES: &[&str] = &[
180        "GITHUB_SHA",
181        "BUILDKITE_COMMIT",
182        "CIRCLE_SHA1",
183        "TRAVIS_COMMIT",
184        "GIT_COMMIT",
185        "APPVEYOR_REPO_COMMIT",
186        "CI_COMMIT_ID",
187        "BITBUCKET_COMMIT",
188        "BUILD_SOURCEVERSION",
189        "CIRRUS_CHANGE_IN_REPO",
190    ];
191
192    const BUILD_URL_ENV_VAR_NAMES: &[&str] = &[
193        "BUILDKITE_BUILD_URL",
194        "CIRCLE_BUILD_URL",
195        "TRAVIS_BUILD_WEB_URL",
196        "BUILD_URL",
197    ];
198
199    pub fn commit(raise_error: bool) -> Option<String> {
200        find_commit_from_env_vars().or_else(|| commit_from_git_command(raise_error))
201    }
202
203    pub fn branch(raise_error: bool) -> Option<String> {
204        find_branch_from_known_env_vars()
205            .or_else(find_branch_from_env_var_ending_with_branch)
206            .or_else(|| branch_from_git_command(raise_error))
207    }
208
209    pub fn build_url() -> Option<String> {
210        github_build_url().or_else(|| {
211            BUILD_URL_ENV_VAR_NAMES
212                .iter()
213                .filter_map(|&name| value_from_env_var(name))
214                .next()
215        })
216    }
217
218    fn find_commit_from_env_vars() -> Option<String> {
219        COMMIT_ENV_VAR_NAMES
220            .iter()
221            .filter_map(|&name| value_from_env_var(name))
222            .next()
223    }
224
225    fn find_branch_from_known_env_vars() -> Option<String> {
226        BRANCH_ENV_VAR_NAMES
227            .iter()
228            .filter_map(|&name| value_from_env_var(name))
229            .next()
230            .map(|val| val.trim_start_matches("refs/heads/").to_string())
231    }
232
233    fn find_branch_from_env_var_ending_with_branch() -> Option<String> {
234        let values: Vec<String> = env::vars()
235            .filter(|(k, _)| k.ends_with("_BRANCH"))
236            .filter_map(|(_, v)| {
237                let v = v.trim();
238                if !v.is_empty() {
239                    Some(v.to_string())
240                } else {
241                    None
242                }
243            })
244            .collect();
245        if values.len() == 1 {
246            Some(values[0].clone())
247        } else {
248            None
249        }
250    }
251
252    fn value_from_env_var(name: &str) -> Option<String> {
253        env::var(name).ok().and_then(|v| {
254            let v = v.trim();
255            if !v.is_empty() {
256                Some(v.to_string())
257            } else {
258                None
259            }
260        })
261    }
262
263    fn branch_from_git_command(raise_error: bool) -> Option<String> {
264        let branch_names = execute_and_parse_command(raise_error);
265        if raise_error {
266            validate_branch_names(&branch_names);
267        }
268        if branch_names.len() == 1 {
269            Some(branch_names[0].clone())
270        } else {
271            None
272        }
273    }
274
275    fn commit_from_git_command(raise_error: bool) -> Option<String> {
276        match execute_git_commit_command() {
277            Ok(s) => {
278                let trimmed = s.trim();
279                if trimmed.is_empty() {
280                    None
281                } else {
282                    Some(trimmed.to_string())
283                }
284            }
285            Err(e) => {
286                if raise_error {
287                    panic!(
288                        "Could not determine current git commit using command `git rev-parse HEAD`. {}",
289                        e
290                    );
291                }
292                None
293            }
294        }
295    }
296
297    fn validate_branch_names(branch_names: &[String]) {
298        if branch_names.is_empty() {
299            panic!(
300                "Command `git rev-parse --abbrev-ref HEAD` didn't return anything that could be identified as the current branch."
301            );
302        }
303        if branch_names.len() > 1 {
304            panic!(
305                "Command `git rev-parse --abbrev-ref HEAD` returned multiple branches: {}. You will need to get the branch name another way.",
306                branch_names.join(", ")
307            );
308        }
309    }
310
311    fn execute_git_command() -> Result<String, String> {
312        Command::new("git")
313            .args(&["rev-parse", "--abbrev-ref", "HEAD"])
314            .output()
315            .map_err(|e| e.to_string())
316            .and_then(|output| {
317                if output.status.success() {
318                    Ok(String::from_utf8_lossy(&output.stdout).to_string())
319                } else {
320                    Err(String::from_utf8_lossy(&output.stderr).to_string())
321                }
322            })
323    }
324
325    fn execute_git_commit_command() -> Result<String, String> {
326        Command::new("git")
327            .args(&["rev-parse", "HEAD"])
328            .output()
329            .map_err(|e| e.to_string())
330            .and_then(|output| {
331                if output.status.success() {
332                    Ok(String::from_utf8_lossy(&output.stdout).to_string())
333                } else {
334                    Err(String::from_utf8_lossy(&output.stderr).to_string())
335                }
336            })
337    }
338
339    fn execute_and_parse_command(raise_error: bool) -> Vec<String> {
340        match execute_git_command() {
341            Ok(output) => output
342                .lines()
343                .map(str::trim)
344                .filter(|l| !l.is_empty())
345                .map(|l| l.split_whitespace().next().unwrap_or("").to_string())
346                .map(|l| l.trim_start_matches("origin/").to_string())
347                .filter(|l| l != "HEAD")
348                .collect(),
349            Err(e) => {
350                if raise_error {
351                    panic!(
352                        "Could not determine current git branch using command `git rev-parse --abbrev-ref HEAD`. {}",
353                        e
354                    );
355                }
356                vec![]
357            }
358        }
359    }
360
361    fn github_build_url() -> Option<String> {
362        let parts: Vec<String> = ["GITHUB_SERVER_URL", "GITHUB_REPOSITORY", "GITHUB_RUN_ID"]
363            .iter()
364            .filter_map(|&name| value_from_env_var(name))
365            .collect();
366        if parts.len() == 3 {
367            Some(format!(
368                "{}/{}/actions/runs/{}",
369                parts[0], parts[1], parts[2]
370            ))
371        } else {
372            None
373        }
374    }
375}