Skip to main content

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