Skip to content

Commit 0bc59d8

Browse files
committed
✨ Add changelog file update functionality
Add ability to update changelog files with generated content This change adds a new feature to automatically update CHANGELOG.md files: - Implement update_changelog_file function to preserve header and append new entries - Add strip_ansi_codes utility to clean colored output for file writing - Update CLI with --update and --file flags for the changelog command - Add date extraction from Git commits with get_commit_date - Improve changelog formatting with ordered change types - Add smart date handling for version entries The implementation intelligently handles existing changelog files by preserving their structure while adding new entries in Keep a Changelog format.
1 parent e2ecaca commit 0bc59d8

File tree

5 files changed

+281
-31
lines changed

5 files changed

+281
-31
lines changed

src/changes/changelog.rs

Lines changed: 192 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,14 @@ use super::prompt;
44
use crate::common::DetailLevel;
55
use crate::config::Config;
66
use crate::git::GitRepo;
7-
use anyhow::Result;
7+
use crate::log_debug;
8+
use anyhow::{Context, Result};
9+
use chrono;
810
use colored::Colorize;
11+
use regex;
12+
use std::fs;
13+
use std::io::Write;
14+
use std::path::Path;
915
use std::sync::Arc;
1016

1117
/// Struct responsible for generating changelogs
@@ -45,6 +51,164 @@ impl ChangelogGenerator {
4551

4652
Ok(format_changelog_response(&changelog))
4753
}
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+
///
67+
/// # Returns
68+
///
69+
/// A Result indicating success or an error
70+
pub fn update_changelog_file(
71+
changelog_content: &str,
72+
changelog_path: &str,
73+
git_repo: &Arc<GitRepo>,
74+
to_ref: &str,
75+
) -> Result<()> {
76+
let path = Path::new(changelog_path);
77+
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";
78+
79+
// Get the date from the "to" Git reference
80+
let commit_date = match git_repo.get_commit_date(to_ref) {
81+
Ok(date) => {
82+
log_debug!("Got commit date for {}: {}", to_ref, date);
83+
date
84+
}
85+
Err(e) => {
86+
log_debug!("Failed to get commit date for {}: {}", to_ref, e);
87+
chrono::Local::now().format("%Y-%m-%d").to_string()
88+
}
89+
};
90+
91+
// Strip ANSI color codes
92+
let stripped_content = strip_ansi_codes(changelog_content);
93+
94+
// Skip the separator line if it exists (the first line with "━━━" or similar)
95+
let clean_content =
96+
if stripped_content.starts_with("━") || stripped_content.starts_with('-') {
97+
// Find the first newline and skip everything before it
98+
if let Some(pos) = stripped_content.find('\n') {
99+
stripped_content[pos + 1..].to_string()
100+
} else {
101+
stripped_content
102+
}
103+
} else {
104+
stripped_content
105+
};
106+
107+
// Extract just the version content (skip the header)
108+
let mut version_content = if clean_content.contains("## [") {
109+
let parts: Vec<&str> = clean_content.split("## [").collect();
110+
if parts.len() > 1 {
111+
format!("## [{}", parts[1])
112+
} else {
113+
clean_content
114+
}
115+
} else {
116+
clean_content
117+
};
118+
119+
// Ensure version content has a date
120+
if version_content.contains(" - \n") {
121+
// Replace empty date placeholder with the commit date
122+
version_content = version_content.replace(" - \n", &format!(" - {commit_date}\n"));
123+
log_debug!("Replaced empty date with commit date: {}", commit_date);
124+
} else if version_content.contains("] - ") && !version_content.contains("] - 20") {
125+
// For cases where there's no date but a dash
126+
let parts: Vec<&str> = version_content.splitn(2, "] - ").collect();
127+
if parts.len() == 2 {
128+
version_content = format!(
129+
"{}] - {}\n{}",
130+
parts[0],
131+
commit_date,
132+
parts[1].trim_start_matches(['\n', ' '])
133+
);
134+
log_debug!("Added commit date after dash: {}", commit_date);
135+
}
136+
} else if !version_content.contains("] - ") {
137+
// If no date pattern at all, find the version line and add a date
138+
let line_end = version_content.find('\n').unwrap_or(version_content.len());
139+
let version_line = &version_content[..line_end];
140+
141+
if version_line.contains("## [") && version_line.contains(']') {
142+
// Insert the date right after the closing bracket
143+
let bracket_pos = version_line.rfind(']')
144+
.expect("Failed to find closing bracket in version line");
145+
version_content = format!(
146+
"{} - {}{}",
147+
&version_content[..=bracket_pos],
148+
commit_date,
149+
&version_content[bracket_pos + 1..]
150+
);
151+
log_debug!("Added date to version line: {}", commit_date);
152+
}
153+
}
154+
155+
// Add a decorative separator after the version content
156+
let separator =
157+
"\n<!-- -------------------------------------------------------------- -->\n\n";
158+
let version_content_with_separator = format!("{version_content}{separator}");
159+
160+
let updated_content = if path.exists() {
161+
let existing_content = fs::read_to_string(path)
162+
.with_context(|| format!("Failed to read changelog file: {changelog_path}"))?;
163+
164+
// Check if the file already has a Keep a Changelog header
165+
if existing_content.contains("# Changelog")
166+
&& existing_content.contains("Keep a Changelog")
167+
{
168+
// Split at the first version heading
169+
if existing_content.contains("## [") {
170+
let parts: Vec<&str> = existing_content.split("## [").collect();
171+
let header = parts[0];
172+
173+
// Combine header with new version content and existing versions
174+
if parts.len() > 1 {
175+
let existing_versions = parts[1..].join("## [");
176+
format!(
177+
"{header}{version_content_with_separator}## [{existing_versions}"
178+
)
179+
} else {
180+
format!("{header}{version_content_with_separator}")
181+
}
182+
} else {
183+
// No version sections yet, just append new content
184+
format!("{existing_content}{version_content_with_separator}")
185+
}
186+
} else {
187+
// Existing file doesn't have proper format, overwrite with default structure
188+
format!("{default_header}{version_content_with_separator}")
189+
}
190+
} else {
191+
// File doesn't exist, create new with proper header
192+
format!("{default_header}{version_content_with_separator}")
193+
};
194+
195+
// Write the updated content back to the file
196+
let mut file = fs::File::create(path)
197+
.with_context(|| format!("Failed to create changelog file: {changelog_path}"))?;
198+
199+
file.write_all(updated_content.as_bytes())
200+
.with_context(|| format!("Failed to write to changelog file: {changelog_path}"))?;
201+
202+
Ok(())
203+
}
204+
}
205+
206+
/// Strips ANSI color/style codes from a string
207+
fn strip_ansi_codes(s: &str) -> String {
208+
// This regex matches ANSI escape codes like colors and styles
209+
let re = regex::Regex::new(r"\x1B\[([0-9]{1,2}(;[0-9]{1,2})*)?[m|K]")
210+
.expect("Failed to compile ANSI escape code regex");
211+
re.replace_all(s, "").to_string()
48212
}
49213

50214
/// Formats the `ChangelogResponse` into a human-readable changelog
@@ -59,26 +223,34 @@ fn format_changelog_response(response: &ChangelogResponse) -> String {
59223
);
60224
formatted.push_str("and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).\n\n");
61225

62-
// Add version and release date
63-
formatted.push_str(&format!(
64-
"## [{}] - {}\n\n",
65-
response
66-
.version
67-
.clone()
68-
.unwrap_or_default()
69-
.bright_green()
70-
.bold(),
71-
response.release_date.clone().unwrap_or_default().yellow()
72-
));
73-
74-
// Add changes grouped by type
75-
for (change_type, entries) in &response.sections {
76-
if !entries.is_empty() {
77-
formatted.push_str(&format_change_type(change_type));
78-
for entry in entries {
79-
formatted.push_str(&format_change_entry(entry));
226+
// Add version and release date - don't provide a date here, it will be set later
227+
let version = response
228+
.version
229+
.clone()
230+
.unwrap_or_else(|| "Unreleased".to_string());
231+
232+
formatted.push_str(&format!("## [{}] - \n\n", version.bright_green().bold()));
233+
234+
// Define the order of change types
235+
let ordered_types = [
236+
ChangelogType::Added,
237+
ChangelogType::Changed,
238+
ChangelogType::Fixed,
239+
ChangelogType::Removed,
240+
ChangelogType::Deprecated,
241+
ChangelogType::Security,
242+
];
243+
244+
// Add changes in the specified order
245+
for change_type in &ordered_types {
246+
if let Some(entries) = response.sections.get(change_type) {
247+
if !entries.is_empty() {
248+
formatted.push_str(&format_change_type(change_type));
249+
for entry in entries {
250+
formatted.push_str(&format_change_entry(entry));
251+
}
252+
formatted.push('\n');
80253
}
81-
formatted.push('\n');
82254
}
83255
}
84256

src/changes/cli.rs

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ use std::sync::Arc;
2222
/// * `from` - The starting point (commit or tag) for the changelog.
2323
/// * `to` - The ending point for the changelog. Defaults to "HEAD" if not provided.
2424
/// * `repository_url` - Optional URL of the remote repository to use.
25+
/// * `update_file` - Whether to update the changelog file.
26+
/// * `changelog_path` - Optional path to the changelog file.
2527
///
2628
/// # Returns
2729
///
@@ -31,6 +33,8 @@ pub async fn handle_changelog_command(
3133
from: String,
3234
to: Option<String>,
3335
repository_url: Option<String>,
36+
update_file: bool,
37+
changelog_path: Option<String>,
3438
) -> Result<()> {
3539
// Load and apply configuration
3640
let mut config = Config::load()?;
@@ -62,6 +66,9 @@ pub async fn handle_changelog_command(
6266
Arc::new(GitRepo::new(&repo_path).context("Failed to create GitRepo")?)
6367
};
6468

69+
// Keep a clone of the Arc for updating the changelog later if needed
70+
let git_repo_for_update = Arc::clone(&git_repo);
71+
6572
// Set the default 'to' reference if not provided
6673
let to = to.unwrap_or_else(|| "HEAD".to_string());
6774

@@ -79,6 +86,31 @@ pub async fn handle_changelog_command(
7986
println!("{}", &changelog);
8087
println!("{}", "━".repeat(50).bright_purple());
8188

89+
// Update the changelog file if requested
90+
if update_file {
91+
let path = changelog_path.unwrap_or_else(|| "CHANGELOG.md".to_string());
92+
let update_spinner = ui::create_spinner(&format!("Updating changelog file at {path}..."));
93+
94+
match ChangelogGenerator::update_changelog_file(
95+
&changelog,
96+
&path,
97+
&git_repo_for_update,
98+
&to,
99+
) {
100+
Ok(()) => {
101+
update_spinner.finish_and_clear();
102+
ui::print_success(&format!(
103+
"✨ Changelog successfully updated at {}",
104+
path.bright_green()
105+
));
106+
}
107+
Err(e) => {
108+
update_spinner.finish_and_clear();
109+
ui::print_error(&format!("Failed to update changelog file: {e}"));
110+
}
111+
}
112+
}
113+
82114
Ok(())
83115
}
84116

src/cli.rs

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,14 @@ pub enum Commands {
129129
/// Ending Git reference (commit hash, tag, or branch name). Defaults to HEAD if not specified.
130130
#[arg(long)]
131131
to: Option<String>,
132+
133+
/// Update the changelog file with the new changes
134+
#[arg(long, help = "Update the changelog file with the new changes")]
135+
update: bool,
136+
137+
/// Path to the changelog file
138+
#[arg(long, help = "Path to the changelog file (defaults to CHANGELOG.md)")]
139+
file: Option<String>,
132140
},
133141

134142
/// Generate release notes
@@ -353,14 +361,18 @@ async fn handle_changelog(
353361
from: String,
354362
to: Option<String>,
355363
repository_url: Option<String>,
364+
update: bool,
365+
file: Option<String>,
356366
) -> anyhow::Result<()> {
357367
log_debug!(
358-
"Handling 'changelog' command with common: {:?}, from: {}, to: {:?}",
368+
"Handling 'changelog' command with common: {:?}, from: {}, to: {:?}, update: {}, file: {:?}",
359369
common,
360370
from,
361-
to
371+
to,
372+
update,
373+
file
362374
);
363-
changes::handle_changelog_command(common, from, to, repository_url).await
375+
changes::handle_changelog_command(common, from, to, repository_url, update, file).await
364376
}
365377

366378
/// Handle the `ReleaseNotes` command
@@ -430,8 +442,14 @@ pub async fn handle_command(
430442
log_debug!("Handling 'list_presets' command");
431443
commands::handle_list_presets_command()?;
432444
}
433-
Commands::Changelog { common, from, to } => {
434-
handle_changelog(common, from, to, repository_url).await?;
445+
Commands::Changelog {
446+
common,
447+
from,
448+
to,
449+
update,
450+
file,
451+
} => {
452+
handle_changelog(common, from, to, repository_url, update, file).await?;
435453
}
436454
Commands::ReleaseNotes { common, from, to } => {
437455
handle_release_notes(common, from, to, repository_url).await?;

src/git.rs

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ use crate::context::{ChangeType, CommitContext, ProjectMetadata, RecentCommit, S
33
use crate::file_analyzers::{self, FileAnalyzer, should_exclude_file};
44
use crate::log_debug;
55
use anyhow::{Context, Result, anyhow};
6+
use chrono;
67
use futures::future::join_all;
78
use git2::{DiffOptions, FileMode, Repository, Status, StatusOptions, Tree};
89
use std::env;
@@ -1164,6 +1165,33 @@ impl GitRepo {
11641165

11651166
Ok(file_paths)
11661167
}
1168+
1169+
/// Gets the date of a commit in YYYY-MM-DD format
1170+
///
1171+
/// # Arguments
1172+
///
1173+
/// * `commit_ish` - A commit-ish reference (hash, tag, branch, etc.)
1174+
///
1175+
/// # Returns
1176+
///
1177+
/// A Result containing the formatted date string or an error
1178+
pub fn get_commit_date(&self, commit_ish: &str) -> Result<String> {
1179+
let repo = self.open_repo()?;
1180+
1181+
// Resolve the commit-ish to an actual commit
1182+
let obj = repo.revparse_single(commit_ish)?;
1183+
let commit = obj.peel_to_commit()?;
1184+
1185+
// Get the commit time
1186+
let time = commit.time();
1187+
1188+
// Convert to a chrono::DateTime for easier formatting
1189+
let datetime = chrono::DateTime::<chrono::Utc>::from_timestamp(time.seconds(), 0)
1190+
.ok_or_else(|| anyhow!("Invalid timestamp"))?;
1191+
1192+
// Format as YYYY-MM-DD
1193+
Ok(datetime.format("%Y-%m-%d").to_string())
1194+
}
11671195
}
11681196

11691197
impl Drop for GitRepo {

0 commit comments

Comments
 (0)