Repository: axodotdev/cargo-dist (rust)

Issue:
Use parse_changelog::Release::title_no_link
Extending https://github.com/taiki-e/parse-changelog/issues/32.

For example, https://github.com/github-changelog-generator/github-changelog-generator generates headings in the following format:

```markdown
## [v1.0.0-beta.55](https://github.com/ignisda/ryot/tree/v1.0.0-beta.55) (2023-06-05)

[Full Changelog](https://github.com/ignisda/ryot/compare/v1.0.0-beta.54...v1.0.0-beta.55)

**Closed issues:**

- Error: Failed to read source file. [\#116](https://github.com/IgnisDa/ryot/issues/116)
```

But cargo-dist generates the announcement title as follows:
![image](https://github.com/axodotdev/cargo-dist/assets/60938164/14a5915e-d8ef-48b5-bd05-727cdc3b1f75)

I want it to be like `v1.0.0-beta.55 (2023-06-05)`.

Pull request description:
chore: update axoproject and use changelog api
fixes #262

Patch:
diff --git a/Cargo.lock b/Cargo.lock
index 49b6a653..aa598775 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -140,9 +140,9 @@ dependencies = [
 
 [[package]]
 name = "axoproject"
-version = "0.4.6"
+version = "0.4.7"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2a27738e3ae3be5c7f5dd49ac14e686a79d7b9bceac1d774dd666b527ec884aa"
+checksum = "6fff7bed7640f7b5ca3cbe96c49887f18f731ac2b8de26b7ce433ce21c78888c"
 dependencies = [
  "axoasset",
  "camino",
@@ -151,7 +151,7 @@ dependencies = [
  "guppy",
  "itertools 0.10.5",
  "miette",
- "parse-changelog 0.6.2",
+ "parse-changelog",
  "pathdiff",
  "semver",
  "serde",
@@ -302,7 +302,6 @@ dependencies = [
  "miette",
  "minijinja",
  "newline-converter",
- "parse-changelog 0.5.4",
  "semver",
  "serde",
  "serde_json",
@@ -1428,18 +1427,6 @@ version = "3.5.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "c1b04fb49957986fdce4d6ee7a65027d55d4b6d2265e5848bbb507b58ccfdb6f"
 
-[[package]]
-name = "parse-changelog"
-version = "0.5.4"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6eab4bdddacb3dd2b7d704d58c2d4755ed63b74563d3d755691741e3b764ad9b"
-dependencies = [
- "indexmap 1.9.3",
- "memchr",
- "once_cell",
- "regex",
-]
-
 [[package]]
 name = "parse-changelog"
 version = "0.6.2"
diff --git a/cargo-dist/Cargo.toml b/cargo-dist/Cargo.toml
index a1181cff..648e12c0 100644
--- a/cargo-dist/Cargo.toml
+++ b/cargo-dist/Cargo.toml
@@ -31,6 +31,10 @@ axocli = { version = "0.1.0", optional = true }
 
 # Features used by the cli and library
 cargo-dist-schema = { version = "=0.2.0", path = "../cargo-dist-schema" }
+
+axoasset = { version = "0.5.0", features = ["json-serde", "toml-serde", "toml-edit", "compression"] }
+axoproject = { version = "0.4.7", default-features = false, features = ["cargo-projects"] }
+
 miette = { version = "5.6.0" }
 thiserror = "1.0.35"
 tracing = { version = "0.1.36", features = ["log"] }
@@ -41,11 +45,8 @@ guppy = "0.15.0"
 camino = "1.1.1"
 semver = "1.0.14"
 toml_edit = "0.19.0"
-parse-changelog = { version = "0.5.3", default-features = false }
 newline-converter = "0.2.2"
-axoproject = { version = "0.4.6", default-features = false, features = ["cargo-projects"] }
 dialoguer = "0.10.4"
-axoasset = { version = "0.5.0", features = ["json-serde", "toml-serde", "toml-edit", "compression"] }
 sha2 = "0.10.6"
 minijinja = { version = "1.0.5", features = ["debug", "loader", "builtins", "json", "custom_syntax"] }
 include_dir = "0.7.3"
diff --git a/cargo-dist/src/backend/ci/github.rs b/cargo-dist/src/backend/ci/github.rs
index 05c5c627..5a8652e0 100644
--- a/cargo-dist/src/backend/ci/github.rs
+++ b/cargo-dist/src/backend/ci/github.rs
@@ -2,8 +2,6 @@
 //!
 //! In the future this may get split up into submodules.
 
-// FIXME(#283): migrate this to minijinja (steal logic from oranda to load a whole dir)
-
 use axoasset::LocalAsset;
 use cargo_dist_schema::{GithubMatrix, GithubMatrixEntry};
 use serde::Serialize;
diff --git a/cargo-dist/src/changelog.rs b/cargo-dist/src/changelog.rs
deleted file mode 100644
index 52436400..00000000
--- a/cargo-dist/src/changelog.rs
+++ /dev/null
@@ -1,161 +0,0 @@
-//! Logic for parsing changelogs
-
-use std::fs::File;
-use std::io::{BufReader, Read};
-
-use camino::Utf8Path;
-use miette::{Context, IntoDiagnostic};
-use semver::Version;
-use tracing::info;
-
-use crate::errors::Result;
-use crate::tasks::DistGraphBuilder;
-
-impl DistGraphBuilder<'_> {
-    /// Try to compute changelogs for the announcement
-    pub fn compute_announcement_changelog(&mut self, announcing_version: Option<&Version>) {
-        // FIXME: currently this only supports a top-level changelog, it would be nice
-        // to allow individual apps to have individual streams
-
-        // FIXME: derive changelogs from semantic commits?
-
-        // Try to find the version we're announcing in the top level CHANGELOG/RELEASES
-        let Some(announcing_version) = announcing_version else {
-            info!("not announcing a consistent version, skipping changelog generation");
-            return;
-        };
-
-        // Load and parse the changelog
-        let Some(changelog_path) = &self.workspace.root_auto_includes.changelog else {
-            info!("no root changelog found, skipping changelog generation");
-            return;
-        };
-        let Ok(changelog_str) = try_load_changelog(changelog_path) else {
-            info!("failed to load changelog, skipping changelog generation");
-            return;
-        };
-        let changelogs = parse_changelog::parse(&changelog_str)
-            .into_diagnostic()
-            .wrap_err_with(|| format!("failed to parse changelog at {changelog_path}"));
-        let changelogs = match changelogs {
-            Ok(changelogs) => changelogs,
-            Err(e) => {
-                info!(
-                    "failed to parse changelog, skipping changelog generation\n{:?}",
-                    e
-                );
-                return;
-            }
-        };
-
-        // Try to extract the changelog entry for the announcing version.
-        //
-        // First, try to find the exact version.
-        //
-        // If that fails, try to find this version without the prerelease suffix.
-        // Because releasing a prerelease for a version that was already published before does not
-        // make much sense it is fairly safe to assume that this entry is in fact just our WIP state
-        // of the release notes.
-        //
-        // If that fails, try to find a section called "Unreleased" and use that (if it's a prerelease).
-        let Some((title, notes)) = try_extract_changelog_exact(&changelogs, announcing_version)
-            .or_else(|| try_extract_changelog_normalized(&changelogs, announcing_version))
-            .or_else(|| try_extract_changelog_unreleased(&changelogs, announcing_version))
-        else {
-            info!(
-                "failed to find {announcing_version} in changelogs, skipping changelog generation"
-            );
-            return;
-        };
-
-        info!("successfully parsed changelog!");
-        self.inner.announcement_title = Some(title);
-        // Those windows newlines get everywhere...
-        let clean_notes = newline_converter::dos2unix(&notes);
-        self.inner.announcement_changelog = Some(clean_notes.into_owned());
-    }
-}
-
-/// Load a changelog to a string
-fn try_load_changelog(changelog_path: &Utf8Path) -> Result<String> {
-    let file = File::open(changelog_path)
-        .into_diagnostic()
-        .wrap_err_with(|| format!("failed to open changelog at {changelog_path}"))?;
-    let mut data = BufReader::new(file);
-    let mut changelog_str = String::new();
-    data.read_to_string(&mut changelog_str)
-        .into_diagnostic()
-        .wrap_err_with(|| format!("failed to read changelog at {changelog_path}"))?;
-    Ok(changelog_str)
-}
-
-/// Tries to find a changelog entry with the exact version given and returns its title and notes.
-fn try_extract_changelog_exact(
-    changelogs: &parse_changelog::Changelog,
-    version: &Version,
-) -> Option<(String, String)> {
-    let version_string = format!("{}", version);
-
-    changelogs.get(&*version_string).map(|release_notes| {
-        (
-            release_notes.title.to_string(),
-            release_notes.notes.to_string(),
-        )
-    })
-}
-
-/// Tries to find a changelog entry that matches the given version's normalized form. That is, just
-/// the `major.minor.patch` part. If successful, the entry's title is modified to include the
-/// version's prerelease part before it is returned together with the notes.
-///
-/// Noop if the given version is already normalized.
-fn try_extract_changelog_normalized(
-    changelogs: &parse_changelog::Changelog,
-    version: &Version,
-) -> Option<(String, String)> {
-    if version.pre.is_empty() {
-        return None;
-    }
-
-    let version_normalized = Version::new(version.major, version.minor, version.patch);
-    let version_normalized_string = format!("{}", version_normalized);
-
-    let release_notes = changelogs.get(&*version_normalized_string)?;
-
-    // title looks something like '<prefix><version><freeform>'
-    // prefix could be 'v' or 'Version ' for example
-    let (prefix_and_version, freeform) = release_notes.title.split_at(
-        release_notes
-            .title
-            .find(&*version_normalized_string)
-            .unwrap() // impossible that this version string is not present in the header
-            + version_normalized_string.len(),
-    );
-
-    // insert prerelease suffix into the title
-    let title = format!(
-        "{}-{} {}",
-        prefix_and_version.trim(),
-        version.pre,
-        freeform.trim()
-    );
-
-    Some((title.trim().to_string(), release_notes.notes.to_string()))
-}
-
-// Tries to find the "Unreleased" changelog heading and replaces it with "Version {version}"
-//
-// Noop if the given version isn't a prerelease.
-fn try_extract_changelog_unreleased(
-    changelogs: &parse_changelog::Changelog,
-    version: &Version,
-) -> Option<(String, String)> {
-    if version.pre.is_empty() {
-        return None;
-    }
-
-    let release_notes = changelogs.get("Unreleased")?;
-    let title = format!("Version {version}");
-
-    Some((title, release_notes.notes.to_string()))
-}
diff --git a/cargo-dist/src/lib.rs b/cargo-dist/src/lib.rs
index d7576e7c..3c0f6cbf 100644
--- a/cargo-dist/src/lib.rs
+++ b/cargo-dist/src/lib.rs
@@ -33,7 +33,6 @@ use miette::{miette, Context, IntoDiagnostic};
 pub use tasks::*;
 
 pub mod backend;
-pub mod changelog;
 pub mod config;
 pub mod errors;
 mod init;
diff --git a/cargo-dist/src/tasks.rs b/cargo-dist/src/tasks.rs
index e4345338..a4b567d1 100644
--- a/cargo-dist/src/tasks.rs
+++ b/cargo-dist/src/tasks.rs
@@ -1749,14 +1749,60 @@ impl<'pkg_graph> DistGraphBuilder<'pkg_graph> {
         builds
     }
 
-    fn compute_announcement_info(&mut self, announcing_version: Option<&Version>) {
+    fn compute_announcement_info(&mut self, announcing: &AnnouncementTag) {
         // Default to using the tag as a title
-        self.inner.announcement_title = self.inner.announcement_tag.clone();
+        self.inner.announcement_title = Some(announcing.tag.clone());
+        self.inner.announcement_tag = Some(announcing.tag.clone());
+        self.inner.announcement_is_prerelease = announcing.prerelease;
 
-        self.compute_announcement_changelog(announcing_version);
+        self.compute_announcement_changelog(announcing);
         self.compute_announcement_github();
     }
 
+    /// Try to compute changelogs for the announcement
+    pub fn compute_announcement_changelog(&mut self, announcing: &AnnouncementTag) {
+        let info = if let Some(announcing_version) = &announcing.version {
+            // Try to find the version we're announcing in the top level CHANGELOG/RELEASES
+            let version = axoproject::Version::Cargo(announcing_version.clone());
+            let Ok(Some(info)) = self.workspace.changelog_for_version(&version) else {
+                info!(
+                    "failed to find {version} in workspace changelogs, skipping changelog generation"
+                );
+                return;
+            };
+
+            info
+        } else if let Some(announcing_package) = announcing.package {
+            // Try to find the package's specific CHANGELOG/RELEASES
+            let package = self.workspace.package(announcing_package);
+            let package_name = &package.name;
+            let version = package
+                .version
+                .as_ref()
+                .expect("cargo package without a version!?");
+            let Ok(Some(info)) = self
+                .workspace
+                .package(announcing_package)
+                .changelog_for_version(version)
+            else {
+                info!(
+                    "failed to find {version} in {package_name} changelogs, skipping changelog generation"
+                );
+                return;
+            };
+
+            info
+        } else {
+            unreachable!("you're neither announcing a version or a package!?");
+        };
+
+        info!("successfully parsed changelog!");
+        self.inner.announcement_title = Some(info.title);
+        // Those windows newlines get everywhere...
+        let clean_notes = newline_converter::dos2unix(&info.body);
+        self.inner.announcement_changelog = Some(clean_notes.into_owned());
+    }
+
     /// If we're publishing to Github, generate some Github notes
     fn compute_announcement_github(&mut self) {
         use std::fmt::Write;
@@ -2021,10 +2067,8 @@ pub fn gather_work(cfg: &Config) -> Result<DistGraph> {
         cfg.needs_coherent_announcement_tag,
     )?;
 
-    graph.inner.announcement_tag = Some(announcing.tag.clone());
-    graph.inner.announcement_is_prerelease = announcing.prerelease;
     if let Some(repo_url) = workspace.web_url()?.as_ref() {
-        let tag = graph.inner.announcement_tag.as_ref().unwrap();
+        let tag = &announcing.tag;
         graph.inner.artifact_download_url = Some(format!("{repo_url}/releases/download/{tag}"));
     }
 
@@ -2089,7 +2133,7 @@ pub fn gather_work(cfg: &Config) -> Result<DistGraph> {
     }
 
     // Prep the announcement's release notes and whatnot
-    graph.compute_announcement_info(announcing.version.as_ref());
+    graph.compute_announcement_info(&announcing);
 
     // Finally compute all the build steps!
     graph.compute_build_steps();
@@ -2237,8 +2281,10 @@ fn find_tool(name: &str) -> Option<Tool> {
 pub(crate) struct AnnouncementTag {
     /// The full tag
     pub tag: String,
-    /// The version we're announcing
+    /// The version we're announcing (if doing a unified version announcement)
     pub version: Option<Version>,
+    /// The package we're announcing (if doing a single-package announcement)
+    pub package: Option<PackageIdx>,
     /// whether we're prereleasing
     pub prerelease: bool,
     /// Which packages+bins we're announcing
@@ -2446,6 +2492,7 @@ pub(crate) fn parse_tag(
         tag: announcement_tag.expect("integrity error: failed to select announcement tag"),
         prerelease: announcing_prerelease,
         version: announcing_version,
+        package: announcing_package,
         rust_releases,
     })
 }


Test patch:
diff --git a/cargo-dist/tests/snapshots/lib_manifest.snap b/cargo-dist/tests/snapshots/lib_manifest.snap
index 9e487163..0d466447 100644
--- a/cargo-dist/tests/snapshots/lib_manifest.snap
+++ b/cargo-dist/tests/snapshots/lib_manifest.snap
@@ -8,6 +8,7 @@ stdout:
   "announcement_tag": "CENSORED",
   "announcement_is_prerelease": "CENSORED"
   "announcement_title": "CENSORED"
+  "announcement_changelog": "CENSORED"
   "announcement_github_body": "CENSORED"
   "system_info": {
     "cargo_version_line": "CENSORED"
diff --git a/cargo-dist/tests/snapshots/lib_manifest_slash.snap b/cargo-dist/tests/snapshots/lib_manifest_slash.snap
index a42c0640..8ada0ac1 100644
--- a/cargo-dist/tests/snapshots/lib_manifest_slash.snap
+++ b/cargo-dist/tests/snapshots/lib_manifest_slash.snap
@@ -8,6 +8,7 @@ stdout:
   "announcement_tag": "CENSORED",
   "announcement_is_prerelease": "CENSORED"
   "announcement_title": "CENSORED"
+  "announcement_changelog": "CENSORED"
   "announcement_github_body": "CENSORED"
   "system_info": {
     "cargo_version_line": "CENSORED"