knope_versioning/changes/
mod.rs

1use std::{cmp::Ordering, fmt::Display, sync::Arc};
2
3use changesets::PackageChange;
4use git_conventional::FooterToken;
5use itertools::Itertools;
6
7use crate::release_notes::{CommitFooter, CustomChangeType, SectionSource};
8
9pub mod conventional_commit;
10
11pub const CHANGESET_DIR: &str = ".changeset";
12
13/// Git commit information including hash and author.
14#[derive(Clone, Debug, PartialEq, Eq)]
15pub struct GitInfo {
16    pub hash: String,
17    pub author_name: String,
18}
19
20/// A change to one or more packages.
21#[derive(Clone, Debug, Eq, PartialEq)]
22pub struct Change {
23    pub change_type: ChangeType,
24    pub summary: String,
25    pub details: Option<String>,
26    pub original_source: ChangeSource,
27    pub git: Option<GitInfo>,
28}
29
30impl Change {
31    /// Convert [`PackageChange`] into [`Change`], optionally including info from the commit that
32    /// added the change files.
33    pub(crate) fn from_changeset<'a>(
34        changes: impl IntoIterator<Item = (&'a PackageChange, Option<GitInfo>)>,
35    ) -> impl Iterator<Item = Self> {
36        changes.into_iter().map(|(package_change, git_info)| {
37            Self::from_package_change_and_commit(package_change, git_info)
38        })
39    }
40
41    /// Create a single change from a package change with explicit commit information.
42    fn from_package_change_and_commit(
43        package_change: &PackageChange,
44        git: Option<GitInfo>,
45    ) -> Self {
46        let mut lines = package_change
47            .summary
48            .trim()
49            .lines()
50            .skip_while(|it| it.is_empty());
51        let summary: String = lines
52            .next()
53            .unwrap_or_default()
54            .chars()
55            .skip_while(|it| *it == '#' || *it == ' ')
56            .collect();
57        let details: String = lines.skip_while(|it| it.is_empty()).join("\n");
58
59        Self {
60            change_type: ChangeType::from(&package_change.change_type),
61            summary,
62            details: (!details.is_empty()).then_some(details),
63            original_source: ChangeSource::ChangeFile {
64                id: package_change.unique_id.clone(),
65            },
66            git,
67        }
68    }
69}
70
71impl Ord for Change {
72    fn cmp(&self, other: &Self) -> Ordering {
73        match (self.details.is_some(), other.details.is_some()) {
74            (false, true) => Ordering::Less,
75            (true, false) => Ordering::Greater,
76            _ => Ordering::Equal,
77        }
78    }
79}
80
81impl PartialOrd for Change {
82    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
83        Some(self.cmp(other))
84    }
85}
86
87#[derive(Clone, Debug, Hash, Eq, PartialEq)]
88pub enum ChangeType {
89    Breaking,
90    Feature,
91    Fix,
92    Custom(SectionSource),
93}
94
95impl ChangeType {
96    #[must_use]
97    pub fn to_changeset_type(&self) -> Option<changesets::ChangeType> {
98        match self {
99            Self::Breaking => Some(changesets::ChangeType::Major),
100            Self::Feature => Some(changesets::ChangeType::Minor),
101            Self::Fix => Some(changesets::ChangeType::Patch),
102            Self::Custom(SectionSource::CustomChangeType(custom)) => {
103                Some(changesets::ChangeType::Custom(custom.to_string()))
104            }
105            Self::Custom(SectionSource::CommitFooter(_)) => None,
106        }
107    }
108}
109
110impl From<&ChangeType> for changesets::ChangeType {
111    fn from(value: &ChangeType) -> Self {
112        match value {
113            ChangeType::Breaking => Self::Major,
114            ChangeType::Feature => Self::Minor,
115            ChangeType::Fix => Self::Patch,
116            ChangeType::Custom(custom) => Self::Custom(custom.to_string()),
117        }
118    }
119}
120
121impl From<&changesets::ChangeType> for ChangeType {
122    fn from(value: &changesets::ChangeType) -> Self {
123        match value {
124            changesets::ChangeType::Major => Self::Breaking,
125            changesets::ChangeType::Minor => Self::Feature,
126            changesets::ChangeType::Patch => Self::Fix,
127            changesets::ChangeType::Custom(custom) => {
128                Self::Custom(SectionSource::CustomChangeType(custom.clone().into()))
129            }
130        }
131    }
132}
133
134impl From<CustomChangeType> for ChangeType {
135    fn from(custom: CustomChangeType) -> Self {
136        changesets::ChangeType::from(String::from(custom)).into()
137    }
138}
139
140impl From<changesets::ChangeType> for ChangeType {
141    fn from(change_type: changesets::ChangeType) -> Self {
142        match change_type {
143            changesets::ChangeType::Major => Self::Breaking,
144            changesets::ChangeType::Minor => Self::Feature,
145            changesets::ChangeType::Patch => Self::Fix,
146            changesets::ChangeType::Custom(custom) => {
147                Self::Custom(SectionSource::CustomChangeType(custom.into()))
148            }
149        }
150    }
151}
152
153impl From<CommitFooter> for ChangeType {
154    fn from(footer: CommitFooter) -> Self {
155        Self::Custom(SectionSource::CommitFooter(footer))
156    }
157}
158
159impl From<FooterToken<'_>> for ChangeType {
160    fn from(footer: FooterToken) -> Self {
161        if footer.breaking() {
162            Self::Breaking
163        } else {
164            Self::Custom(SectionSource::CommitFooter(CommitFooter::from(footer)))
165        }
166    }
167}
168
169#[derive(Clone, Debug, Eq, PartialEq)]
170pub enum ChangeSource {
171    ConventionalCommit { description: String },
172    ChangeFile { id: Arc<changesets::UniqueId> },
173}
174
175impl Display for ChangeSource {
176    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
177        match self {
178            Self::ConventionalCommit {
179                description: message,
180                ..
181            } => write!(f, "commit {message}"),
182            Self::ChangeFile { id, .. } => write!(f, "changeset {}", id.to_file_name()),
183        }
184    }
185}
186
187#[cfg(test)]
188mod test_parse_changes {
189    use changesets::{PackageChange, UniqueId};
190    use pretty_assertions::assert_eq;
191
192    use super::*;
193    use crate::changes::{ChangeSource, ChangeType};
194
195    #[test]
196    fn simple_changeset() {
197        let package_change = PackageChange {
198            unique_id: Arc::new(UniqueId::exact("1234")),
199            change_type: changesets::ChangeType::Minor,
200            summary: "# a feature\n\n\n\n".into(),
201        };
202        let change = Change::from_package_change_and_commit(&package_change, None);
203        assert_eq!(change.summary, "a feature");
204        assert!(change.details.is_none());
205        assert_eq!(
206            change.original_source,
207            ChangeSource::ChangeFile {
208                id: package_change.unique_id,
209            }
210        );
211        assert_eq!(change.change_type, ChangeType::Feature);
212    }
213
214    #[test]
215    fn complex_changeset() {
216        let package_change = PackageChange {
217            unique_id: Arc::new(UniqueId::exact("1234")),
218            change_type: changesets::ChangeType::Minor,
219            summary: "# a feature\n\nwith details\n\n- first\n- second".into(),
220        };
221        let change = Change::from_package_change_and_commit(&package_change, None);
222        assert_eq!(change.summary, "a feature");
223        assert_eq!(change.details.unwrap(), "with details\n\n- first\n- second");
224        assert_eq!(
225            change.original_source,
226            ChangeSource::ChangeFile {
227                id: package_change.unique_id,
228            }
229        );
230        assert_eq!(change.change_type, ChangeType::Feature);
231    }
232
233    #[test]
234    #[expect(clippy::indexing_slicing)]
235    fn from_package_changes_with_commits() {
236        let changes_with_commits = [
237            (
238                &PackageChange {
239                    unique_id: Arc::new(UniqueId::exact("committed-change")),
240                    change_type: changesets::ChangeType::Major,
241                    summary: "# Breaking change".into(),
242                },
243                Some(GitInfo {
244                    author_name: "Bob".to_string(),
245                    hash: "def456".to_string(),
246                }),
247            ),
248            (
249                &PackageChange {
250                    unique_id: Arc::new(UniqueId::exact("uncommitted-change")),
251                    change_type: changesets::ChangeType::Minor,
252                    summary: "# Feature without commit".into(),
253                },
254                None,
255            ),
256        ];
257
258        let changes: Vec<Change> = Change::from_changeset(changes_with_commits).collect();
259
260        assert_eq!(changes.len(), 2);
261
262        // First change has commit info
263        assert_eq!(changes[0].summary, "Breaking change");
264        assert_eq!(changes[0].git.as_ref().unwrap().author_name, "Bob");
265        assert_eq!(changes[0].git.as_ref().unwrap().hash, "def456");
266
267        // Second change has no commit info
268        assert_eq!(changes[1].summary, "Feature without commit");
269        assert_eq!(changes[1].git, None);
270    }
271}