1use crate::changeset::{BumpType, Changeset};
7use crate::config::ChangelogConfig;
8use crate::error::{Error, Result};
9use crate::version::Version;
10use chrono::{DateTime, Utc};
11use std::collections::HashMap;
12use std::fs;
13use std::path::Path;
14
15#[derive(Debug, Clone)]
17pub struct ChangelogEntry {
18 pub version: Version,
20 pub date: DateTime<Utc>,
22 pub changes: Vec<ChangelogChange>,
24}
25
26#[derive(Debug, Clone)]
28pub struct ChangelogChange {
29 pub bump_type: BumpType,
31 pub summary: String,
33 pub description: Option<String>,
35 pub packages: Vec<String>,
37}
38
39impl ChangelogEntry {
40 #[must_use]
42 pub const fn new(version: Version, date: DateTime<Utc>, changes: Vec<ChangelogChange>) -> Self {
43 Self {
44 version,
45 date,
46 changes,
47 }
48 }
49
50 #[must_use]
52 pub fn to_markdown(&self) -> String {
53 use std::fmt::Write;
54 let mut output = String::new();
55
56 let date_str = self.date.format("%Y-%m-%d").to_string();
58 let _ = writeln!(output, "## [{}] - {}\n", self.version, date_str);
59
60 let mut major_changes = Vec::new();
62 let mut minor_changes = Vec::new();
63 let mut patch_changes = Vec::new();
64
65 for change in &self.changes {
66 match change.bump_type {
67 BumpType::Major => major_changes.push(change),
68 BumpType::Minor => minor_changes.push(change),
69 BumpType::Patch => patch_changes.push(change),
70 BumpType::None => {} }
72 }
73
74 if !major_changes.is_empty() {
76 output.push_str("### Breaking Changes\n\n");
77 for change in major_changes {
78 output.push_str(&format_change(change));
79 }
80 output.push('\n');
81 }
82
83 if !minor_changes.is_empty() {
84 output.push_str("### Features\n\n");
85 for change in minor_changes {
86 output.push_str(&format_change(change));
87 }
88 output.push('\n');
89 }
90
91 if !patch_changes.is_empty() {
92 output.push_str("### Fixes\n\n");
93 for change in patch_changes {
94 output.push_str(&format_change(change));
95 }
96 output.push('\n');
97 }
98
99 output
100 }
101}
102
103fn format_change(change: &ChangelogChange) -> String {
105 use std::fmt::Write;
106 let mut output = String::new();
107
108 if change.packages.len() > 1 {
110 let _ = writeln!(
111 output,
112 "- **[{}]** {}",
113 change.packages.join(", "),
114 change.summary
115 );
116 } else if !change.packages.is_empty() {
117 let _ = writeln!(output, "- **{}**: {}", change.packages[0], change.summary);
118 } else {
119 let _ = writeln!(output, "- {}", change.summary);
120 }
121
122 if let Some(ref desc) = change.description {
124 for line in desc.lines() {
125 let _ = writeln!(output, " {line}");
126 }
127 }
128
129 output
130}
131
132pub struct ChangelogGenerator {
134 config: ChangelogConfig,
136}
137
138impl ChangelogGenerator {
139 #[must_use]
141 pub const fn new(config: ChangelogConfig) -> Self {
142 Self { config }
143 }
144
145 #[must_use]
147 pub fn default_config() -> Self {
148 Self::new(ChangelogConfig::default())
149 }
150
151 #[must_use]
153 pub fn generate_entries(
154 &self,
155 changesets: &[Changeset],
156 package: &str,
157 version: &Version,
158 ) -> Option<ChangelogEntry> {
159 let changes: Vec<ChangelogChange> = changesets
160 .iter()
161 .filter_map(|cs| {
162 let pkg_change = cs.packages.iter().find(|p| p.name == package)?;
164
165 Some(ChangelogChange {
166 bump_type: pkg_change.bump,
167 summary: cs.summary.clone(),
168 description: cs.description.clone(),
169 packages: vec![package.to_string()],
170 })
171 })
172 .collect();
173
174 if changes.is_empty() {
175 return None;
176 }
177
178 Some(ChangelogEntry::new(version.clone(), Utc::now(), changes))
179 }
180
181 #[must_use]
183 pub fn generate_workspace_entry(
184 &self,
185 changesets: &[Changeset],
186 new_versions: &HashMap<String, Version>,
187 ) -> Option<ChangelogEntry> {
188 if changesets.is_empty() {
189 return None;
190 }
191
192 let changes: Vec<ChangelogChange> = changesets
193 .iter()
194 .map(|cs| {
195 let bump_type = cs
197 .packages
198 .iter()
199 .map(|p| p.bump)
200 .max()
201 .unwrap_or(BumpType::None);
202
203 let packages: Vec<String> = cs.packages.iter().map(|p| p.name.clone()).collect();
204
205 ChangelogChange {
206 bump_type,
207 summary: cs.summary.clone(),
208 description: cs.description.clone(),
209 packages,
210 }
211 })
212 .collect();
213
214 let version = new_versions
216 .values()
217 .max()
218 .cloned()
219 .unwrap_or_else(|| Version::new(0, 1, 0));
220
221 Some(ChangelogEntry::new(version, Utc::now(), changes))
222 }
223
224 pub fn update_file(&self, path: &Path, entry: &ChangelogEntry) -> Result<()> {
230 let new_content = entry.to_markdown();
231
232 let existing = if path.exists() {
233 fs::read_to_string(path).map_err(|e| {
234 Error::changeset_io_with_source(
235 format!("Failed to read changelog: {}", path.display()),
236 Some(path.to_path_buf()),
237 e,
238 )
239 })?
240 } else {
241 "# Changelog\n\nAll notable changes to this project will be documented in this file.\n\n".to_string()
243 };
244
245 let content = existing.find("\n## ").map_or_else(
247 || format!("{}\n{}", existing.trim_end(), new_content),
249 |idx| {
250 format!("{}{}{}", &existing[..idx], "\n", new_content.trim_end()) + &existing[idx..]
251 },
252 );
253
254 if let Some(parent) = path.parent() {
256 fs::create_dir_all(parent).map_err(|e| {
257 Error::changeset_io_with_source(
258 format!("Failed to create directory: {}", parent.display()),
259 Some(parent.to_path_buf()),
260 e,
261 )
262 })?;
263 }
264
265 fs::write(path, content).map_err(|e| {
266 Error::changeset_io_with_source(
267 format!("Failed to write changelog: {}", path.display()),
268 Some(path.to_path_buf()),
269 e,
270 )
271 })?;
272
273 Ok(())
274 }
275
276 #[must_use]
278 pub fn get_changelog_path(&self, package_root: &Path) -> std::path::PathBuf {
279 package_root.join(&self.config.path)
280 }
281}
282
283#[cfg(test)]
284mod tests {
285 use super::*;
286 use crate::changeset::PackageChange;
287 use tempfile::TempDir;
288
289 #[test]
290 fn test_changelog_entry_to_markdown() {
291 let version = Version::new(1, 2, 0);
292 let changes = vec![
293 ChangelogChange {
294 bump_type: BumpType::Minor,
295 summary: "Add new feature".to_string(),
296 description: None,
297 packages: vec!["my-pkg".to_string()],
298 },
299 ChangelogChange {
300 bump_type: BumpType::Patch,
301 summary: "Fix bug".to_string(),
302 description: Some("Detailed description".to_string()),
303 packages: vec!["my-pkg".to_string()],
304 },
305 ];
306
307 let entry = ChangelogEntry::new(version, Utc::now(), changes);
308 let md = entry.to_markdown();
309
310 assert!(md.contains("## [1.2.0]"));
311 assert!(md.contains("### Features"));
312 assert!(md.contains("Add new feature"));
313 assert!(md.contains("### Fixes"));
314 assert!(md.contains("Fix bug"));
315 assert!(md.contains("Detailed description"));
316 }
317
318 #[test]
319 fn test_changelog_entry_breaking_changes() {
320 let version = Version::new(2, 0, 0);
321 let changes = vec![ChangelogChange {
322 bump_type: BumpType::Major,
323 summary: "Breaking API change".to_string(),
324 description: None,
325 packages: vec!["my-pkg".to_string()],
326 }];
327
328 let entry = ChangelogEntry::new(version, Utc::now(), changes);
329 let md = entry.to_markdown();
330
331 assert!(md.contains("### Breaking Changes"));
332 assert!(md.contains("Breaking API change"));
333 }
334
335 #[test]
336 fn test_format_change_multiple_packages() {
337 let change = ChangelogChange {
338 bump_type: BumpType::Minor,
339 summary: "Shared feature".to_string(),
340 description: None,
341 packages: vec!["pkg-a".to_string(), "pkg-b".to_string()],
342 };
343
344 let formatted = format_change(&change);
345 assert!(formatted.contains("[pkg-a, pkg-b]"));
346 }
347
348 #[test]
349 fn test_changelog_generator_generate_entries() {
350 let generator = ChangelogGenerator::default_config();
351
352 let changesets = vec![
353 Changeset::with_id(
354 "cs1",
355 "Add feature A",
356 vec![PackageChange::new("my-pkg", BumpType::Minor)],
357 None,
358 ),
359 Changeset::with_id(
360 "cs2",
361 "Fix bug B",
362 vec![PackageChange::new("my-pkg", BumpType::Patch)],
363 None,
364 ),
365 Changeset::with_id(
366 "cs3",
367 "Other package change",
368 vec![PackageChange::new("other-pkg", BumpType::Minor)],
369 None,
370 ),
371 ];
372
373 let version = Version::new(1, 1, 0);
374 let entry = generator.generate_entries(&changesets, "my-pkg", &version);
375
376 assert!(entry.is_some());
377 let entry = entry.unwrap();
378 assert_eq!(entry.version, version);
379 assert_eq!(entry.changes.len(), 2);
380 }
381
382 #[test]
383 fn test_changelog_generator_no_changes_for_package() {
384 let generator = ChangelogGenerator::default_config();
385
386 let changesets = vec![Changeset::with_id(
387 "cs1",
388 "Other change",
389 vec![PackageChange::new("other-pkg", BumpType::Minor)],
390 None,
391 )];
392
393 let version = Version::new(1, 0, 0);
394 let entry = generator.generate_entries(&changesets, "my-pkg", &version);
395
396 assert!(entry.is_none());
397 }
398
399 #[test]
400 fn test_changelog_generator_update_file_new() {
401 let temp = TempDir::new().unwrap();
402 let changelog_path = temp.path().join("CHANGELOG.md");
403
404 let generator = ChangelogGenerator::default_config();
405 let entry = ChangelogEntry::new(
406 Version::new(1, 0, 0),
407 Utc::now(),
408 vec![ChangelogChange {
409 bump_type: BumpType::Minor,
410 summary: "Initial release".to_string(),
411 description: None,
412 packages: vec!["pkg".to_string()],
413 }],
414 );
415
416 generator.update_file(&changelog_path, &entry).unwrap();
417
418 let content = fs::read_to_string(&changelog_path).unwrap();
419 assert!(content.contains("# Changelog"));
420 assert!(content.contains("## [1.0.0]"));
421 assert!(content.contains("Initial release"));
422 }
423
424 #[test]
425 fn test_changelog_generator_update_file_existing() {
426 let temp = TempDir::new().unwrap();
427 let changelog_path = temp.path().join("CHANGELOG.md");
428
429 let initial = r"# Changelog
431
432All notable changes to this project will be documented in this file.
433
434## [0.1.0] - 2023-01-01
435
436### Features
437
438- Initial version
439";
440 fs::write(&changelog_path, initial).unwrap();
441
442 let generator = ChangelogGenerator::default_config();
443 let entry = ChangelogEntry::new(
444 Version::new(0, 2, 0),
445 Utc::now(),
446 vec![ChangelogChange {
447 bump_type: BumpType::Minor,
448 summary: "New feature".to_string(),
449 description: None,
450 packages: vec!["pkg".to_string()],
451 }],
452 );
453
454 generator.update_file(&changelog_path, &entry).unwrap();
455
456 let content = fs::read_to_string(&changelog_path).unwrap();
457 assert!(content.contains("## [0.2.0]"));
458 assert!(content.contains("## [0.1.0]"));
459 assert!(content.find("## [0.2.0]").unwrap() < content.find("## [0.1.0]").unwrap());
461 }
462}