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