pact_broker_cli/cli/
utils.rs

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