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 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 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 = if let Some(idx) = existing.find("\n## ") {
247 format!("{}{}{}", &existing[..idx], "\n", new_content.trim_end()) + &existing[idx..]
248 } else {
249 format!("{}\n{}", existing.trim_end(), new_content)
251 };
252
253 if let Some(parent) = path.parent() {
255 fs::create_dir_all(parent).map_err(|e| {
256 Error::changeset_io_with_source(
257 format!("Failed to create directory: {}", parent.display()),
258 Some(parent.to_path_buf()),
259 e,
260 )
261 })?;
262 }
263
264 fs::write(path, content).map_err(|e| {
265 Error::changeset_io_with_source(
266 format!("Failed to write changelog: {}", path.display()),
267 Some(path.to_path_buf()),
268 e,
269 )
270 })?;
271
272 Ok(())
273 }
274
275 #[must_use]
277 pub fn get_changelog_path(&self, package_root: &Path) -> std::path::PathBuf {
278 package_root.join(&self.config.path)
279 }
280}
281
282#[cfg(test)]
283mod tests {
284 use super::*;
285 use crate::changeset::PackageChange;
286 use tempfile::TempDir;
287
288 #[test]
289 fn test_changelog_entry_to_markdown() {
290 let version = Version::new(1, 2, 0);
291 let changes = vec![
292 ChangelogChange {
293 bump_type: BumpType::Minor,
294 summary: "Add new feature".to_string(),
295 description: None,
296 packages: vec!["my-pkg".to_string()],
297 },
298 ChangelogChange {
299 bump_type: BumpType::Patch,
300 summary: "Fix bug".to_string(),
301 description: Some("Detailed description".to_string()),
302 packages: vec!["my-pkg".to_string()],
303 },
304 ];
305
306 let entry = ChangelogEntry::new(version, Utc::now(), changes);
307 let md = entry.to_markdown();
308
309 assert!(md.contains("## [1.2.0]"));
310 assert!(md.contains("### Features"));
311 assert!(md.contains("Add new feature"));
312 assert!(md.contains("### Fixes"));
313 assert!(md.contains("Fix bug"));
314 assert!(md.contains("Detailed description"));
315 }
316
317 #[test]
318 fn test_changelog_entry_breaking_changes() {
319 let version = Version::new(2, 0, 0);
320 let changes = vec![ChangelogChange {
321 bump_type: BumpType::Major,
322 summary: "Breaking API change".to_string(),
323 description: None,
324 packages: vec!["my-pkg".to_string()],
325 }];
326
327 let entry = ChangelogEntry::new(version, Utc::now(), changes);
328 let md = entry.to_markdown();
329
330 assert!(md.contains("### Breaking Changes"));
331 assert!(md.contains("Breaking API change"));
332 }
333
334 #[test]
335 fn test_format_change_multiple_packages() {
336 let change = ChangelogChange {
337 bump_type: BumpType::Minor,
338 summary: "Shared feature".to_string(),
339 description: None,
340 packages: vec!["pkg-a".to_string(), "pkg-b".to_string()],
341 };
342
343 let formatted = format_change(&change);
344 assert!(formatted.contains("[pkg-a, pkg-b]"));
345 }
346
347 #[test]
348 fn test_changelog_generator_generate_entries() {
349 let generator = ChangelogGenerator::default_config();
350
351 let changesets = vec![
352 Changeset::with_id(
353 "cs1",
354 "Add feature A",
355 vec![PackageChange::new("my-pkg", BumpType::Minor)],
356 None,
357 ),
358 Changeset::with_id(
359 "cs2",
360 "Fix bug B",
361 vec![PackageChange::new("my-pkg", BumpType::Patch)],
362 None,
363 ),
364 Changeset::with_id(
365 "cs3",
366 "Other package change",
367 vec![PackageChange::new("other-pkg", BumpType::Minor)],
368 None,
369 ),
370 ];
371
372 let version = Version::new(1, 1, 0);
373 let entry = generator.generate_entries(&changesets, "my-pkg", &version);
374
375 assert!(entry.is_some());
376 let entry = entry.unwrap();
377 assert_eq!(entry.version, version);
378 assert_eq!(entry.changes.len(), 2);
379 }
380
381 #[test]
382 fn test_changelog_generator_no_changes_for_package() {
383 let generator = ChangelogGenerator::default_config();
384
385 let changesets = vec![Changeset::with_id(
386 "cs1",
387 "Other change",
388 vec![PackageChange::new("other-pkg", BumpType::Minor)],
389 None,
390 )];
391
392 let version = Version::new(1, 0, 0);
393 let entry = generator.generate_entries(&changesets, "my-pkg", &version);
394
395 assert!(entry.is_none());
396 }
397
398 #[test]
399 fn test_changelog_generator_update_file_new() {
400 let temp = TempDir::new().unwrap();
401 let changelog_path = temp.path().join("CHANGELOG.md");
402
403 let generator = ChangelogGenerator::default_config();
404 let entry = ChangelogEntry::new(
405 Version::new(1, 0, 0),
406 Utc::now(),
407 vec![ChangelogChange {
408 bump_type: BumpType::Minor,
409 summary: "Initial release".to_string(),
410 description: None,
411 packages: vec!["pkg".to_string()],
412 }],
413 );
414
415 generator.update_file(&changelog_path, &entry).unwrap();
416
417 let content = fs::read_to_string(&changelog_path).unwrap();
418 assert!(content.contains("# Changelog"));
419 assert!(content.contains("## [1.0.0]"));
420 assert!(content.contains("Initial release"));
421 }
422
423 #[test]
424 fn test_changelog_generator_update_file_existing() {
425 let temp = TempDir::new().unwrap();
426 let changelog_path = temp.path().join("CHANGELOG.md");
427
428 let initial = r"# Changelog
430
431All notable changes to this project will be documented in this file.
432
433## [0.1.0] - 2023-01-01
434
435### Features
436
437- Initial version
438";
439 fs::write(&changelog_path, initial).unwrap();
440
441 let generator = ChangelogGenerator::default_config();
442 let entry = ChangelogEntry::new(
443 Version::new(0, 2, 0),
444 Utc::now(),
445 vec![ChangelogChange {
446 bump_type: BumpType::Minor,
447 summary: "New feature".to_string(),
448 description: None,
449 packages: vec!["pkg".to_string()],
450 }],
451 );
452
453 generator.update_file(&changelog_path, &entry).unwrap();
454
455 let content = fs::read_to_string(&changelog_path).unwrap();
456 assert!(content.contains("## [0.2.0]"));
457 assert!(content.contains("## [0.1.0]"));
458 assert!(content.find("## [0.2.0]").unwrap() < content.find("## [0.1.0]").unwrap());
460 }
461}