Skip to main content

git_disjoint/
issue_group_map.rs

1use std::{
2    collections::HashSet,
3    error::Error,
4    fmt::Display,
5    io::{self, Write},
6};
7
8use git2::Commit;
9use indexmap::IndexMap;
10
11use crate::{
12    cli::{
13        CommitGrouping, CommitsToConsider, OverlayCommitsIntoOnePullRequest,
14        PromptUserToChooseCommits,
15    },
16    interact::{prompt_user, IssueGroupWhitelist, SelectIssuesError},
17    issue::Issue,
18    issue_group::{self, GitCommitSummary, IssueGroup},
19};
20
21#[derive(Debug, Default)]
22pub struct IssueGroupMap<'repo>(IndexMap<IssueGroup, Vec<Commit<'repo>>>);
23
24impl<'repo> IntoIterator for IssueGroupMap<'repo> {
25    type Item = (IssueGroup, Vec<Commit<'repo>>);
26
27    type IntoIter = indexmap::map::IntoIter<IssueGroup, Vec<Commit<'repo>>>;
28
29    fn into_iter(self) -> Self::IntoIter {
30        self.0.into_iter()
31    }
32}
33
34impl<'repo> FromIterator<(IssueGroup, Vec<Commit<'repo>>)> for IssueGroupMap<'repo> {
35    fn from_iter<T: IntoIterator<Item = (IssueGroup, Vec<Commit<'repo>>)>>(iter: T) -> Self {
36        Self(iter.into_iter().collect())
37    }
38}
39
40#[derive(Debug)]
41#[non_exhaustive]
42pub struct FromCommitsError {
43    kind: FromCommitsErrorKind,
44}
45
46impl Display for FromCommitsError {
47    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
48        match &self.kind {
49            FromCommitsErrorKind::FromCommit(_) => write!(f, "unable to get commit summary"),
50            FromCommitsErrorKind::IO(_) => write!(f, "unable to write to stream"),
51        }
52    }
53}
54
55impl Error for FromCommitsError {
56    fn source(&self) -> Option<&(dyn Error + 'static)> {
57        match &self.kind {
58            FromCommitsErrorKind::FromCommit(err) => Some(err),
59            FromCommitsErrorKind::IO(err) => Some(err),
60        }
61    }
62}
63
64#[derive(Debug)]
65pub enum FromCommitsErrorKind {
66    #[non_exhaustive]
67    FromCommit(issue_group::FromCommitError),
68    #[non_exhaustive]
69    IO(io::Error),
70}
71
72impl From<issue_group::FromCommitError> for FromCommitsError {
73    fn from(err: issue_group::FromCommitError) -> Self {
74        Self {
75            kind: FromCommitsErrorKind::FromCommit(err),
76        }
77    }
78}
79
80impl From<io::Error> for FromCommitsError {
81    fn from(err: io::Error) -> Self {
82        Self {
83            kind: FromCommitsErrorKind::IO(err),
84        }
85    }
86}
87
88impl<'repo> IssueGroupMap<'repo> {
89    fn with_capacity(n: usize) -> Self {
90        Self(IndexMap::with_capacity(n))
91    }
92
93    fn insert(&mut self, key: IssueGroup, value: Vec<Commit<'repo>>) {
94        self.0.insert(key, value);
95    }
96
97    pub fn try_from_commits<I>(
98        commits: I,
99        commits_to_consider: CommitsToConsider,
100        commit_grouping: CommitGrouping,
101    ) -> Result<Self, FromCommitsError>
102    where
103        I: IntoIterator<Item = Commit<'repo>>,
104    {
105        let mut suffix: u32 = 0;
106        let mut seen_issue_groups = HashSet::new();
107        let commits_by_issue: IndexMap<IssueGroup, Vec<Commit>> = commits
108            .into_iter()
109            // Parse issue from commit message
110            .map(
111                |commit| -> Result<Option<(IssueGroup, Commit)>, FromCommitsError> {
112                    let issue = commit.message().and_then(Issue::parse_from_commit_message);
113                    // If:
114                    // - we're grouping commits by issue, and
115                    // - this commit includes an issue,
116                    // then add this commit to that issue's group.
117                    if commit_grouping == CommitGrouping::ByIssue {
118                        if let Some(issue) = issue {
119                            return Ok(Some((issue.into(), commit)));
120                        }
121                    }
122
123                    // If:
124                    // - we're treating every commit separately, or
125                    // - we're considering all commits (even commits without an issue),
126                    // add this commit to a unique issue-group.
127                    if commit_grouping == CommitGrouping::Individual
128                        || commits_to_consider == CommitsToConsider::All
129                    {
130                        let summary: GitCommitSummary = (&commit).try_into()?;
131                        let mut proposed_issue_group = summary.clone();
132
133                        // Use unique issue group names so each commit is
134                        // addressable in the selection menu.
135                        // DISCUSS: would it be better to use an array?
136                        // No, because there's so much ambiguity. Should we expose the
137                        // commit hash? Probably
138                        while seen_issue_groups.contains(&proposed_issue_group) {
139                            suffix += 1;
140                            proposed_issue_group = GitCommitSummary(format!("{summary}_{suffix}"));
141                        }
142
143                        seen_issue_groups.insert(proposed_issue_group.clone());
144
145                        return Ok(Some((IssueGroup::Commit(proposed_issue_group), commit)));
146                    }
147
148                    // Otherwise, skip this commit.
149                    writeln!(
150                        io::stderr(),
151                        "Warning: ignoring commit without issue trailer: {:?}",
152                        commit.id()
153                    )?;
154                    Ok(None)
155                },
156            )
157            .filter_map(Result::transpose)
158            .try_fold(
159                Default::default(),
160                |mut map,
161                 maybe_tuple|
162                 -> Result<IndexMap<IssueGroup, Vec<Commit>>, FromCommitsError> {
163                    let (issue, commit) = maybe_tuple?;
164                    let commits = map.entry(issue).or_default();
165                    commits.push(commit);
166                    Ok(map)
167                },
168            )?;
169
170        Ok(Self(commits_by_issue))
171    }
172
173    pub fn select_issues(
174        self,
175        choose: PromptUserToChooseCommits,
176        overlay: OverlayCommitsIntoOnePullRequest,
177    ) -> Result<Self, SelectIssuesError> {
178        let selected_issue_groups: IssueGroupWhitelist = {
179            if choose == PromptUserToChooseCommits::No
180                && overlay == OverlayCommitsIntoOnePullRequest::No
181            {
182                IssueGroupWhitelist::WhitelistDNE
183            } else {
184                let keys = self.0.keys();
185                IssueGroupWhitelist::Whitelist(prompt_user(keys)?)
186            }
187        };
188
189        Ok(match &selected_issue_groups {
190            // If there is a whitelist, only operate on issue_groups in the whitelist
191            IssueGroupWhitelist::Whitelist(whitelist) => self
192                .into_iter()
193                .filter(|(issue_group, _commits)| whitelist.contains(issue_group))
194                .collect(),
195            // If there is no whitelist, then operate on every issue
196            IssueGroupWhitelist::WhitelistDNE => self,
197        })
198    }
199
200    pub fn apply_overlay(self, overlay: OverlayCommitsIntoOnePullRequest) -> Self {
201        match overlay {
202            // If we are overlaying all active issue groups into one PR,
203            // combine all active commits under the first issue group
204            OverlayCommitsIntoOnePullRequest::Yes => self
205                .into_iter()
206                .reduce(|mut accumulator, mut item| {
207                    accumulator.1.append(&mut item.1);
208                    accumulator
209                })
210                // Map the option back into an IndexMap
211                .map(|(issue_group, commits)| {
212                    let mut map = Self::with_capacity(1);
213                    map.insert(issue_group, commits);
214                    map
215                })
216                .unwrap_or_default(),
217            // If we are not overlaying issue groups, keep them separate
218            OverlayCommitsIntoOnePullRequest::No => self,
219        }
220    }
221}