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