gitai/features/changelog/
change_log.rs1use super::common::generate_changes_content;
2use super::models::{BreakingChange, ChangeEntry, ChangeMetrics, ChangelogResponse, ChangelogType};
3use super::prompt;
4use crate::common::DetailLevel;
5use crate::config::Config;
6use crate::debug;
7use crate::git::GitRepo;
8use anyhow::{Context, Result};
9use chrono;
10use colored::Colorize;
11use regex;
12use std::fmt::Write as FmtWrite;
13use std::fs;
14use std::io::Write;
15use std::path::Path;
16use std::sync::Arc;
17
18pub struct ChangelogGenerator;
20
21impl ChangelogGenerator {
22 pub async fn generate(
36 git_repo: Arc<GitRepo>,
37 from: &str,
38 to: &str,
39 config: &Config,
40 detail_level: DetailLevel,
41 ) -> Result<String> {
42 let changelog: ChangelogResponse = generate_changes_content::<ChangelogResponse>(
43 git_repo,
44 from,
45 to,
46 config,
47 detail_level,
48 prompt::create_changelog_system_prompt,
49 prompt::create_changelog_user_prompt,
50 )
51 .await?;
52
53 Ok(format_changelog_response(&changelog))
54 }
55
56 #[allow(clippy::too_many_lines)]
73 pub fn update_changelog_file(
74 changelog_content: &str,
75 changelog_path: &str,
76 git_repo: &Arc<GitRepo>,
77 to_ref: &str,
78 version_name: Option<String>,
79 ) -> Result<()> {
80 let path = Path::new(changelog_path);
81 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";
82
83 let commit_date = match git_repo.get_commit_date(to_ref) {
85 Ok(date) => {
86 debug!("Got commit date for {}: {}", to_ref, date);
87 date
88 }
89 Err(e) => {
90 debug!("Failed to get commit date for {}: {}", to_ref, e);
91 chrono::Local::now().format("%Y-%m-%d").to_string()
92 }
93 };
94
95 let stripped_content = strip_ansi_codes(changelog_content);
97
98 let clean_content =
100 if stripped_content.starts_with("━") || stripped_content.starts_with('-') {
101 if let Some(pos) = stripped_content.find('\n') {
103 stripped_content[pos + 1..].to_string()
104 } else {
105 stripped_content
106 }
107 } else {
108 stripped_content
109 };
110
111 let mut version_content = if clean_content.contains("## [") {
113 let parts: Vec<&str> = clean_content.split("## [").collect();
114 if parts.len() > 1 {
115 format!("## [{}", parts[1])
116 } else {
117 clean_content
118 }
119 } else {
120 clean_content
121 };
122
123 if let Some(version) = version_name {
125 if version_content.contains("## [") {
126 let re = regex::Regex::new(r"## \[([^\]]+)\]").expect("Failed to compile regex");
127 version_content = re
128 .replace(&version_content, &format!("## [{version}]"))
129 .to_string();
130 debug!("Replaced version with user-provided version: {}", version);
131 } else {
132 debug!("Could not find version header to replace in changelog content");
133 }
134 }
135
136 if version_content.contains(" - \n") {
138 version_content = version_content.replace(" - \n", &format!(" - {commit_date}\n"));
140 debug!("Replaced empty date with commit date: {}", commit_date);
141 } else if version_content.contains("] - ") && !version_content.contains("] - 20") {
142 let parts: Vec<&str> = version_content.splitn(2, "] - ").collect();
144 if parts.len() == 2 {
145 version_content = format!(
146 "{}] - {}\n{}",
147 parts[0],
148 commit_date,
149 parts[1].trim_start_matches(['\n', ' '])
150 );
151 debug!("Added commit date after dash: {}", commit_date);
152 }
153 } else if !version_content.contains("] - ") {
154 let line_end = version_content.find('\n').unwrap_or(version_content.len());
156 let version_line = &version_content[..line_end];
157
158 if version_line.contains("## [") && version_line.contains(']') {
159 let bracket_pos = version_line
161 .rfind(']')
162 .expect("Failed to find closing bracket in version line");
163 version_content = format!(
164 "{} - {}{}",
165 &version_content[..=bracket_pos],
166 commit_date,
167 &version_content[bracket_pos + 1..]
168 );
169 debug!("Added date to version line: {}", commit_date);
170 }
171 }
172
173 let separator =
175 "\n<!-- -------------------------------------------------------------- -->\n\n";
176 let version_content_with_separator = format!("{version_content}{separator}");
177
178 let updated_content = if path.exists() {
179 let existing_content = fs::read_to_string(path)
180 .with_context(|| format!("Failed to read changelog file: {changelog_path}"))?;
181
182 if existing_content.contains("# Changelog")
184 && existing_content.contains("Keep a Changelog")
185 {
186 if existing_content.contains("## [") {
188 let parts: Vec<&str> = existing_content.split("## [").collect();
189 let header = parts[0];
190
191 if parts.len() > 1 {
193 let existing_versions = parts[1..].join("## [");
194 format!("{header}{version_content_with_separator}## [{existing_versions}")
195 } else {
196 format!("{header}{version_content_with_separator}")
197 }
198 } else {
199 format!("{existing_content}{version_content_with_separator}")
201 }
202 } else {
203 format!("{default_header}{version_content_with_separator}")
205 }
206 } else {
207 format!("{default_header}{version_content_with_separator}")
209 };
210
211 let mut file = fs::File::create(path)
213 .with_context(|| format!("Failed to create changelog file: {changelog_path}"))?;
214
215 file.write_all(updated_content.as_bytes())
216 .with_context(|| format!("Failed to write to changelog file: {changelog_path}"))?;
217
218 Ok(())
219 }
220}
221
222fn strip_ansi_codes(s: &str) -> String {
224 let re = regex::Regex::new(r"\x1B\[([0-9]{1,2}(;[0-9]{1,2})*)?[m|K]")
226 .expect("Failed to compile ANSI escape code regex");
227 re.replace_all(s, "").to_string()
228}
229
230fn format_changelog_response(response: &ChangelogResponse) -> String {
232 let mut formatted = String::new();
233
234 formatted.push_str(&"# Changelog\n\n".bright_cyan().bold().to_string());
236 formatted.push_str("All notable changes to this project will be documented in this file.\n\n");
237 formatted.push_str(
238 "The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),\n",
239 );
240 formatted.push_str("and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).\n\n");
241
242 let version = response
244 .version
245 .clone()
246 .unwrap_or_else(|| "Unreleased".to_string());
247
248 write!(formatted, "## [{}] - \n\n", version.bright_green().bold())
249 .expect("writing to string should never fail");
250
251 let ordered_types = [
253 ChangelogType::Added,
254 ChangelogType::Changed,
255 ChangelogType::Fixed,
256 ChangelogType::Removed,
257 ChangelogType::Deprecated,
258 ChangelogType::Security,
259 ];
260
261 for change_type in &ordered_types {
263 if let Some(entries) = response.sections.get(change_type)
264 && !entries.is_empty()
265 {
266 formatted.push_str(&format_change_type(change_type));
267 for entry in entries {
268 formatted.push_str(&format_change_entry(entry));
269 }
270 formatted.push('\n');
271 }
272 }
273
274 if !response.breaking_changes.is_empty() {
276 formatted.push_str(
277 &"### ⚠️ Breaking Changes\n\n"
278 .bright_red()
279 .bold()
280 .to_string(),
281 );
282 for breaking_change in &response.breaking_changes {
283 formatted.push_str(&format_breaking_change(breaking_change));
284 }
285 formatted.push('\n');
286 }
287
288 formatted.push_str(&"### 📊 Metrics\n\n".bright_magenta().bold().to_string());
290 formatted.push_str(&format_metrics(&response.metrics));
291
292 formatted
293}
294
295fn format_change_type(change_type: &ChangelogType) -> String {
297 let (emoji, text) = match change_type {
298 ChangelogType::Added => ("✨", "Added"),
299 ChangelogType::Changed => ("🔄", "Changed"),
300 ChangelogType::Deprecated => ("⚠️", "Deprecated"),
301 ChangelogType::Removed => ("🗑️", "Removed"),
302 ChangelogType::Fixed => ("🐛", "Fixed"),
303 ChangelogType::Security => ("🔒", "Security"),
304 };
305 format!("### {} {}\n\n", emoji, text.bright_blue().bold())
306}
307
308fn format_change_entry(entry: &ChangeEntry) -> String {
310 let mut formatted = format!("- {}", entry.description);
311
312 if !entry.associated_issues.is_empty() {
313 write!(
314 formatted,
315 " ({})",
316 entry.associated_issues.join(", ").yellow()
317 )
318 .expect("writing to string should never fail");
319 }
320
321 if let Some(pr) = &entry.pull_request {
322 write!(formatted, " [{}]", pr.bright_purple())
323 .expect("writing to string should never fail");
324 }
325
326 writeln!(formatted, " ({})", entry.commit_hashes.join(", ").dimmed())
327 .expect("writing to string should never fail");
328
329 formatted
330}
331
332fn format_breaking_change(breaking_change: &BreakingChange) -> String {
334 format!(
335 "- {} ({})\n",
336 breaking_change.description,
337 breaking_change.commit_hash.dimmed()
338 )
339}
340
341fn format_metrics(metrics: &ChangeMetrics) -> String {
343 format!(
344 "- Total Commits: {}\n- Files Changed: {}\n- Insertions: {}\n- Deletions: {}\n",
345 metrics.total_commits.to_string().green(),
346 metrics.files_changed.to_string().yellow(),
347 metrics.insertions.to_string().green(),
348 metrics.deletions.to_string().red()
349 )
350}