commit_info/
lib.rs

1#![warn(missing_docs)]
2#![warn(rustdoc::missing_doc_code_examples)]
3
4//! This crate gathers relevant git info from any Repo. Some of the info returned includes:
5//! - **Git status info**: Checks if a repo is dirty, has been modified and so on.
6//! - **Commits**: Gathers and shows information for the last 10 commits
7//! 
8//! ## Example
9//! ```rust
10//!  # let mut path = env::current_dir().unwrap();
11//!  # path.push("test_project");
12//!  # let dir = path.to_string_lossy().to_string();
13//!  // let dir = "/path/to/repo"; <- Point to the location of t=your repo
14//!  let info = Info::new(&dir).status_info()?.commit_info()?;
15//!  println("{:#?}", info);
16
17// Copyright 2022 Anthony Mugendi
18//
19// Licensed under the Apache License, Version 2.0 (the "License");
20// you may not use this file except in compliance with the License.
21// You may obtain a copy of the License at
22//
23//     http://www.apache.org/licenses/LICENSE-2.0
24//
25// Unless required by applicable law or agreed to in writing, software
26// distributed under the License is distributed on an "AS IS" BASIS,
27// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
28// See the License for the specific language governing permissions and
29// limitations under the License.
30
31use anyhow::Result;
32use chrono::{DateTime, Utc};
33use cmd_lib::run_fun;
34use serde::{Deserialize, Serialize};
35use serde_json::{from_str, json};
36use std::{collections::HashMap, path::PathBuf};
37
38/// The Status Struct:
39/// Holds information about the status of the repo
40#[derive(Debug, Clone)]
41pub struct Status {
42    /// Holds any error thrown by ```git status```
43    pub error: Option<String>,
44    /// Indicates if repo is dirty or not. For this, we check both ```git status -s``` and ```git diff -stat```
45    pub git_dirty: Option<bool>,
46    /// A HashMap describing the state of the repo
47    pub summary: HashMap<String, bool>,
48}
49
50/// Struct holding info of each commit
51#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
52pub struct Commit {
53    /// The repo commit date
54    #[serde(with = "my_date_format")]
55    pub commit_date: Option<DateTime<Utc>>,
56    /// The repo commit message
57    pub commit_message: Option<String>,
58    /// The repo author name
59    pub author_name: Option<String>,
60    /// The repo author email
61    pub author_email: Option<String>,
62    /// The repo committer name (sometimes the author is not always the committer)
63    pub committer_name: Option<String>,
64    /// The repo committer email
65    pub committer_email: Option<String>,
66    /// tree hash
67    pub tree_hash: Option<String>,
68}
69
70/// The main struct that returns combined Status and Commits info
71#[derive(Debug, Clone)]
72pub struct Info {
73    /// Repo directory
74    pub dir: String,
75    /// Boolean indicating id the directory above is indeed a repo
76    pub is_git: bool,
77    /// Repo branch inspected
78    pub branch: Option<String>,
79    /// Status information for the repo
80    pub status: Option<Status>,
81    /// Information on the repo commits
82    pub commits: Option<Vec<Commit>>,
83}
84
85
86impl Commit {
87    /// To initialize a blank Commit Struct
88    pub fn new() -> Commit {
89        Commit {
90            // branch: "".into(),
91            commit_date: None,
92            commit_message: None,
93            author_name: None,
94            author_email: None,
95            committer_name: None,
96            committer_email: None,
97            tree_hash: None,
98        }
99    }
100}
101
102impl Info {
103    /// To initialize the Info Struct. A &str pointing to the repo directory is expected
104    /// This implementation method checks that the directory does indeed exist and that the repo is a git repo
105    /// It returns a new Info Struct with the "dir" and "is_git" fields set
106    /// 
107    /// ## Example
108    /// ```
109    ///  # let mut path = env::current_dir().unwrap();
110    ///  # path.push("test_project");
111    ///  # let dir = path.to_string_lossy().to_string();
112    ///  // let dir = "/path/to/repo"; <- Point to the location of t=your repo
113    ///  let info = Info::new(&dir);
114    ///  println("{:#?}", info);
115    /// ```
116    pub fn new(dir: &str) -> Info {
117        // check if dir is_git
118        let mut project_path = PathBuf::from(dir);
119        project_path.push(".git");
120
121        let is_git = project_path.exists();
122
123        Info {
124            dir: dir.into(),
125            is_git: is_git,
126            status: None,
127            commits: None,
128            branch: None,
129        }
130    }
131
132    /// Get information of all the commits.
133    /// This Method returns Info in its result.
134    /// If there are no commits, the returned value is None
135    /// ## Example
136    /// ```
137    ///  # let mut path = env::current_dir().unwrap();
138    ///  # path.push("test_project");
139    ///  # let dir = path.to_string_lossy().to_string();
140    ///  // let dir = "/path/to/repo"; <- Point to the location of t=your repo
141    ///  let commits_info = Info::new(&dir).commit_info()?;
142    ///  println("{:#?}", commits_info);
143    /// ```
144    pub fn commit_info(&self) -> Result<Info> {
145        let mut git_info = self.clone();
146
147        if git_info.is_git {
148            let dir = &git_info.dir;
149
150            //check diff
151            let branch = match run_fun!(
152                cd ${dir};
153                git branch -r |  grep -v HEAD | head -n 1 ;
154            ) {
155                Ok(resp) => {
156                    let r = resp.clone();
157                    r
158                }
159                _ => "".into(),
160            };
161
162            let branch = &branch[..];
163            let branch = branch.trim();
164            // println!("BBB >> {:?}", branch);
165            git_info.branch = Some(branch.into());
166
167            let format = format!("{{\"commit_date\":\"%ci\", \"commit_message\":\"%s\", \"author_name\":\"%an\", \"author_email\":\"%ae\", \"committer_name\":\"%cn\", \"committer_email\":\"%ce\",  \"tree_hash\":\"%t\"}}");
168
169            // let format = "%ci";
170
171            let empty_commit = json!(Commit::new());
172
173            let commits = match run_fun!(
174                cd ${dir};
175                git log --format="$format" $branch
176                // git status
177            ) {
178                Ok(resp) => resp,
179                Err(_) => {
180                    // println!("{:#?}", e);
181                    // Commit::new()
182                    empty_commit.to_string()
183                }
184            };
185
186            // println!("{:#?}", commits);
187
188            let commits = commits.split("\n").collect::<Vec<&str>>();
189            let len: usize = if commits.len() > 5 { 5 } else { commits.len() };
190
191            // pick top
192            let top_commits: Vec<Commit> = commits[0..len]
193                .to_vec()
194                .iter()
195                .map(|s| {
196                    let commit: Commit = match from_str(s) {
197                        Ok(c) => c,
198                        _ => Commit::new(),
199                    };
200                    commit
201                })
202                .filter(|e: &Commit| {
203                    // let b:&Commit = e;
204                    e.commit_date != None
205                })
206                .collect();
207
208            git_info.commits = if top_commits.len() > 0 {
209                Some(top_commits)
210            } else {
211                None
212            };
213
214            // println!("{:#?}",);
215            // git_info
216        }
217        Ok(git_info)
218    }
219
220    /// This method returns status information for the repo
221    /// ## Example
222    /// ```
223    ///  # let mut path = env::current_dir().unwrap();
224    ///  # path.push("test_project");
225    ///  # let dir = path.to_string_lossy().to_string();
226    ///  // let dir = "/path/to/repo"; <- Point to the location of t=your repo
227    ///  let status_info = Info::new(&dir).status_info()?;
228    ///  println("{:#?}", status_info);
229    /// ```
230    pub fn status_info(&self) -> Result<Info> {
231        let mut git_info = self.clone();
232        let mut status = Status {
233            error: None,
234            git_dirty: None,
235            summary: HashMap::new(),
236        };
237
238        if git_info.is_git {
239            let dir = &git_info.dir;
240
241            match run_fun!( cd ${dir}; git status -s; ) {
242                // if we can run git status then it is a git directory
243                Ok(resp) => {
244                    //
245                    let is_modified = resp.len() > 0;
246
247                    //check diff
248                    let resp = match run_fun!( cd ${dir}; git diff --stat; ) {
249                        Ok(r) => r,
250                        _ => "ERR".into(),
251                    };
252                    let is_dirty = resp.len() > 0;
253
254                    status.summary.insert("is_modified".into(), is_modified);
255                    status.summary.insert("is_dirty".into(), is_dirty);
256                    status.git_dirty = Some(is_dirty || is_modified);
257                }
258                Err(e) => {
259                    status.error = Some(format!("{:?}", e));
260                }
261            };
262        }
263
264        git_info.status = Some(status);
265
266        Ok(git_info)
267    }
268}
269
270mod my_date_format {
271    use chrono::{DateTime, TimeZone, Utc};
272    use serde::{self, Deserialize, Deserializer, Serializer};
273
274    // 2014-08-29 16:09:40 -0600
275
276    const FORMAT: &'static str = "%Y-%m-%d %H:%M:%S %Z";
277
278    // The signature of a serialize_with function must follow the pattern:
279    //
280    //    fn serialize<S>(&T, S) -> Result<S::Ok, S::Error>
281    //    where
282    //        S: Serializer
283    //
284    // although it may also be generic over the input types T.
285    pub fn serialize<S>(date: &Option<DateTime<Utc>>, serializer: S) -> Result<S::Ok, S::Error>
286    where
287        S: Serializer,
288    {
289        let s = match date {
290            Some(dt) => {
291                let s = format!("{}", dt.format(FORMAT));
292                s
293            }
294            _ => "null".into(),
295        };
296
297        serializer.serialize_str(&s)
298    }
299
300    // The signature of a deserialize_with function must follow the pattern:
301    //
302    //    fn deserialize<'de, D>(D) -> Result<T, D::Error>
303    //    where
304    //        D: Deserializer<'de>
305    //
306    // although it may also be generic over the output types T.
307    pub fn deserialize<'de, D>(deserializer: D) -> Result<Option<DateTime<Utc>>, D::Error>
308    where
309        D: Deserializer<'de>,
310    {
311        let s = String::deserialize(deserializer)?;
312
313        let dt = Utc
314            .datetime_from_str(&s, FORMAT)
315            .map_err(serde::de::Error::custom)?;
316
317        Ok(Some(dt))
318    }
319}
320
321
322// To successfully run tests, first create a "test_project" directory at the home of this crate
323// Do so by running cargo new test_project
324// It is not included so you will need to create it yourself
325#[cfg(test)]
326mod tests {
327
328    use super::Info;
329    use std::env;
330
331    fn test_dir() -> String {
332        let mut path = env::current_dir().unwrap();
333        path.push("test_project");
334
335        path.to_string_lossy().to_string()
336    }
337
338    #[test]
339    fn it_works() {
340        let dir = test_dir();
341
342        let info = Info::new(&dir)
343            .status_info()
344            .expect("Unable to get status info")
345            .commit_info()
346            .expect("Unable to get commit info");
347
348        assert_eq!(None, info.commits);
349        assert_eq!(Some(true), info.status.expect("err").git_dirty);
350    }
351}