1use regex::Regex;
2use std::sync::LazyLock;
3use std::{cell::OnceCell, str::FromStr};
4
5use crate::{Actor, Error, ModifiedFile, Repository};
6
7fn iter_co_authors(haystack: &str) -> impl Iterator<Item = &str> {
10 const CO_AUTHOR_REGEX: &str = r"(?m)^Co-authored-by: (.*) <(.*?)>$";
11 static RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(CO_AUTHOR_REGEX).unwrap());
13
14 let prefix = "Co-authored-by:";
15 RE.find_iter(haystack).map(move |re_match| {
16 re_match
17 .as_str()
18 .strip_prefix(prefix)
19 .unwrap_or_default()
20 .trim()
21 })
22}
23
24pub struct Commit<'repo> {
26 inner: git2::Commit<'repo>,
27 ctx: &'repo Repository,
28 cache: OnceCell<git2::Diff<'repo>>,
29}
30
31impl<'repo> Commit<'repo> {
32 pub fn new(commit: git2::Commit<'repo>, repository: &'repo Repository) -> Self {
34 Self {
35 inner: commit.to_owned(),
36 ctx: repository,
37 cache: OnceCell::new(),
38 }
39 }
40
41 pub fn hash(&self) -> String {
43 self.inner.id().to_string()
44 }
45
46 pub fn msg(&self) -> Option<&str> {
48 self.inner.message()
49 }
50
51 pub fn author(&self) -> Actor {
53 Actor::new(self.inner.author())
54 }
55
56 pub fn co_authors(&self) -> impl Iterator<Item = Result<Actor, Error>> {
61 let commit_msg = self.msg().unwrap_or_default();
62 iter_co_authors(commit_msg).map(Actor::from_str)
63 }
64
65 pub fn committer(&self) -> Actor {
67 Actor::new(self.inner.committer())
68 }
69
70 pub fn parents(&self) -> impl Iterator<Item = String> {
72 self.inner.parent_ids().map(|id| id.to_string())
73 }
74
75 pub fn is_merge(&self) -> bool {
77 self.inner.parent_count() > 1
78 }
79
80 pub fn mod_files(&self) -> Result<impl Iterator<Item = ModifiedFile<'_>>, Error> {
82 let diff = self.diff()?;
83
84 Ok((0..diff.deltas().len()).map(move |n| ModifiedFile::new(diff, n)))
85 }
86
87 pub fn insertions(&self) -> Result<usize, Error> {
89 Ok(self.stats()?.insertions())
90 }
91
92 pub fn deletions(&self) -> Result<usize, Error> {
94 Ok(self.stats()?.deletions())
95 }
96
97 pub fn lines(&self) -> Result<usize, Error> {
99 Ok(self.insertions()? + self.deletions()?)
100 }
101
102 pub fn files(&self) -> Result<usize, Error> {
104 Ok(self.stats()?.files_changed())
105 }
106
107 fn stats(&self) -> Result<git2::DiffStats, Error> {
110 let diff = self.diff()?;
111 diff.stats().map_err(Error::Git)
112 }
113
114 fn diff(&self) -> Result<&git2::Diff<'repo>, Error> {
118 let diff = self.calculate_diff()?;
119 Ok(self.cache.get_or_init(|| diff))
120 }
121
122 fn calculate_diff(&self) -> Result<git2::Diff<'repo>, Error> {
125 let this_tree = self.inner.tree().ok();
126 let parent_tree = self.resolve_parent_tree()?;
127
128 self.ctx
129 .raw()
130 .diff_tree_to_tree(parent_tree.as_ref(), this_tree.as_ref(), None)
132 .map_err(Error::Git)
133 }
134
135 fn resolve_parent_tree(&self) -> Result<Option<git2::Tree<'_>>, Error> {
138 Ok(match self.inner.parent_count() {
139 0 => None,
140 1 => self.inner.parent(0).map_err(Error::Git)?.tree().ok(),
141 _ => return Err(Error::PathError("Placeholder error".to_string())),
143 })
144 }
145}
146
147#[cfg(test)]
148mod test {
149 use super::*;
150 use crate::{
151 Local, Repository,
152 common::{EXPECTED_ACTOR_EMAIL, EXPECTED_ACTOR_NAME, EXPECTED_MSG, init_repo},
153 };
154
155 fn commit_fixture<F, R>(f: F) -> R
156 where
157 F: FnOnce(&Repository<Local>, &Commit) -> R,
158 {
159 let repo = init_repo();
160
161 let repo = Repository::<Local>::from_repository(repo);
162 let commit = repo.head().expect("Failed to get HEAD");
163
164 f(&repo, &commit)
165 }
166
167 #[test]
168 fn test_msg() {
169 commit_fixture(|_, commit| {
170 assert_eq!(commit.msg(), Some(EXPECTED_MSG));
172 });
173 }
174
175 #[test]
176 fn test_author() {
177 commit_fixture(|_, commit| {
178 assert_eq!(
179 commit.author().name().unwrap(),
180 EXPECTED_ACTOR_NAME.to_string()
181 );
182 assert_eq!(
183 commit.author().email().unwrap(),
184 EXPECTED_ACTOR_EMAIL.to_string()
185 );
186 });
187 }
188
189 #[test]
190 fn test_co_authors() {
191 commit_fixture(|_, commit| {
192 for co_auth in commit.co_authors() {
193 assert!(co_auth.is_ok());
194 }
195 });
196 }
197
198 #[test]
199 fn test_committer() {
200 commit_fixture(|_, commit| {
201 assert_eq!(
202 commit.committer().name().unwrap(),
203 EXPECTED_ACTOR_NAME.to_string()
204 );
205 assert_eq!(
206 commit.committer().email().unwrap(),
207 EXPECTED_ACTOR_EMAIL.to_string()
208 );
209 });
210 }
211
212 #[test]
213 fn test_parents() {
214 commit_fixture(|_, commit| {
215 assert_eq!(commit.parents().collect::<Vec<String>>().len(), 1);
216 });
217 }
218
219 #[test]
220 fn test_is_merge() {
221 commit_fixture(|_, commit| {
222 assert!(!commit.is_merge());
223 });
224 }
225
226 #[test]
227 fn test_insertions() {
228 commit_fixture(|_, commit| {
229 assert_eq!(commit.insertions().unwrap(), 1);
230 });
231 }
232
233 #[test]
234 fn test_deletions() {
235 commit_fixture(|_, commit| {
236 assert_eq!(commit.deletions().unwrap(), 0);
237 });
238 }
239
240 #[test]
241 fn test_lines() {
242 commit_fixture(|_, commit| {
243 assert_eq!(commit.lines().unwrap(), 1);
244 });
245 }
246
247 #[test]
248 fn test_stat() {
249 commit_fixture(|_, commit| {
250 let _: git2::DiffStats = commit
253 .stats()
254 .expect("Failed to construct git2 Stats object");
255 });
256 }
257
258 #[test]
259 fn test_iter_matches() {
260 let haystack = "Co-authored-by: John <john@example.com>";
261 assert_eq!(iter_co_authors(haystack).collect::<Vec<&str>>().len(), 1);
262
263 let haystack = "No matches expected";
264 assert_eq!(iter_co_authors(haystack).collect::<Vec<&str>>().len(), 0);
265 }
266}