knope_versioning/changes/
mod.rs1use 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#[derive(Clone, Debug, PartialEq, Eq)]
15pub struct GitInfo {
16 pub hash: String,
17 pub author_name: String,
18 pub pr_number: Option<u64>,
20 pub pr_author_login: Option<String>,
22}
23
24#[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 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 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 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 assert_eq!(changes[1].summary, "Feature without commit");
275 assert_eq!(changes[1].git, None);
276 }
277}