Skip to main content

git_iris/
changelog.rs

1//! Changelog file utilities
2//!
3//! This module provides utilities for managing changelog files.
4//! The changelog/release notes *types* are in the `types` module.
5
6use crate::git::GitRepo;
7use crate::log_debug;
8use anyhow::{Context, Result};
9use regex;
10use std::fs;
11use std::io::Write;
12use std::path::Path;
13use std::sync::Arc;
14
15/// Utilities for changelog file management
16pub struct ChangelogGenerator;
17
18impl ChangelogGenerator {
19    /// Updates a changelog file with new content
20    ///
21    /// This function reads the existing changelog file (if it exists), preserves the header,
22    /// and prepends the new changelog content while maintaining the file structure.
23    ///
24    /// # Arguments
25    ///
26    /// * `changelog_content` - The new changelog content to prepend
27    /// * `changelog_path` - Path to the changelog file
28    /// * `git_repo` - `GitRepo` instance to use for retrieving commit dates
29    /// * `to_ref` - The "to" Git reference (commit/tag) to extract the date from
30    /// * `version_name` - Optional custom version name to use instead of version from Git
31    ///
32    /// # Returns
33    ///
34    /// A Result indicating success or an error
35    #[allow(clippy::too_many_lines)]
36    ///
37    /// # Errors
38    ///
39    /// Returns an error when the changelog cannot be read, parsed, or written.
40    pub fn update_changelog_file(
41        changelog_content: &str,
42        changelog_path: &str,
43        git_repo: &Arc<GitRepo>,
44        to_ref: &str,
45        version_name: Option<String>,
46    ) -> Result<()> {
47        let path = Path::new(changelog_path);
48        let default_header = "# Changelog\n\nAll notable changes to this project will be documented in this file.\n\nThe format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),\nand this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).\n\n";
49
50        // Get the date from the "to" Git reference
51        let commit_date = match git_repo.get_commit_date(to_ref) {
52            Ok(date) => {
53                log_debug!("Got commit date for {}: {}", to_ref, date);
54                date
55            }
56            Err(e) => {
57                log_debug!("Failed to get commit date for {}: {}", to_ref, e);
58                chrono::Local::now().format("%Y-%m-%d").to_string()
59            }
60        };
61
62        // Strip ANSI color codes
63        let stripped_content = strip_ansi_codes(changelog_content);
64
65        // Skip the separator line if it exists (the first line with "━━━" or similar)
66        let clean_content =
67            if stripped_content.starts_with("━") || stripped_content.starts_with('-') {
68                // Find the first newline and skip everything before it
69                if let Some(pos) = stripped_content.find('\n') {
70                    stripped_content[pos + 1..].to_string()
71                } else {
72                    stripped_content
73                }
74            } else {
75                stripped_content
76            };
77
78        // Extract just the version content (skip the header)
79        let mut version_content = if clean_content.contains("## [") {
80            let parts: Vec<&str> = clean_content.split("## [").collect();
81            if parts.len() > 1 {
82                format!("## [{}", parts[1])
83            } else {
84                clean_content
85            }
86        } else {
87            clean_content
88        };
89
90        // If version_name is provided, override the existing version
91        if let Some(version) = version_name {
92            if version_content.contains("## [") {
93                let re = regex::Regex::new(r"## \[([^\]]+)\]")
94                    .context("Failed to compile changelog version regex")?;
95                version_content = re
96                    .replace(&version_content, &format!("## [{version}]"))
97                    .to_string();
98                log_debug!("Replaced version with user-provided version: {}", version);
99            } else {
100                log_debug!("Could not find version header to replace in changelog content");
101            }
102        }
103
104        // Ensure version content has a date
105        if version_content.contains(" - \n") {
106            // Replace empty date placeholder with the commit date
107            version_content = version_content.replace(" - \n", &format!(" - {commit_date}\n"));
108            log_debug!("Replaced empty date with commit date: {}", commit_date);
109        } else if version_content.contains("] - ") && !version_content.contains("] - 20") {
110            // For cases where there's no date but a dash
111            let parts: Vec<&str> = version_content.splitn(2, "] - ").collect();
112            if parts.len() == 2 {
113                version_content = format!(
114                    "{}] - {}\n{}",
115                    parts[0],
116                    commit_date,
117                    parts[1].trim_start_matches(['\n', ' '])
118                );
119                log_debug!("Added commit date after dash: {}", commit_date);
120            }
121        } else if !version_content.contains("] - ") {
122            // If no date pattern at all, find the version line and add a date
123            let line_end = version_content.find('\n').unwrap_or(version_content.len());
124            let version_line = &version_content[..line_end];
125
126            if version_line.contains("## [") && version_line.contains(']') {
127                // Insert the date right after the closing bracket
128                if let Some(bracket_pos) = version_line.rfind(']') {
129                    version_content = format!(
130                        "{} - {}{}",
131                        &version_content[..=bracket_pos],
132                        commit_date,
133                        &version_content[bracket_pos + 1..]
134                    );
135                    log_debug!("Added date to version line: {}", commit_date);
136                }
137            }
138        }
139
140        // Add a decorative separator after the version content
141        let separator =
142            "\n<!-- -------------------------------------------------------------- -->\n\n";
143        let version_content_with_separator = format!("{version_content}{separator}");
144
145        let updated_content = if path.exists() {
146            let existing_content = fs::read_to_string(path)
147                .with_context(|| format!("Failed to read changelog file: {changelog_path}"))?;
148
149            // Check if the file already has a Keep a Changelog header
150            if existing_content.contains("# Changelog")
151                && existing_content.contains("Keep a Changelog")
152            {
153                // Split at the first version heading
154                if existing_content.contains("## [") {
155                    let parts: Vec<&str> = existing_content.split("## [").collect();
156                    let header = parts[0];
157
158                    // Combine header with new version content and existing versions
159                    if parts.len() > 1 {
160                        let existing_versions = parts[1..].join("## [");
161                        format!("{header}{version_content_with_separator}## [{existing_versions}")
162                    } else {
163                        format!("{header}{version_content_with_separator}")
164                    }
165                } else {
166                    // No version sections yet, just append new content
167                    format!("{existing_content}{version_content_with_separator}")
168                }
169            } else {
170                // Existing file doesn't have proper format, overwrite with default structure
171                format!("{default_header}{version_content_with_separator}")
172            }
173        } else {
174            // File doesn't exist, create new with proper header
175            format!("{default_header}{version_content_with_separator}")
176        };
177
178        // Write the updated content back to the file
179        let mut file = fs::File::create(path)
180            .with_context(|| format!("Failed to create changelog file: {changelog_path}"))?;
181
182        file.write_all(updated_content.as_bytes())
183            .with_context(|| format!("Failed to write to changelog file: {changelog_path}"))?;
184
185        Ok(())
186    }
187}
188
189/// Strips ANSI color/style codes from a string
190fn strip_ansi_codes(s: &str) -> String {
191    let re = regex::Regex::new(r"\x1B\[([0-9]{1,2}(;[0-9]{1,2})*)?[m|K]")
192        .expect("Failed to compile ANSI escape code regex");
193    re.replace_all(s, "").to_string()
194}