xtask/cmd/change_logs/
generate.rs1use std::collections::{HashMap, HashSet};
2
3use anyhow::Context;
4use cargo_metadata::camino::Utf8Path;
5
6use super::util::{Fragment, PackageChangeLog};
7use crate::cmd::IGNORED_PACKAGES;
8
9#[derive(Debug, Clone, clap::Parser)]
10pub struct Generate {
11 #[clap(long, short, value_delimiter = ',')]
12 #[clap(alias = "package")]
13 packages: Vec<String>,
15 #[clap(long, short, value_delimiter = ',')]
16 #[clap(alias = "exclude-package")]
17 exclude_packages: Vec<String>,
19}
20
21const CHANGE_LOG_HEADER: &str = "# Changelog
22
23<!--
24This file is automatically generated by our release process.
25DO NOT edit it directly.
26If you want to add a change log entry for this package,
27please create a new file in /changes.d/<pr-number>.toml
28Refer to the [README.md](/changes.d/README.md) for more information.
29-->
30
31All notable changes to this project will be documented in this file.
32
33The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
34and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
35
36## [Unreleased]
37";
38
39fn update_change_log(logs: &[PackageChangeLog], manifest_path: &Utf8Path) -> anyhow::Result<()> {
40 let change_log_path_md = manifest_path.with_file_name("CHANGELOG.md");
41
42 let mut change_log = if !change_log_path_md.exists() {
43 CHANGE_LOG_HEADER.to_string()
44 } else {
45 std::fs::read_to_string(&change_log_path_md).context("failed to read CHANGELOG.md")?
46 };
47
48 let mut breaking_changes = logs.iter().filter(|log| log.breaking).collect::<Vec<_>>();
51 breaking_changes.sort_by_key(|log| &log.category);
52 let mut other_changes = logs.iter().filter(|log| !log.breaking).collect::<Vec<_>>();
53 other_changes.sort_by_key(|log| &log.category);
54
55 fn make_logs(logs: &[&PackageChangeLog]) -> String {
56 logs.iter()
57 .map(
58 |entry| {
59 format!(
60 "- {category}: {description} ([#{pr_number}](https://github.com/scufflecloud/scuffle/pull/{pr_number})){authors}",
61 category = entry.category,
62 description = entry.description,
63 pr_number = entry.pr_number,
64 authors = if !entry.authors.is_empty() {
65 format!(" ({})", entry.authors.join(", "))
66 } else {
67 String::new()
68 },
69 )
70 }
71 )
72 .collect::<Vec<_>>()
73 .join("\n")
74 }
75
76 let breaking_changes = make_logs(&breaking_changes);
77 let other_changes = make_logs(&other_changes);
78
79 let mut replaced = String::new();
80
81 replaced.push_str("## [Unreleased]\n");
82 if !breaking_changes.is_empty() {
83 replaced.push_str("\n### ⚠️ Breaking changes\n\n");
84 replaced.push_str(&breaking_changes);
85 replaced.push('\n');
86 }
87
88 if !other_changes.is_empty() {
89 replaced.push_str("\n### 🛠️ Non-breaking changes\n\n");
90 replaced.push_str(&other_changes);
91 replaced.push('\n');
92 }
93
94 change_log = change_log.replace("## [Unreleased]\n", &replaced);
95
96 std::fs::write(&change_log_path_md, change_log).context("failed to write CHANGELOG.md")?;
97
98 Ok(())
99}
100
101fn generate_change_logs(
102 package: &str,
103 change_fragments: &mut HashMap<u64, Fragment>,
104) -> anyhow::Result<Vec<PackageChangeLog>> {
105 let mut logs = Vec::new();
106
107 for fragment in change_fragments.values_mut() {
108 logs.extend(fragment.remove_package(package).context("parse")?);
109 }
110
111 Ok(logs)
112}
113
114fn save_change_fragments(fragments: &mut HashMap<u64, Fragment>) -> anyhow::Result<()> {
115 fragments
116 .values_mut()
117 .filter(|fragment| fragment.changed)
118 .try_for_each(|fragment| fragment.save().context("save"))?;
119
120 fragments.retain(|_, fragment| !fragment.deleted);
121
122 Ok(())
123}
124
125impl Generate {
126 pub fn run(self) -> anyhow::Result<()> {
127 let start = std::time::Instant::now();
128
129 let metadata = crate::utils::metadata()?;
130
131 let workspace_package_ids = metadata.workspace_members.iter().cloned().collect::<HashSet<_>>();
132
133 let path = metadata.workspace_root.join("changes.d");
134
135 eprintln!("reading {path}");
136
137 let mut change_fragments = std::fs::read_dir(&path)?
138 .filter_map(|entry| entry.ok())
139 .filter_map(|entry| {
140 let path = entry.path();
141 if path.is_file() {
142 let pr_number = path
143 .file_name()?
144 .to_str()?
145 .strip_prefix("pr-")?
146 .strip_suffix(".toml")?
147 .parse()
148 .ok()?;
149
150 Some((pr_number, path))
151 } else {
152 None
153 }
154 })
155 .try_fold(HashMap::new(), |mut fragments, (pr_number, path)| {
156 let fragment = Fragment::new(pr_number, &path).with_context(|| path.display().to_string())?;
157
158 fragments.insert(pr_number, fragment);
159
160 anyhow::Ok(fragments)
161 })?;
162
163 let packages = metadata
164 .packages
165 .iter()
166 .filter(|p| workspace_package_ids.contains(&p.id) && !IGNORED_PACKAGES.contains(&p.name.as_str()))
167 .filter(|p| self.packages.is_empty() || self.packages.contains(&p.name))
168 .filter(|p| self.exclude_packages.is_empty() || !self.exclude_packages.contains(&p.name))
169 .collect::<Vec<_>>();
170
171 for package in &self.packages {
172 anyhow::ensure!(packages.iter().any(|p| p.name == *package), "Package {} not found", package);
173 }
174
175 for package in packages {
176 let change_logs = generate_change_logs(package.name.as_str(), &mut change_fragments).context("generate")?;
177 if !change_logs.is_empty() {
178 update_change_log(&change_logs, &package.manifest_path).context("update")?;
179 save_change_fragments(&mut change_fragments).context("save")?;
180 eprintln!("Updated change logs for {}", package.name);
181 }
182 }
183
184 eprintln!("Done in {:?}", start.elapsed());
185
186 Ok(())
187 }
188}