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}
19
20#[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 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 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 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 assert_eq!(changes[1].summary, "Feature without commit");
269 assert_eq!(changes[1].git, None);
270 }
271}