1use std::{
2 convert::TryInto,
3 ops::Add,
4 time::{Duration, SystemTime, UNIX_EPOCH},
5};
6
7use miette::{IntoDiagnostic, Result, WrapErr};
8
9use crate::{
10 external::Vcs,
11 mit::{
12 cmd::{errors::Error::NoAuthorsToSet, vcs::has_vcs_coauthor, CONFIG_KEY_EXPIRES},
13 Author,
14 },
15};
16
17pub fn set_commit_authors(
22 config: &mut dyn Vcs,
23 authors: &[&Author<'_>],
24 expires_in: Duration,
25) -> Result<()> {
26 let (first_author, others) = authors.split_first().ok_or(NoAuthorsToSet)?;
27
28 remove_coauthors(config)?;
29 set_vcs_user(config, first_author)?;
30 set_vcs_coauthors(config, others)?;
31 set_vcs_expires_time(config, expires_in)?;
32
33 Ok(())
34}
35
36pub fn remove_coauthors(config: &mut dyn Vcs) -> Result<()> {
37 get_defined_vcs_coauthor_keys(config)
38 .into_iter()
39 .try_for_each(|key| config.remove(&key))?;
40
41 Ok(())
42}
43
44#[allow(clippy::maybe_infinite_iter)]
45fn get_defined_vcs_coauthor_keys(config: &dyn Vcs) -> Vec<String> {
46 (0..)
47 .take_while(|index| has_vcs_coauthor(config, *index))
48 .flat_map(|index| {
49 [
50 format!("mit.author.coauthors.{index}.name"),
51 format!("mit.author.coauthors.{index}.email"),
52 ]
53 })
54 .collect()
55}
56
57fn set_vcs_coauthors(config: &mut dyn Vcs, authors: &[&Author<'_>]) -> Result<()> {
58 authors
59 .iter()
60 .enumerate()
61 .try_for_each(|(index, author)| set_vcs_coauthor(config, index, author))
62}
63
64pub fn set_vcs_coauthor(config: &mut dyn Vcs, index: usize, author: &Author<'_>) -> Result<()> {
65 set_vcs_coauthor_name(config, index, author)?;
66 set_vcs_coauthor_email(config, index, author)?;
67
68 Ok(())
69}
70
71fn set_vcs_coauthor_name(config: &mut dyn Vcs, index: usize, author: &Author<'_>) -> Result<()> {
72 config.set_str(&format!("mit.author.coauthors.{index}.name"), author.name())?;
73 Ok(())
74}
75
76fn set_vcs_coauthor_email(config: &mut dyn Vcs, index: usize, author: &Author<'_>) -> Result<()> {
77 config.set_str(
78 &format!("mit.author.coauthors.{index}.email"),
79 author.email(),
80 )?;
81 Ok(())
82}
83
84pub fn set_vcs_user(config: &mut dyn Vcs, author: &Author<'_>) -> Result<()> {
85 config.set_str("user.name", author.name())?;
86 config.set_str("user.email", author.email())?;
87 set_author_signing_key(config, author)?;
88
89 Ok(())
90}
91
92fn set_author_signing_key(config: &mut dyn Vcs, author: &Author<'_>) -> Result<()> {
93 if let Some(key) = author.signingkey() {
94 config
95 .set_str("user.signingkey", key)
96 .wrap_err("failed to set git author's signing key ")
97 } else {
98 if config.get_str("user.signingkey")?.is_some() {
99 config
100 .remove("user.signingkey")
101 .wrap_err("failed to remove git author's signing key")?;
102 }
103 Ok(())
104 }
105}
106
107fn set_vcs_expires_time(config: &mut dyn Vcs, expires_in: Duration) -> Result<()> {
108 let now = SystemTime::now()
109 .duration_since(UNIX_EPOCH)
110 .into_diagnostic()?;
111 let expiry_time = now.add(expires_in).as_secs().try_into().into_diagnostic()?;
112 config
113 .set_i64(CONFIG_KEY_EXPIRES, expiry_time)
114 .wrap_err("failed to set author expiry time")
115}
116
117#[cfg(test)]
118mod tests {
119 use std::{
120 collections::BTreeMap,
121 convert::TryFrom,
122 error::Error,
123 ops::Add,
124 time::{Duration, SystemTime, UNIX_EPOCH},
125 };
126
127 use miette::{miette, Result};
128
129 use crate::{
130 external::{InMemory, RepoState, Vcs},
131 mit::{set_commit_authors, Author},
132 };
133
134 struct FailingVcs;
135
136 impl Vcs for FailingVcs {
137 fn entries(&self, _glob: Option<&str>) -> Result<Vec<String>> {
138 Ok(vec![])
139 }
140
141 fn get_bool(&self, _name: &str) -> Result<Option<bool>> {
142 Ok(None)
143 }
144
145 fn get_str(&self, name: &str) -> Result<Option<&str>> {
146 if name == "user.signingkey" {
147 Ok(Some("existing-key"))
148 } else {
149 Ok(None)
150 }
151 }
152
153 fn get_i64(&self, _name: &str) -> Result<Option<i64>> {
154 Ok(None)
155 }
156
157 fn set_str(&mut self, _name: &str, _value: &str) -> Result<()> {
158 Ok(())
159 }
160
161 fn set_i64(&mut self, _name: &str, _value: i64) -> Result<()> {
162 Ok(())
163 }
164
165 fn remove(&mut self, name: &str) -> Result<()> {
166 if name == "user.signingkey" {
167 Err(miette!("simulated remove error"))
168 } else {
169 Ok(())
170 }
171 }
172
173 fn state(&self) -> Option<RepoState> {
174 None
175 }
176 }
177
178 #[test]
179 fn the_first_initial_becomes_the_author() {
180 let mut buffer = BTreeMap::new();
181
182 let mut vcs_config = InMemory::new(&mut buffer);
183
184 let author = Author::new("Billie Thompson".into(), "billie@example.com".into(), None);
185 let actual = set_commit_authors(&mut vcs_config, &[&author], Duration::from_hours(1));
186
187 actual.unwrap();
188 assert_eq!(
189 Some(&"Billie Thompson".to_string()),
190 buffer.get("user.name"),
191 "Expected the first author's name to be set as user.name"
192 );
193 assert_eq!(
194 Some(&"billie@example.com".to_string()),
195 buffer.get("user.email"),
196 "Expected the first author's email to be set as user.email"
197 );
198 }
199
200 #[test]
201 fn the_first_initial_sets_signing_key_if_it_is_there() {
202 let mut str_map = BTreeMap::new();
203 let mut vcs_config = InMemory::new(&mut str_map);
204
205 let author = Author::new(
206 "Billie Thompson".into(),
207 "billie@example.com".into(),
208 Some("0A46826A".into()),
209 );
210 let actual = set_commit_authors(&mut vcs_config, &[&author], Duration::from_hours(1));
211
212 actual.unwrap();
213 assert_eq!(
214 Some(&"0A46826A".to_string()),
215 str_map.get("user.signingkey"),
216 "Expected the signing key to be set when the author has one"
217 );
218 }
219
220 #[test]
221 fn the_first_initial_removes_if_it_is_there_and_not_present() {
222 let mut buffer = BTreeMap::new();
223 buffer.insert("user.signingkey".into(), "0A46826A".into());
224
225 let mut vcs_config = InMemory::new(&mut buffer);
226
227 let author = Author::new("Billie Thompson".into(), "billie@example.com".into(), None);
228 let actual = set_commit_authors(&mut vcs_config, &[&author], Duration::from_hours(1));
229
230 actual.unwrap();
231 assert_eq!(
232 None,
233 buffer.get("user.signingkey"),
234 "Expected the signing key to be removed when the author does not have one"
235 );
236 }
237
238 #[test]
239 fn multiple_authors_become_coauthors() {
240 let mut buffer = BTreeMap::new();
241 let mut vcs_config = InMemory::new(&mut buffer);
242
243 let author_1 = Author::new("Billie Thompson".into(), "billie@example.com".into(), None);
244 let author_2 = Author::new("Somebody Else".into(), "somebody@example.com".into(), None);
245 let author_3 = Author::new("Annie Example".into(), "annie@example.com".into(), None);
246 let inputs = vec![&author_1, &author_2, &author_3];
247
248 let actual = set_commit_authors(&mut vcs_config, &inputs, Duration::from_hours(1));
249
250 actual.unwrap();
251 assert_eq!(
252 Some(&"Billie Thompson".to_string()),
253 buffer.get("user.name"),
254 "Expected the primary author's name to be set as user.name"
255 );
256 assert_eq!(
257 Some(&"billie@example.com".to_string()),
258 buffer.get("user.email"),
259 "Expected the primary author's email to be set as user.email"
260 );
261 assert_eq!(
262 Some(&"Somebody Else".to_string()),
263 buffer.get("mit.author.coauthors.0.name"),
264 "Expected the first coauthor's name to be set"
265 );
266 assert_eq!(
267 Some(&"somebody@example.com".to_string()),
268 buffer.get("mit.author.coauthors.0.email"),
269 "Expected the first coauthor's email to be set"
270 );
271 assert_eq!(
272 Some(&"Annie Example".to_string()),
273 buffer.get("mit.author.coauthors.1.name"),
274 "Expected the second coauthor's name to be set"
275 );
276 assert_eq!(
277 Some(&"annie@example.com".to_string()),
278 buffer.get("mit.author.coauthors.1.email"),
279 "Expected the second coauthor's email to be set"
280 );
281 }
282
283 #[test]
284 fn old_co_authors_are_removed() {
285 let mut buffer = BTreeMap::new();
286 buffer.insert(
287 "mit.author.expires".into(),
288 format!(
289 "{}",
290 SystemTime::now()
291 .duration_since(UNIX_EPOCH)
292 .map(|x| x.as_secs() + 1000)
293 .unwrap()
294 ),
295 );
296 buffer.insert("user.name".into(), "Another Name".into());
297 buffer.insert("user.email".into(), "another@example.com".into());
298 buffer.insert(
299 "mit.author.coauthors.0.name".into(),
300 "Different Name".into(),
301 );
302 buffer.insert(
303 "mit.author.coauthors.0.email".into(),
304 "different@example.com".into(),
305 );
306 let mut vcs_config = InMemory::new(&mut buffer);
307 let author = Author::new("Billie Thompson".into(), "billie@example.com".into(), None);
308 let inputs = vec![&author];
309
310 let actual = set_commit_authors(&mut vcs_config, &inputs, Duration::from_hours(1));
311
312 actual.unwrap();
313 assert_eq!(
314 Some(&"Billie Thompson".to_string()),
315 buffer.get("user.name"),
316 "Expected the new primary author's name to overwrite the old one"
317 );
318 assert_eq!(
319 Some(&"billie@example.com".to_string()),
320 buffer.get("user.email"),
321 "Expected the new primary author's email to overwrite the old one"
322 );
323 assert_eq!(
324 None,
325 buffer.get("mit.author.coauthors.0.name"),
326 "Expected old coauthor name to be removed"
327 );
328 assert_eq!(
329 None,
330 buffer.get("mit.author.coauthors.0.email"),
331 "Expected old coauthor email to be removed"
332 );
333 }
334
335 #[test]
336 fn sets_the_expiry_time() {
337 let mut buffer = BTreeMap::new();
338 let mut vcs_config = InMemory::new(&mut buffer);
339
340 let author = Author::new("Billie Thompson".into(), "billie@example.com".into(), None);
341 let actual = set_commit_authors(&mut vcs_config, &[&author], Duration::from_hours(1));
342
343 actual.unwrap();
344
345 let sec59min = SystemTime::now()
346 .duration_since(UNIX_EPOCH)
347 .map(|x| x.add(Duration::from_mins(59)))
348 .map_err(|x| -> Box<dyn Error> { Box::from(x) })
349 .map(|x| x.as_secs())
350 .and_then(|x| i64::try_from(x).map_err(Box::from))
351 .unwrap();
352
353 let sec61min = SystemTime::now()
354 .duration_since(UNIX_EPOCH)
355 .map(|x| x.add(Duration::from_mins(61)))
356 .map_err(|x| -> Box<dyn Error> { Box::from(x) })
357 .map(|x| x.as_secs())
358 .and_then(|x| i64::try_from(x).map_err(Box::from))
359 .unwrap();
360
361 let actual_expire_time: i64 = buffer
362 .get("mit.author.expires")
363 .and_then(|x| x.parse().ok())
364 .expect("Failed to read expire");
365
366 assert!(
367 actual_expire_time < sec61min,
368 "Expected less than {}, found {}",
369 sec61min,
370 actual_expire_time
371 );
372 assert!(
373 actual_expire_time > sec59min,
374 "Expected more than {} seconds since UNIX EPOCH, found {}",
375 sec59min,
376 actual_expire_time
377 );
378 }
379
380 #[test]
381 fn propagates_error_when_removing_signing_key_fails() {
382 let mut vcs_config = FailingVcs;
383
384 let author = Author::new("Billie Thompson".into(), "billie@example.com".into(), None);
385 let actual = set_commit_authors(&mut vcs_config, &[&author], Duration::from_hours(1));
386
387 assert!(
388 actual.is_err(),
389 "Expected an error when removing the signing key fails"
390 );
391 }
392
393 struct ExpiryFailingVcs;
394
395 impl Vcs for ExpiryFailingVcs {
396 fn entries(&self, _glob: Option<&str>) -> Result<Vec<String>> {
397 Ok(vec![])
398 }
399
400 fn get_bool(&self, _name: &str) -> Result<Option<bool>> {
401 Ok(None)
402 }
403
404 fn get_str(&self, _name: &str) -> Result<Option<&str>> {
405 Ok(None)
406 }
407
408 fn get_i64(&self, _name: &str) -> Result<Option<i64>> {
409 Ok(None)
410 }
411
412 fn set_str(&mut self, _name: &str, _value: &str) -> Result<()> {
413 Ok(())
414 }
415
416 fn set_i64(&mut self, _name: &str, _value: i64) -> Result<()> {
417 Err(miette!("simulated set_i64 error"))
418 }
419
420 fn remove(&mut self, _name: &str) -> Result<()> {
421 Ok(())
422 }
423
424 fn state(&self) -> Option<RepoState> {
425 None
426 }
427 }
428
429 #[test]
430 fn expiry_error_message_mentions_time_not_name() {
431 let mut vcs_config = ExpiryFailingVcs;
432
433 let author = Author::new("Billie Thompson".into(), "billie@example.com".into(), None);
434 let actual = set_commit_authors(&mut vcs_config, &[&author], Duration::from_hours(1));
435
436 let err = actual.expect_err("expected set_commit_authors to fail with ExpiryFailingVcs");
437 let err_msg = format!("{err:#?}");
438 assert!(
439 err_msg.contains("time") || format!("{err}").contains("time"),
440 "Expected the expiry error message to mention 'time', got: {}",
441 err_msg
442 );
443 assert!(
444 !format!("{err}").contains("expiry name"),
445 "Error message should not say 'expiry name', got: {}",
446 err
447 );
448 }
449}