ephemeris_release/adapters/
changelog.rs1use std::collections::BTreeMap;
4use std::fmt::Write as _;
5use std::path::{Path, PathBuf};
6
7use ephemeris_files::{FilesError, RepositoryPath, RepositoryRoot};
8
9use super::utf8::{self, NotUtf8};
10use crate::conventional::{Commit, CommitType};
11use crate::edits::{EditError, PlannedEdits};
12use crate::version::Version;
13
14#[derive(Debug, thiserror::Error)]
16pub(crate) enum ReadError {
17 #[error(transparent)]
19 Files(#[from] FilesError),
20 #[error(transparent)]
22 NotUtf8(#[from] NotUtf8),
23}
24
25#[derive(Clone, Debug, Eq, PartialEq, thiserror::Error)]
27#[error("changelog {} has no Unreleased heading", path.display())]
28pub(crate) struct MissingUnreleased {
29 pub path: PathBuf,
31}
32
33#[derive(Clone, Debug, Default, Eq, PartialEq)]
35pub(crate) struct Entry {
36 pub(crate) commits: Vec<Commit>,
38 pub(crate) dependencies: BTreeMap<String, Version>,
40}
41
42#[derive(Clone, Copy, Debug, Eq, PartialEq)]
44enum Group {
45 Added,
46 Changed,
47 Fixed,
48 Other,
49}
50
51impl Group {
52 const ALL: [Self; 4] = [Self::Added, Self::Changed, Self::Fixed, Self::Other];
53
54 const fn of(commit: &Commit) -> Self {
55 match commit.kind {
56 Some(CommitType::Feature) => Self::Added,
57 Some(CommitType::Fix) => Self::Fixed,
58 Some(CommitType::Other) | None => Self::Other,
59 }
60 }
61
62 const fn heading(self) -> &'static str {
63 match self {
64 Self::Added => "Added",
65 Self::Changed => "Changed",
66 Self::Fixed => "Fixed",
67 Self::Other => "Other",
68 }
69 }
70}
71
72#[derive(Clone, Debug, Eq, PartialEq)]
75pub(crate) struct ChangelogEdit {
76 path: RepositoryPath,
77 before: Option<Vec<u8>>,
78 contents: String,
79}
80
81impl ChangelogEdit {
82 pub(crate) fn read(root: &RepositoryRoot, path: &RepositoryPath) -> Result<Self, ReadError> {
85 let before = root.read(path)?;
86 let contents = before
87 .as_deref()
88 .map(|bytes| utf8::decode(path.as_path(), bytes))
89 .transpose()?
90 .unwrap_or("# Changelog\n\n## [Unreleased]\n")
91 .to_owned();
92 Ok(Self {
93 path: path.clone(),
94 before,
95 contents,
96 })
97 }
98
99 pub(crate) fn render(
105 &self,
106 version: &str,
107 date: &str,
108 entry: &Entry,
109 ) -> Result<Self, MissingUnreleased> {
110 Ok(Self {
111 contents: render(self.path.as_path(), &self.contents, version, date, entry)?,
112 ..self.clone()
113 })
114 }
115
116 pub(crate) fn contribute(&self, edits: &mut PlannedEdits) -> Result<(), EditError> {
121 edits.add(
122 self.path.clone(),
123 self.before.clone(),
124 Some(self.contents.as_bytes().to_vec()),
125 )
126 }
127}
128
129fn render(
136 path: &Path,
137 contents: &str,
138 version: &str,
139 date: &str,
140 entry: &Entry,
141) -> Result<String, MissingUnreleased> {
142 let marker = heading("Unreleased");
143 let Some((before, after)) = contents.split_once(&marker) else {
144 return Err(MissingUnreleased {
145 path: path.to_path_buf(),
146 });
147 };
148 if section(contents, version).is_some() {
149 return Ok(contents.to_owned());
150 }
151 let release = heading(version);
152 let (unreleased, older) = after
153 .find("\n## ")
154 .map_or((after, ""), |index| after.split_at(index.saturating_add(1)));
155 let mut rendered = format!("{before}{marker}\n\n{release} - {date}\n");
156 let unreleased = unreleased.trim();
157 if !unreleased.is_empty() {
158 _ = write!(rendered, "\n{unreleased}\n");
159 }
160 for group in Group::ALL {
161 let mut lines: Vec<String> = entry
162 .commits
163 .iter()
164 .filter(|commit| Group::of(commit) == group)
165 .map(|commit| {
166 let breaking = if commit.breaking { "[breaking] " } else { "" };
167 format!("{breaking}{}", commit.description)
168 })
169 .collect();
170 if group == Group::Changed {
171 lines.extend(
172 entry
173 .dependencies
174 .iter()
175 .map(|(name, proposed)| format!("Update {name} to {proposed}")),
176 );
177 }
178 if lines.is_empty() {
179 continue;
180 }
181 _ = write!(rendered, "\n### {}\n\n", group.heading());
182 for line in lines {
183 _ = writeln!(rendered, "- {line}");
184 }
185 }
186 if !older.is_empty() {
187 _ = write!(rendered, "\n{older}");
188 }
189 Ok(rendered)
190}
191
192pub(crate) fn section(contents: &str, version: &str) -> Option<String> {
195 let release = heading(version);
196 let mut lines = contents.lines().skip_while(|line| {
197 line.strip_prefix(&release)
198 .is_none_or(|rest| !(rest.is_empty() || rest.starts_with(' ')))
199 });
200 lines.next()?;
201 let body: Vec<_> = lines.take_while(|line| !line.starts_with("## ")).collect();
202 Some(body.join("\n").trim().to_owned())
203}
204
205fn heading(version: &str) -> String {
206 format!("## [{version}]")
207}
208
209#[cfg(test)]
210mod tests {
211 use std::collections::BTreeMap;
212 use std::fs;
213 use std::path::Path;
214
215 use ephemeris_files::RepositoryRoot;
216
217 use super::{ChangelogEdit, Entry, render, section};
218 use crate::commit_subject::CommitSubject;
219 use crate::conventional::Commit;
220 use crate::version::Version;
221
222 #[test]
223 fn the_section_of_a_version_is_the_body_render_wrote() {
224 let contents = render(
225 Path::new("CHANGELOG.md"),
226 "# Changelog\n\n## [Unreleased]\n\n## [1.0.0] - 2026-01-01\n\n- First.\n",
227 "1.1.0",
228 "2026-10-21",
229 &Entry::default(),
230 )
231 .unwrap();
232 assert_eq!(section(&contents, "1.1.0").as_deref(), Some(""));
233 assert_eq!(section(&contents, "1.0.0").as_deref(), Some("- First."));
234 assert_eq!(section(&contents, "1.0").as_deref(), None);
235 assert_eq!(section(&contents, "2.0.0"), None);
236 }
237
238 #[test]
239 fn rendering_a_version_whose_section_exists_changes_nothing() {
240 let contents =
241 "# Changelog\n\n## [Unreleased]\n\n- Later.\n\n## [1.1.0] - 2026-10-21\n\n- Done.\n";
242 let rendered = render(
243 Path::new("CHANGELOG.md"),
244 contents,
245 "1.1.0",
246 "2026-10-29",
247 &Entry::default(),
248 )
249 .unwrap();
250 assert_eq!(rendered, contents);
251 }
252
253 #[test]
254 fn commits_follow_the_unreleased_text_in_groups() {
255 let commits = Commit::from_messages(
256 [
257 "docs: describe the flag",
258 "feat(cli): add the flag",
259 "Change the build",
260 "fix: keep the order",
261 "feat!: rename the client",
262 ],
263 &CommitSubject::try_from("release: {unit} {version}").unwrap(),
264 );
265 let previous = "## [1.0.0] - 2026-01-01\n\n- First.\n";
266 let rendered = render(
267 Path::new("CHANGELOG.md"),
268 &format!("# Changelog\n\n## [Unreleased]\n\n- Maintainer note.\n\n{previous}"),
269 "2.0.0",
270 "2026-10-23",
271 &Entry {
272 commits: commits.clone(),
273 ..Entry::default()
274 },
275 )
276 .unwrap();
277 assert_eq!(
278 rendered,
279 format!(
280 "# Changelog\n\n## [Unreleased]\n\n## [2.0.0] - 2026-10-23\n\n- Maintainer \
281 note.\n\n### Added\n\n- add the flag\n- [breaking] rename the client\n\n### \
282 Fixed\n\n- keep the order\n\n### Other\n\n- describe the flag\n- Change the \
283 build\n\n{previous}"
284 )
285 );
286 assert_eq!(
287 section(&rendered, "2.0.0").as_deref(),
288 Some(
289 "- Maintainer note.\n\n### Added\n\n- add the flag\n- [breaking] rename the \
290 client\n\n### Fixed\n\n- keep the order\n\n### Other\n\n- describe the flag\n- \
291 Change the build"
292 )
293 );
294 assert_eq!(
295 render(
296 Path::new("CHANGELOG.md"),
297 "## [Unreleased]\n",
298 "0.1.1",
299 "2026-10-23",
300 &Entry {
301 commits: commits[2..4].to_vec(),
302 ..Entry::default()
303 }
304 )
305 .unwrap(),
306 "## [Unreleased]\n\n## [0.1.1] - 2026-10-23\n\n### Fixed\n\n- keep the order\n\n### \
307 Other\n\n- Change the build\n"
308 );
309 }
310
311 #[test]
312 fn an_entry_without_content_is_the_version_heading_alone() {
313 assert_eq!(
314 render(
315 Path::new("CHANGELOG.md"),
316 "## [Unreleased]\n",
317 "0.1.1",
318 "2026-10-23",
319 &Entry::default()
320 )
321 .unwrap(),
322 "## [Unreleased]\n\n## [0.1.1] - 2026-10-23\n"
323 );
324 }
325
326 #[test]
327 fn dependency_updates_are_listed_under_changed_after_added() {
328 let entry = Entry {
329 commits: Commit::from_messages(
330 ["feat: add the flag", "fix: keep the order"],
331 &CommitSubject::try_from("release: {unit} {version}").unwrap(),
332 ),
333 dependencies: BTreeMap::from([
334 ("core".to_owned(), Version::parse("2.0.0").unwrap()),
335 ("util".to_owned(), Version::parse("1.2.3").unwrap()),
336 ]),
337 };
338 assert_eq!(
339 render(
340 Path::new("CHANGELOG.md"),
341 "## [Unreleased]\n",
342 "1.0.1",
343 "2026-10-23",
344 &entry
345 )
346 .unwrap(),
347 "## [Unreleased]\n\n## [1.0.1] - 2026-10-23\n\n### Added\n\n- add the flag\n\n### \
348 Changed\n\n- Update core to 2.0.0\n- Update util to 1.2.3\n\n### Fixed\n\n- keep the \
349 order\n"
350 );
351 }
352
353 #[test]
354 fn a_changelog_edit_keeps_the_unreleased_text_and_reads_an_absent_file_as_empty() {
355 let directory = tempfile::tempdir().unwrap();
356 fs::write(
357 directory.path().join("CHANGELOG.md"),
358 "# Changelog\n\n## [Unreleased]\n\n- maintainer note\n",
359 )
360 .unwrap();
361 let root = RepositoryRoot::new(directory.path()).unwrap();
362 let rendered = ChangelogEdit::read(&root, &"CHANGELOG.md".parse().unwrap())
363 .unwrap()
364 .render("1.1.0", "2026-09-23", &Entry::default())
365 .unwrap();
366 assert!(rendered.contents.contains("maintainer note"));
367 assert!(rendered.contents.contains("## [1.1.0] - 2026-09-23"));
368 let absent = ChangelogEdit::read(&root, &"absent/CHANGELOG.md".parse().unwrap()).unwrap();
369 assert_eq!(absent.before, None);
370 assert!(absent.contents.contains("## [Unreleased]"));
371 }
372}