Skip to main content

git_claw/workflow/
changelog.rs

1//! Changelog generation and update utilities.
2
3use crate::core::conventional::ConventionalCommit;
4use std::path::Path;
5
6/// Generates a markdown changelog release entry grouped by conventional commit categories.
7///
8/// Formats under `## [<version>] - <date>` followed by categorized commit lists:
9/// - `### Breaking Changes`
10/// - `### Features`
11/// - `### Bug Fixes`
12/// - `### Performance Improvements`
13/// - `### Other Changes`
14///
15/// If a commit has an optional scope, it is formatted as `- **<scope>**: <description>`.
16/// Otherwise, it is formatted as `- <description>`.
17///
18/// # Arguments
19///
20/// * `version` - Target release version string (e.g., "v1.2.0").
21/// * `date` - Formatted release date (e.g., "2026-10-17").
22/// * `commits` - Slice of conventional commits included in the release.
23///
24/// # Example
25///
26/// ```
27/// use git_claw::core::conventional::ConventionalCommit;
28/// use git_claw::workflow::changelog::generate_changelog_entry;
29///
30/// let commits = vec![
31///     ConventionalCommit {
32///         commit_type: "feat".to_string(),
33///         scope: Some("auth".to_string()),
34///         is_breaking: false,
35///         description: "add OAuth2 login".to_string(),
36///     },
37/// ];
38/// let entry = generate_changelog_entry("v1.1.0", "2026-10-17", &commits);
39/// assert!(entry.contains("## [v1.1.0] - 2026-10-17"));
40/// assert!(entry.contains("### Features"));
41/// assert!(entry.contains("- **auth**: add OAuth2 login"));
42/// ```
43pub 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
92/// Updates or creates `CHANGELOG.md` in the specified repository root.
93///
94/// If `CHANGELOG.md` exists:
95/// - If it contains `# Changelog`, inserts the release entry immediately after the title line.
96/// - Otherwise, prepends the release entry before existing contents.
97///
98/// If `CHANGELOG.md` does not exist, creates it with a `# Changelog` heading and the entry.
99///
100/// # Arguments
101///
102/// * `repo_root` - Path to the repository root.
103/// * `entry` - Release entry markdown string to insert.
104///
105/// # Example
106///
107/// ```no_run
108/// use std::path::Path;
109/// use git_claw::workflow::changelog::update_changelog_file;
110///
111/// # fn run() -> std::io::Result<()> {
112/// update_changelog_file(Path::new("."), "## [v1.0.0] - 2026-10-17\n\n### Features\n- init\n")?;
113/// # Ok(())
114/// # }
115/// ```
116pub 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
154/// Returns the current UTC date formatted as `YYYY-MM-DD`.
155///
156/// Computes date from system time using Gregorian calendar arithmetic without external dependencies.
157///
158/// # Example
159///
160/// ```
161/// use git_claw::workflow::changelog::current_date_ymd;
162///
163/// let today = current_date_ymd();
164/// assert_eq!(today.len(), 10);
165/// assert_eq!(&today[4..5], "-");
166/// assert_eq!(&today[7..8], "-");
167/// ```
168pub 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}