Skip to main content

git_bot_feedback/client/
local.rs

1use super::RestApiClient;
2use crate::{OutputVariable, RestClientError as ClientError, ReviewOptions, ThreadCommentOptions};
3
4#[cfg(feature = "file-changes")]
5use crate::{FileDiffLines, FileFilter, LinesChangedOnly, parse_diff};
6#[cfg(feature = "file-changes")]
7use std::{collections::HashMap, process::Command};
8
9/// A (mostly) non-operational implementation of [`RestApiClient`].
10///
11/// This is primarily meant for use in local contexts (or in unsupported CI
12/// platforms/contexts) because the following methods silently do nothing:
13///
14/// - [`Self::post_thread_comment`]
15/// - [`Self::cull_pr_reviews`]
16/// - [`Self::post_pr_review`]
17/// - [`Self::set_user_agent`]
18///
19/// However, [`Self::get_list_of_changed_files`] does use the git CLI
20/// to get a list of changed files.
21///
22/// Instantiate with [`Default::default()`].
23/// ```rust
24/// use git_bot_feedback::client::LocalClient;
25///
26/// let client = LocalClient::default();
27/// ```
28#[derive(Debug, Clone, PartialEq, Eq, Default)]
29pub struct LocalClient;
30
31#[async_trait::async_trait]
32impl RestApiClient for LocalClient {
33    #[cfg(feature = "file-changes")]
34    async fn get_list_of_changed_files(
35        &self,
36        file_filter: &FileFilter,
37        lines_changed_only: &LinesChangedOnly,
38        base_diff: Option<String>,
39        ignore_index: bool,
40    ) -> Result<HashMap<String, FileDiffLines>, ClientError> {
41        let git_status = if ignore_index {
42            0
43        } else {
44            match Command::new("git").args(["status", "--short"]).output() {
45                Err(e) => {
46                    return Err(ClientError::io("invoke `git status`", e));
47                }
48                Ok(output) => {
49                    if output.status.success() {
50                        String::from_utf8_lossy(&output.stdout)
51                            .to_string()
52                            // trim last newline to prevent an extra empty line being counted as a changed file
53                            .trim_end_matches('\n')
54                            .lines()
55                            // we only care about staged changes
56                            .filter(|l| !l.starts_with(' '))
57                            .count()
58                    } else {
59                        let err_msg = String::from_utf8_lossy(&output.stderr).to_string();
60                        return Err(ClientError::GitCommand(err_msg));
61                    }
62                }
63            }
64        };
65        let mut diff_args = vec!["diff".to_string()];
66        if git_status != 0 {
67            // There are changes in the working directory.
68            // So, compare include the staged changes.
69            diff_args.push("--staged".to_string());
70        }
71        if let Some(base) = base_diff {
72            match Command::new("git")
73                .args(["rev-parse", base.as_str()])
74                .output()
75            {
76                Err(e) => {
77                    return Err(ClientError::Io {
78                        task: format!("invoke `git rev-parse {base}` to validate reference"),
79                        source: e,
80                    });
81                }
82                Ok(output) => {
83                    if output.status.success() {
84                        diff_args.push(base);
85                    } else if base.chars().all(|c| c.is_ascii_digit()) {
86                        // if all chars form a decimal number, then
87                        // try using it as a number of parents from HEAD
88                        diff_args.push(format!("HEAD~{base}"));
89                        // note, if still not a valid git reference, then
90                        // the error will be raised by the `git diff` command later
91                    } else {
92                        let err_msg = String::from_utf8_lossy(&output.stderr).to_string();
93                        // Given diff base did not resolve to a valid git reference
94                        return Err(ClientError::GitCommand(err_msg));
95                    }
96                }
97            }
98        } else if git_status == 0 {
99            // No base diff provided and there are no staged changes,
100            // just get the diff of the last commit.
101            diff_args.push("HEAD~1".to_string());
102        }
103        match Command::new("git").args(&diff_args).output() {
104            Err(e) => Err(ClientError::Io {
105                task: format!("invoke `git {}`", diff_args.join(" ")),
106                source: e,
107            }),
108            Ok(output) => {
109                if output.status.success() {
110                    let diff_str = String::from_utf8_lossy(&output.stdout).to_string();
111                    let files = parse_diff(&diff_str, file_filter, lines_changed_only);
112                    Ok(files)
113                } else {
114                    let err_msg = String::from_utf8_lossy(&output.stderr).to_string();
115                    Err(ClientError::GitCommand(err_msg))
116                }
117            }
118        }
119    }
120
121    fn is_pr_event(&self) -> bool {
122        false
123    }
124
125    fn set_user_agent(&mut self, _user_agent: &str) -> Result<(), ClientError> {
126        Ok(())
127    }
128
129    async fn post_thread_comment(&self, _options: ThreadCommentOptions) -> Result<(), ClientError> {
130        Ok(())
131    }
132
133    async fn cull_pr_reviews(&mut self, _options: &mut ReviewOptions) -> Result<(), ClientError> {
134        Ok(())
135    }
136
137    async fn post_pr_review(&mut self, _options: &ReviewOptions) -> Result<(), ClientError> {
138        Ok(())
139    }
140
141    fn write_output_variables(&self, vars: &[OutputVariable]) -> Result<(), ClientError> {
142        for var in vars {
143            log::info!("{}: {}", var.name, var.value);
144        }
145        Ok(())
146    }
147}