Skip to main content

mit_commit_message_lints/mit/cmd/
rotate_authors.rs

1use miette::Result;
2use rand::seq::SliceRandom;
3
4use crate::external::Vcs;
5use crate::mit::cmd::set_commit_authors::{remove_coauthors, set_vcs_coauthor, set_vcs_user};
6use crate::mit::{Author, cmd::vcs::get_vcs_coauthors_config};
7
8/// Rotate the primary author among configured authors
9///
10/// Moves the first coauthor to become the primary author,
11/// and demotes the current primary to be the last coauthor.
12/// This affects the NEXT commit (git reads user.name/email
13/// before the prepare-commit-msg hook runs).
14///
15/// # Errors
16///
17/// Returns an error if:
18/// - Reading git config (user.name, user.email, or coauthors) fails
19/// - Writing git config (removing old coauthors or setting new authors) fails
20///
21/// # Panics
22///
23/// This function will panic if the primary author (constructed from user.name
24/// and user.email) is None. However, this is prevented by an early return check:
25/// if either user.name or user.email is missing, the function returns Ok(())
26/// without attempting to unwrap. The unwrap on line 47 is safe because at that
27/// point we've confirmed both name and email exist.
28pub fn rotate_authors(config: &mut dyn Vcs, strategy: crate::mit::RotationOption) -> Result<()> {
29    // Read the current primary author
30    let primary_name = config.get_str("user.name")?.map(String::from);
31    let primary_email = config.get_str("user.email")?.map(String::from);
32    let primary_signingkey = config.get_str("user.signingkey")?.map(String::from);
33
34    let primary = match (primary_name, primary_email, primary_signingkey) {
35        (Some(name), Some(email), signingkey) => Some(Author::new(
36            name.into(),
37            email.into(),
38            signingkey.map(Into::into),
39        )),
40        _ => return Ok(()), // No primary author, nothing to rotate
41    };
42
43    // Read coauthors
44    let coauthor_emails: Vec<String> = get_vcs_coauthors_config(config, "email")?
45        .into_iter()
46        .filter_map(|x| x.map(|s| s.to_string()))
47        .collect();
48
49    let coauthors: Vec<Author> = get_vcs_coauthors_config(config, "name")?
50        .into_iter()
51        .filter_map(|x| x.map(|s| s.to_string()))
52        .zip(coauthor_emails)
53        .filter_map(|(name, email)| {
54            if name.is_empty() || email.is_empty() {
55                None
56            } else {
57                Some(Author::new(name.into(), email.into(), None))
58            }
59        })
60        .collect();
61
62    // Build full author list
63    let mut all_authors: Vec<Author> = vec![primary.unwrap()];
64    all_authors.extend(coauthors);
65
66    // If only 0 or 1 author, nothing to rotate
67    if all_authors.len() <= 1 {
68        return Ok(());
69    }
70
71    // Apply the rotation strategy
72    match strategy {
73        crate::mit::RotationOption::Off => return Ok(()),
74        crate::mit::RotationOption::RoundRobin => {
75            all_authors.rotate_left(1);
76        }
77        crate::mit::RotationOption::Random => {
78            all_authors.shuffle(&mut rand::rng());
79        }
80    }
81
82    // Write back
83    remove_coauthors(config)?;
84    set_vcs_user(config, &all_authors[0])?;
85    all_authors[1..]
86        .iter()
87        .enumerate()
88        .try_for_each(|(index, author)| set_vcs_coauthor(config, index, author))?;
89
90    Ok(())
91}
92
93#[cfg(test)]
94mod tests {
95    use std::collections::BTreeMap;
96    use std::time::Duration;
97
98    use miette::Result;
99
100    use crate::external::InMemory;
101    use crate::mit::{Author, set_commit_authors};
102
103    #[test]
104    fn rotate_authors_rotates_three_authors() -> Result<()> {
105        let mut buffer = BTreeMap::new();
106        {
107            let mut vcs_config = InMemory::new(&mut buffer);
108
109            let author_1 = Author::new("Billie Thompson".into(), "billie@example.com".into(), None);
110            let author_2 = Author::new("Somebody Else".into(), "someone@example.com".into(), None);
111            let author_3 = Author::new("Annie Example".into(), "annie@example.com".into(), None);
112
113            set_commit_authors(
114                &mut vcs_config,
115                &[&author_1, &author_2, &author_3],
116                Duration::from_hours(1),
117            )?;
118        }
119
120        // Initial state: A is primary, B & C are coauthors
121        assert_eq!(
122            buffer.get("user.name").map(String::as_str),
123            Some("Billie Thompson"),
124            "Expected the initial primary author to be Billie Thompson"
125        );
126        assert_eq!(
127            buffer
128                .get("mit.author.coauthors.0.name")
129                .map(String::as_str),
130            Some("Somebody Else"),
131            "Expected the first coauthor to be Somebody Else before rotation"
132        );
133        assert_eq!(
134            buffer
135                .get("mit.author.coauthors.1.name")
136                .map(String::as_str),
137            Some("Annie Example"),
138            "Expected the second coauthor to be Annie Example before rotation"
139        );
140
141        // Rotate: B becomes primary, C & A are coauthors
142        {
143            let mut vcs_config = InMemory::new(&mut buffer);
144            crate::mit::cmd::rotate_authors::rotate_authors(
145                &mut vcs_config,
146                crate::mit::RotationOption::RoundRobin,
147            )?;
148        }
149
150        assert_eq!(
151            buffer.get("user.name").map(String::as_str),
152            Some("Somebody Else"),
153            "Expected the primary author to be Somebody Else after rotation"
154        );
155        assert_eq!(
156            buffer
157                .get("mit.author.coauthors.0.name")
158                .map(String::as_str),
159            Some("Annie Example"),
160            "Expected the first coauthor to be Annie Example after rotation"
161        );
162        assert_eq!(
163            buffer
164                .get("mit.author.coauthors.1.name")
165                .map(String::as_str),
166            Some("Billie Thompson"),
167            "Expected the second coauthor to be Billie Thompson after rotation"
168        );
169
170        Ok(())
171    }
172
173    #[test]
174    fn rotate_authors_noops_with_single_author() -> Result<()> {
175        let mut buffer = BTreeMap::new();
176        {
177            let mut vcs_config = InMemory::new(&mut buffer);
178            let author = Author::new("Billie Thompson".into(), "billie@example.com".into(), None);
179            set_commit_authors(&mut vcs_config, &[&author], Duration::from_hours(1))?;
180        }
181
182        {
183            let mut vcs_config = InMemory::new(&mut buffer);
184            crate::mit::cmd::rotate_authors::rotate_authors(
185                &mut vcs_config,
186                crate::mit::RotationOption::RoundRobin,
187            )?;
188        }
189
190        // Should be unchanged
191        assert_eq!(
192            buffer.get("user.name").map(String::as_str),
193            Some("Billie Thompson"),
194            "Expected user.name to be unchanged with a single author"
195        );
196        assert_eq!(
197            buffer.get("user.email").map(String::as_str),
198            Some("billie@example.com"),
199            "Expected user.email to be unchanged with a single author"
200        );
201        assert!(
202            !buffer.contains_key("mit.author.coauthors.0.name"),
203            "Expected no coauthors to be set with a single author"
204        );
205
206        Ok(())
207    }
208
209    #[test]
210    fn rotate_authors_noops_with_zero_authors() -> Result<()> {
211        let mut buffer = BTreeMap::new();
212
213        {
214            let mut vcs_config = InMemory::new(&mut buffer);
215            crate::mit::cmd::rotate_authors::rotate_authors(
216                &mut vcs_config,
217                crate::mit::RotationOption::RoundRobin,
218            )?;
219        }
220
221        // Buffer should be unchanged
222        assert!(
223            !buffer.contains_key("user.name"),
224            "Expected no user.name to be set when there are no authors"
225        );
226
227        Ok(())
228    }
229
230    #[test]
231    fn rotate_authors_rotates_two_authors() -> Result<()> {
232        let mut buffer = BTreeMap::new();
233        {
234            let mut vcs_config = InMemory::new(&mut buffer);
235
236            let author_1 = Author::new("Billie Thompson".into(), "billie@example.com".into(), None);
237            let author_2 = Author::new("Somebody Else".into(), "someone@example.com".into(), None);
238
239            set_commit_authors(
240                &mut vcs_config,
241                &[&author_1, &author_2],
242                Duration::from_hours(1),
243            )?;
244        }
245
246        // Initial: A is primary, B is coauthor
247        assert_eq!(
248            buffer.get("user.name").map(String::as_str),
249            Some("Billie Thompson"),
250            "Expected the initial primary author to be Billie Thompson"
251        );
252        assert_eq!(
253            buffer
254                .get("mit.author.coauthors.0.name")
255                .map(String::as_str),
256            Some("Somebody Else"),
257            "Expected the first coauthor to be Somebody Else before rotation"
258        );
259
260        // Rotate: B becomes primary, A becomes coauthor
261        {
262            let mut vcs_config = InMemory::new(&mut buffer);
263            crate::mit::cmd::rotate_authors::rotate_authors(
264                &mut vcs_config,
265                crate::mit::RotationOption::RoundRobin,
266            )?;
267        }
268
269        assert_eq!(
270            buffer.get("user.name").map(String::as_str),
271            Some("Somebody Else"),
272            "Expected the primary author to be Somebody Else after first rotation"
273        );
274        assert_eq!(
275            buffer
276                .get("mit.author.coauthors.0.name")
277                .map(String::as_str),
278            Some("Billie Thompson"),
279            "Expected the first coauthor to be Billie Thompson after first rotation"
280        );
281
282        // Rotate again: back to A primary, B coauthor
283        {
284            let mut vcs_config = InMemory::new(&mut buffer);
285            crate::mit::cmd::rotate_authors::rotate_authors(
286                &mut vcs_config,
287                crate::mit::RotationOption::RoundRobin,
288            )?;
289        }
290
291        assert_eq!(
292            buffer.get("user.name").map(String::as_str),
293            Some("Billie Thompson"),
294            "Expected the primary author to be Billie Thompson after second rotation"
295        );
296        assert_eq!(
297            buffer
298                .get("mit.author.coauthors.0.name")
299                .map(String::as_str),
300            Some("Somebody Else"),
301            "Expected the first coauthor to be Somebody Else after second rotation"
302        );
303
304        Ok(())
305    }
306
307    #[test]
308    fn rotate_authors_random_produces_valid_permutation() -> Result<()> {
309        let mut buffer = BTreeMap::new();
310        {
311            let mut vcs_config = InMemory::new(&mut buffer);
312
313            let author_1 = Author::new("Billie Thompson".into(), "billie@example.com".into(), None);
314            let author_2 = Author::new("Somebody Else".into(), "someone@example.com".into(), None);
315            let author_3 = Author::new("Annie Example".into(), "annie@example.com".into(), None);
316
317            set_commit_authors(
318                &mut vcs_config,
319                &[&author_1, &author_2, &author_3],
320                Duration::from_hours(1),
321            )?;
322        }
323
324        // Collect original author set (sorted)
325        let mut original: Vec<String> = vec![buffer.get("user.name").cloned().unwrap_or_default()];
326        for i in 0..3 {
327            if let Some(name) = buffer.get(&format!("mit.author.coauthors.{i}.name")) {
328                original.push(name.clone());
329            }
330        }
331        original.sort();
332
333        // Rotate randomly
334        {
335            let mut vcs_config = InMemory::new(&mut buffer);
336            crate::mit::cmd::rotate_authors::rotate_authors(
337                &mut vcs_config,
338                crate::mit::RotationOption::Random,
339            )?;
340        }
341
342        // Collect result author set (sorted)
343        let mut result: Vec<String> = vec![buffer.get("user.name").cloned().unwrap_or_default()];
344        for i in 0..3 {
345            if let Some(name) = buffer.get(&format!("mit.author.coauthors.{i}.name")) {
346                result.push(name.clone());
347            }
348        }
349        result.sort();
350
351        // The multiset of authors must be preserved (it's a permutation)
352        assert_eq!(
353            result, original,
354            "Expected random rotation to produce a valid permutation of the original authors"
355        );
356
357        Ok(())
358    }
359
360    #[test]
361    fn rotate_authors_random_noops_with_single_author() -> Result<()> {
362        let mut buffer = BTreeMap::new();
363        {
364            let mut vcs_config = InMemory::new(&mut buffer);
365            let author = Author::new("Billie Thompson".into(), "billie@example.com".into(), None);
366            set_commit_authors(&mut vcs_config, &[&author], Duration::from_hours(1))?;
367        }
368
369        {
370            let mut vcs_config = InMemory::new(&mut buffer);
371            crate::mit::cmd::rotate_authors::rotate_authors(
372                &mut vcs_config,
373                crate::mit::RotationOption::Random,
374            )?;
375        }
376
377        assert_eq!(
378            buffer.get("user.name").map(String::as_str),
379            Some("Billie Thompson"),
380            "Expected user.name to be unchanged with a single author under random rotation"
381        );
382        assert_eq!(
383            buffer.get("user.email").map(String::as_str),
384            Some("billie@example.com"),
385            "Expected user.email to be unchanged with a single author under random rotation"
386        );
387
388        Ok(())
389    }
390
391    #[test]
392    fn rotate_authors_ignores_coauthor_with_empty_name() -> Result<()> {
393        let mut buffer = BTreeMap::new();
394        buffer.insert("user.name".into(), "Billie Thompson".into());
395        buffer.insert("user.email".into(), "billie@example.com".into());
396        // Coauthor with empty name but non-empty email — should be filtered out
397        buffer.insert("mit.author.coauthors.0.name".into(), String::new());
398        buffer.insert(
399            "mit.author.coauthors.0.email".into(),
400            "ghost@example.com".into(),
401        );
402
403        {
404            let mut vcs_config = InMemory::new(&mut buffer);
405            crate::mit::cmd::rotate_authors::rotate_authors(
406                &mut vcs_config,
407                crate::mit::RotationOption::RoundRobin,
408            )?;
409        }
410
411        // With the empty-name coauthor filtered, only the primary remains,
412        // so rotation should be a no-op.
413        assert_eq!(
414            buffer.get("user.name").map(String::as_str),
415            Some("Billie Thompson"),
416            "Expected user.name to be unchanged when the only coauthor has an empty name"
417        );
418
419        Ok(())
420    }
421}