Skip to main content

cuenv_release/
changelog.rs

1//! Changelog generation and formatting.
2//!
3//! This module provides utilities for generating and updating CHANGELOG.md files
4//! based on consumed changesets.
5
6use 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/// A single entry in the changelog.
16#[derive(Debug, Clone)]
17pub struct ChangelogEntry {
18    /// Version being released.
19    pub version: Version,
20    /// Release date.
21    pub date: DateTime<Utc>,
22    /// Changes organized by category.
23    pub changes: Vec<ChangelogChange>,
24}
25
26/// A single change in the changelog.
27#[derive(Debug, Clone)]
28pub struct ChangelogChange {
29    /// Type of change (major, minor, patch).
30    pub bump_type: BumpType,
31    /// Summary of the change.
32    pub summary: String,
33    /// Detailed description (optional).
34    pub description: Option<String>,
35    /// Packages affected by this change.
36    pub packages: Vec<String>,
37}
38
39impl ChangelogEntry {
40    /// Create a new changelog entry.
41    #[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    /// Format this entry as Markdown.
51    #[must_use]
52    pub fn to_markdown(&self) -> String {
53        use std::fmt::Write;
54        let mut output = String::new();
55
56        // Version header
57        let date_str = self.date.format("%Y-%m-%d").to_string();
58        let _ = writeln!(output, "## [{}] - {}\n", self.version, date_str);
59
60        // Group changes by type
61        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 => {} // Skip none changes
71            }
72        }
73
74        // Write sections
75        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
103/// Format a single change as a Markdown list item.
104fn format_change(change: &ChangelogChange) -> String {
105    use std::fmt::Write;
106    let mut output = String::new();
107
108    // Package prefix if multiple packages
109    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    // Add description if present
123    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
132/// Generator for changelog files.
133pub struct ChangelogGenerator {
134    /// Changelog configuration.
135    config: ChangelogConfig,
136}
137
138impl ChangelogGenerator {
139    /// Create a new changelog generator with the given configuration.
140    #[must_use]
141    pub const fn new(config: ChangelogConfig) -> Self {
142        Self { config }
143    }
144
145    /// Create a changelog generator with default configuration.
146    #[must_use]
147    pub fn default_config() -> Self {
148        Self::new(ChangelogConfig::default())
149    }
150
151    /// Generate changelog entries from changesets for a specific package.
152    #[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                // Find if this changeset affects our package
163                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    /// Generate a workspace-level changelog entry.
182    #[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                // Get the highest bump type from all packages
196                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        // Use the highest version as the workspace version
215        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    /// Update a changelog file with a new entry.
225    ///
226    /// # Errors
227    ///
228    /// Returns an error if the file cannot be read or written.
229    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            // Create initial changelog structure
242            "# Changelog\n\nAll notable changes to this project will be documented in this file.\n\n".to_string()
243        };
244
245        // Insert new content after the header
246        let content = existing.find("\n## ").map_or_else(
247            // No existing entries, append to end
248            || format!("{}\n{}", existing.trim_end(), new_content),
249            |idx| {
250                format!("{}{}{}", &existing[..idx], "\n", new_content.trim_end()) + &existing[idx..]
251            },
252        );
253
254        // Ensure parent directory exists
255        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    /// Get the changelog file path for a package.
277    #[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        // Create initial changelog
430        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        // New entry should come before old one
460        assert!(content.find("## [0.2.0]").unwrap() < content.find("## [0.1.0]").unwrap());
461    }
462}