Skip to main content

mit_commit_message_lints/mit/cmd/
set_commit_authors.rs

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
17/// # Errors
18///
19/// If writing to the git mit file fails for some reason.
20/// This reason will be specific to VCS implementation
21pub 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}