gitai/features/changelog/
change_log.rs

1use 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
18/// Struct responsible for generating changelogs
19pub struct ChangelogGenerator;
20
21impl ChangelogGenerator {
22    /// Generates a changelog for the specified range of commits.
23    ///
24    /// # Arguments
25    ///
26    /// * `git_repo` - `GitRepo` instance
27    /// * `from` - Starting point for the changelog (e.g., a commit hash or tag)
28    /// * `to` - Ending point for the changelog (e.g., a commit hash, tag, or "HEAD")
29    /// * `config` - Configuration object containing LLM settings
30    /// * `detail_level` - Level of detail for the changelog (Minimal, Standard, or Detailed)
31    ///
32    /// # Returns
33    ///
34    /// A Result containing the generated changelog as a String, or an error
35    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    /// Updates a changelog file with new content
57    ///
58    /// This function reads the existing changelog file (if it exists), preserves the header,
59    /// and prepends the new changelog content while maintaining the file structure.
60    ///
61    /// # Arguments
62    ///
63    /// * `changelog_content` - The new changelog content to prepend
64    /// * `changelog_path` - Path to the changelog file
65    /// * `git_repo` - `GitRepo` instance to use for retrieving commit dates
66    /// * `to_ref` - The "to" Git reference (commit/tag) to extract the date from
67    /// * `version_name` - Optional custom version name to use instead of version from Git
68    ///
69    /// # Returns
70    ///
71    /// A Result indicating success or an error
72    #[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        // Get the date from the "to" Git reference
84        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        // Strip ANSI color codes
96        let stripped_content = strip_ansi_codes(changelog_content);
97
98        // Skip the separator line if it exists (the first line with "━━━" or similar)
99        let clean_content =
100            if stripped_content.starts_with("━") || stripped_content.starts_with('-') {
101                // Find the first newline and skip everything before it
102                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        // Extract just the version content (skip the header)
112        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 version_name is provided, override the existing version
124        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        // Ensure version content has a date
137        if version_content.contains(" - \n") {
138            // Replace empty date placeholder with the commit date
139            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            // For cases where there's no date but a dash
143            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            // If no date pattern at all, find the version line and add a date
155            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                // Insert the date right after the closing bracket
160                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        // Add a decorative separator after the version content
174        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            // Check if the file already has a Keep a Changelog header
183            if existing_content.contains("# Changelog")
184                && existing_content.contains("Keep a Changelog")
185            {
186                // Split at the first version heading
187                if existing_content.contains("## [") {
188                    let parts: Vec<&str> = existing_content.split("## [").collect();
189                    let header = parts[0];
190
191                    // Combine header with new version content and existing versions
192                    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                    // No version sections yet, just append new content
200                    format!("{existing_content}{version_content_with_separator}")
201                }
202            } else {
203                // Existing file doesn't have proper format, overwrite with default structure
204                format!("{default_header}{version_content_with_separator}")
205            }
206        } else {
207            // File doesn't exist, create new with proper header
208            format!("{default_header}{version_content_with_separator}")
209        };
210
211        // Write the updated content back to the file
212        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
222/// Strips ANSI color/style codes from a string
223fn strip_ansi_codes(s: &str) -> String {
224    // This regex matches ANSI escape codes like colors and styles
225    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
230/// Formats the `ChangelogResponse` into a human-readable changelog
231fn format_changelog_response(response: &ChangelogResponse) -> String {
232    let mut formatted = String::new();
233
234    // Add header
235    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    // Add version and release date - don't provide a date here, it will be set later
243    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    // Define the order of change types
252    let ordered_types = [
253        ChangelogType::Added,
254        ChangelogType::Changed,
255        ChangelogType::Fixed,
256        ChangelogType::Removed,
257        ChangelogType::Deprecated,
258        ChangelogType::Security,
259    ];
260
261    // Add changes in the specified order
262    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    // Add breaking changes
275    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    // Add metrics
289    formatted.push_str(&"### 📊 Metrics\n\n".bright_magenta().bold().to_string());
290    formatted.push_str(&format_metrics(&response.metrics));
291
292    formatted
293}
294
295/// Formats a change type with an appropriate emoji
296fn 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
308/// Formats a single change entry
309fn 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
332/// Formats a breaking change
333fn format_breaking_change(breaking_change: &BreakingChange) -> String {
334    format!(
335        "- {} ({})\n",
336        breaking_change.description,
337        breaking_change.commit_hash.dimmed()
338    )
339}
340
341/// Formats the change metrics
342fn 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}