1use 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
15pub struct ChangelogGenerator;
17
18impl ChangelogGenerator {
19 #[allow(clippy::too_many_lines)]
36 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 commit_date = changelog_commit_date(git_repo, to_ref);
49 let clean_content = clean_generated_changelog(changelog_content);
50 let mut version_content = extract_version_content(&clean_content);
51
52 apply_version_override(&mut version_content, version_name)?;
53 ensure_version_date(&mut version_content, &commit_date);
54
55 let updated_content =
56 updated_changelog_content(path, changelog_path, &with_separator(&version_content))?;
57
58 let mut file = fs::File::create(path)
59 .with_context(|| format!("Failed to create changelog file: {changelog_path}"))?;
60
61 file.write_all(updated_content.as_bytes())
62 .with_context(|| format!("Failed to write to changelog file: {changelog_path}"))?;
63
64 Ok(())
65 }
66}
67
68fn changelog_commit_date(git_repo: &Arc<GitRepo>, to_ref: &str) -> String {
69 match git_repo.get_commit_date(to_ref) {
70 Ok(date) => {
71 log_debug!("Got commit date for {}: {}", to_ref, date);
72 date
73 }
74 Err(e) => {
75 log_debug!("Failed to get commit date for {}: {}", to_ref, e);
76 chrono::Local::now().format("%Y-%m-%d").to_string()
77 }
78 }
79}
80
81fn clean_generated_changelog(changelog_content: &str) -> String {
82 let stripped_content = strip_ansi_codes(changelog_content);
83 if stripped_content.starts_with("━") || stripped_content.starts_with('-') {
84 stripped_content
85 .find('\n')
86 .map_or(stripped_content.clone(), |pos| {
87 stripped_content[pos + 1..].to_string()
88 })
89 } else {
90 stripped_content
91 }
92}
93
94fn extract_version_content(clean_content: &str) -> String {
95 clean_content
96 .split_once("## [")
97 .map_or(clean_content.to_string(), |(_, version)| {
98 format!("## [{version}")
99 })
100}
101
102fn apply_version_override(
103 version_content: &mut String,
104 version_name: Option<String>,
105) -> Result<()> {
106 let Some(version) = version_name else {
107 return Ok(());
108 };
109
110 if !version_content.contains("## [") {
111 log_debug!("Could not find version header to replace in changelog content");
112 return Ok(());
113 }
114
115 let re = regex::Regex::new(r"## \[([^\]]+)\]")
116 .context("Failed to compile changelog version regex")?;
117 *version_content = re
118 .replace(version_content, &format!("## [{version}]"))
119 .to_string();
120 log_debug!("Replaced version with user-provided version: {}", version);
121 Ok(())
122}
123
124fn ensure_version_date(version_content: &mut String, commit_date: &str) {
125 if version_content.contains(" - \n") {
126 *version_content = version_content.replace(" - \n", &format!(" - {commit_date}\n"));
127 log_debug!("Replaced empty date with commit date: {}", commit_date);
128 } else if version_content.contains("] - ") && !version_content.contains("] - 20") {
129 add_date_after_dash(version_content, commit_date);
130 } else if !version_content.contains("] - ") {
131 add_date_to_version_line(version_content, commit_date);
132 }
133}
134
135fn add_date_after_dash(version_content: &mut String, commit_date: &str) {
136 if let Some((prefix, rest)) = version_content.split_once("] - ") {
137 *version_content = format!(
138 "{prefix}] - {commit_date}\n{}",
139 rest.trim_start_matches(['\n', ' '])
140 );
141 log_debug!("Added commit date after dash: {}", commit_date);
142 }
143}
144
145fn add_date_to_version_line(version_content: &mut String, commit_date: &str) {
146 let line_end = version_content.find('\n').unwrap_or(version_content.len());
147 let version_line = &version_content[..line_end];
148
149 if version_line.contains("## [")
150 && let Some(bracket_pos) = version_line.rfind(']')
151 {
152 *version_content = format!(
153 "{} - {}{}",
154 &version_content[..=bracket_pos],
155 commit_date,
156 &version_content[bracket_pos + 1..]
157 );
158 log_debug!("Added date to version line: {}", commit_date);
159 }
160}
161
162fn with_separator(version_content: &str) -> String {
163 format!(
164 "{version_content}\n<!-- -------------------------------------------------------------- -->\n\n"
165 )
166}
167
168fn updated_changelog_content(
169 path: &Path,
170 changelog_path: &str,
171 version_content: &str,
172) -> Result<String> {
173 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";
174
175 if !path.exists() {
176 return Ok(format!("{default_header}{version_content}"));
177 }
178
179 let existing_content = fs::read_to_string(path)
180 .with_context(|| format!("Failed to read changelog file: {changelog_path}"))?;
181
182 Ok(merge_existing_changelog(
183 &existing_content,
184 default_header,
185 version_content,
186 ))
187}
188
189fn merge_existing_changelog(
190 existing_content: &str,
191 default_header: &str,
192 version_content: &str,
193) -> String {
194 if !existing_content.contains("# Changelog") || !existing_content.contains("Keep a Changelog") {
195 return format!("{default_header}{version_content}");
196 }
197
198 existing_content.split_once("## [").map_or_else(
199 || format!("{existing_content}{version_content}"),
200 |(header, existing_versions)| format!("{header}{version_content}## [{existing_versions}"),
201 )
202}
203
204fn strip_ansi_codes(s: &str) -> String {
206 let re = regex::Regex::new(r"\x1B\[([0-9]{1,2}(;[0-9]{1,2})*)?[m|K]")
207 .expect("Failed to compile ANSI escape code regex");
208 re.replace_all(s, "").to_string()
209}