git_claw/workflow/
changelog.rs1use crate::core::conventional::ConventionalCommit;
4use std::path::Path;
5
6pub fn generate_changelog_entry(
44 version: &str,
45 date: &str,
46 commits: &[ConventionalCommit],
47) -> String {
48 let mut breaking = Vec::new();
49 let mut features = Vec::new();
50 let mut fixes = Vec::new();
51 let mut perf = Vec::new();
52 let mut other = Vec::new();
53
54 for commit in commits {
55 if commit.is_breaking {
56 breaking.push(commit);
57 } else {
58 match commit.commit_type.as_str() {
59 "feat" => features.push(commit),
60 "fix" => fixes.push(commit),
61 "perf" => perf.push(commit),
62 _ => other.push(commit),
63 }
64 }
65 }
66
67 let mut entry = format!("## [{}] - {}\n", version, date);
68 let sections = [
69 ("Breaking Changes", breaking),
70 ("Features", features),
71 ("Bug Fixes", fixes),
72 ("Performance Improvements", perf),
73 ("Other Changes", other),
74 ];
75
76 for (title, list) in sections {
77 if !list.is_empty() {
78 entry.push_str(&format!("\n### {}\n\n", title));
79 for c in list {
80 if let Some(scope) = &c.scope {
81 entry.push_str(&format!("- **{}**: {}\n", scope, c.description));
82 } else {
83 entry.push_str(&format!("- {}\n", c.description));
84 }
85 }
86 }
87 }
88
89 entry
90}
91
92pub fn update_changelog_file(repo_root: &Path, entry: &str) -> std::io::Result<()> {
117 let changelog_path = repo_root.join("CHANGELOG.md");
118 let trimmed_entry = entry.trim();
119
120 if changelog_path.exists() {
121 let content = std::fs::read_to_string(&changelog_path)?;
122 let new_content = if let Some(idx) = content.find("# Changelog") {
123 let line_end = content[idx..]
124 .find('\n')
125 .map(|i| idx + i + 1)
126 .unwrap_or(content.len());
127 let prefix = &content[..line_end];
128 let suffix = &content[line_end..];
129 let suffix_clean = suffix.trim_start_matches(['\r', '\n']);
130 if suffix_clean.is_empty() {
131 format!("{}\n\n{}\n", prefix.trim_end(), trimmed_entry)
132 } else {
133 format!(
134 "{}\n\n{}\n\n{}",
135 prefix.trim_end(),
136 trimmed_entry,
137 suffix_clean
138 )
139 }
140 } else {
141 format!("{}\n\n{}", trimmed_entry, content)
142 };
143 std::fs::write(&changelog_path, new_content)?;
144 } else {
145 std::fs::write(
146 &changelog_path,
147 format!("# Changelog\n\n{}\n", trimmed_entry),
148 )?;
149 }
150
151 Ok(())
152}
153
154pub fn current_date_ymd() -> String {
169 use std::time::{SystemTime, UNIX_EPOCH};
170 let secs = SystemTime::now()
171 .duration_since(UNIX_EPOCH)
172 .unwrap_or_default()
173 .as_secs();
174 let days = (secs / 86400) as i64;
175 let z = days + 719468;
176 let era = if z >= 0 { z } else { z - 146096 } / 146097;
177 let doe = (z - era * 146097) as u32;
178 let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
179 let y = (yoe as i64) + era * 400;
180 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
181 let mp = (5 * doy + 2) / 153;
182 let d = doy - (153 * mp + 2) / 5 + 1;
183 let m = if mp < 10 { mp + 3 } else { mp - 9 };
184 let y = if m <= 2 { y + 1 } else { y };
185 format!("{:04}-{:02}-{:02}", y, m, d)
186}
187
188#[cfg(test)]
189mod tests {
190 use super::*;
191 use tempfile::tempdir;
192
193 #[test]
194 fn test_generate_changelog_entry_grouping() {
195 let commits = vec![
196 ConventionalCommit {
197 commit_type: "fix".to_string(),
198 scope: None,
199 is_breaking: true,
200 description: "break everything".to_string(),
201 },
202 ConventionalCommit {
203 commit_type: "feat".to_string(),
204 scope: Some("auth".to_string()),
205 is_breaking: false,
206 description: "support jwt".to_string(),
207 },
208 ConventionalCommit {
209 commit_type: "fix".to_string(),
210 scope: None,
211 is_breaking: false,
212 description: "null pointer exception".to_string(),
213 },
214 ConventionalCommit {
215 commit_type: "perf".to_string(),
216 scope: Some("db".to_string()),
217 is_breaking: false,
218 description: "index lookup".to_string(),
219 },
220 ConventionalCommit {
221 commit_type: "chore".to_string(),
222 scope: None,
223 is_breaking: false,
224 description: "update license".to_string(),
225 },
226 ];
227
228 let entry = generate_changelog_entry("v1.0.0", "2026-10-17", &commits);
229 assert!(entry.starts_with("## [v1.0.0] - 2026-10-17\n"));
230 assert!(entry.contains("### Breaking Changes\n\n- break everything\n"));
231 assert!(entry.contains("### Features\n\n- **auth**: support jwt\n"));
232 assert!(entry.contains("### Bug Fixes\n\n- null pointer exception\n"));
233 assert!(entry.contains("### Performance Improvements\n\n- **db**: index lookup\n"));
234 assert!(entry.contains("### Other Changes\n\n- update license\n"));
235 }
236
237 #[test]
238 fn test_update_changelog_file_fresh() {
239 let dir = tempdir().unwrap();
240 let entry = "## [v0.1.0] - 2026-10-17\n\n### Features\n\n- init";
241 update_changelog_file(dir.path(), entry).unwrap();
242
243 let content = std::fs::read_to_string(dir.path().join("CHANGELOG.md")).unwrap();
244 assert_eq!(
245 content,
246 "# Changelog\n\n## [v0.1.0] - 2026-10-17\n\n### Features\n\n- init\n"
247 );
248 }
249
250 #[test]
251 fn test_update_changelog_file_existing_with_header() {
252 let dir = tempdir().unwrap();
253 let initial =
254 "# Changelog\n\n## [v0.1.0] - 2026-10-16\n\n### Features\n\n- initial release\n";
255 std::fs::write(dir.path().join("CHANGELOG.md"), initial).unwrap();
256
257 let entry = "## [v0.2.0] - 2026-10-17\n\n### Features\n\n- new feature";
258 update_changelog_file(dir.path(), entry).unwrap();
259
260 let content = std::fs::read_to_string(dir.path().join("CHANGELOG.md")).unwrap();
261 let expected = "# Changelog\n\n## [v0.2.0] - 2026-10-17\n\n### Features\n\n- new feature\n\n## [v0.1.0] - 2026-10-16\n\n### Features\n\n- initial release\n";
262 assert_eq!(content, expected);
263 }
264
265 #[test]
266 fn test_current_date_ymd_format() {
267 let date = current_date_ymd();
268 assert_eq!(date.len(), 10);
269 let parts: Vec<&str> = date.split('-').collect();
270 assert_eq!(parts.len(), 3);
271 assert_eq!(parts[0].len(), 4);
272 assert_eq!(parts[1].len(), 2);
273 assert_eq!(parts[2].len(), 2);
274 }
275}