Skip to main content

ephemeris_release/adapters/
changelog.rs

1//! Changelog transformation shared by the ecosystem adapters.
2
3use 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/// A changelog cannot be read as text.
15#[derive(Debug, thiserror::Error)]
16pub(crate) enum ReadError {
17    /// The file cannot be read.
18    #[error(transparent)]
19    Files(#[from] FilesError),
20    /// The file is not UTF-8 text.
21    #[error(transparent)]
22    NotUtf8(#[from] NotUtf8),
23}
24
25/// Changelog has no supported `Unreleased` heading.
26#[derive(Clone, Debug, Eq, PartialEq, thiserror::Error)]
27#[error("changelog {} has no Unreleased heading", path.display())]
28pub(crate) struct MissingUnreleased {
29    /// Path of the invalid changelog.
30    pub path: PathBuf,
31}
32
33/// What a unit's changelog entry reports.
34#[derive(Clone, Debug, Default, Eq, PartialEq)]
35pub(crate) struct Entry {
36    /// The commits that change the unit.
37    pub(crate) commits: Vec<Commit>,
38    /// The direct dependencies released in the same proposal.
39    pub(crate) dependencies: BTreeMap<String, Version>,
40}
41
42/// A changelog heading that entries are grouped under.
43#[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/// A unit changelog read from the repository, with the edit that adds a
73/// release entry to it.
74#[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    /// Read the changelog at `path`. An absent file reads as an empty
83    /// changelog.
84    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    /// This changelog with the entry for `version` above its Unreleased
100    /// content.
101    ///
102    /// # Errors
103    /// Returns an error when the changelog has no `Unreleased` heading.
104    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    /// Add this changelog as an edit.
117    ///
118    /// # Errors
119    /// Returns an error when the edit conflicts with another one.
120    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
129/// Render a release entry above the current Unreleased content, which moves
130/// under the new version heading. The commits of `entry` follow that content,
131/// grouped by type. Its dependency updates are listed under `Changed`.
132///
133/// # Errors
134/// Returns an error when the changelog has no `Unreleased` heading.
135fn 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
192/// The body of the section [`render`] wrote for `version`, or `None` when
193/// the changelog has no such section.
194pub(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}