project_version/
changelog.rs

1use anyhow::{Context, Result};
2use chrono::Local;
3use log::{debug, warn};
4use regex::Regex;
5use semver::Version;
6use std::fs;
7use std::path::{Path, PathBuf};
8
9// Regex patterns for finding changelog files (case-insensitive)
10const CHANGELOG_PATTERNS: [&str; 3] = [
11    r"(?i)^changelog(\.md)?$", // CHANGELOG.md, Changelog.md, changelog, etc.
12    r"(?i)^changes(\.md)?$",   // CHANGES.md, Changes.md, changes, etc.
13    r"(?i)^history(\.md)?$",   // HISTORY.md, History.md, etc.
14];
15
16// Regex patterns for unreleased section headers
17// Using (?m) for multiline mode so ^ matches start of any line
18const UNRELEASED_PATTERNS: [&str; 3] = [
19    r"(?mi)^##\s*\[unreleased\]",            // ## [Unreleased]
20    r"(?mi)^##\s+unreleased",                // ## Unreleased
21    r"(?mi)^\[unreleased\](?:\s*)?(?:\n|$)", // [Unreleased] at start of line
22];
23
24/// Find a changelog file in the specified directory
25pub fn find_changelog(dir: &str) -> Option<PathBuf> {
26    let dir_path = Path::new(dir);
27
28    if let Ok(entries) = fs::read_dir(dir_path) {
29        for entry in entries.filter_map(Result::ok) {
30            let file_name = entry.file_name();
31            let file_name_str = file_name.to_string_lossy();
32
33            for pattern in &CHANGELOG_PATTERNS {
34                let re = Regex::new(pattern).unwrap();
35                if re.is_match(&file_name_str) {
36                    let path = entry.path();
37                    debug!("Found changelog: {}", path.display());
38                    return Some(path);
39                }
40            }
41        }
42    }
43
44    None
45}
46
47/// Update the changelog by replacing the unreleased section with the new version
48pub fn update_changelog(path: &Path, version: &Version) -> Result<()> {
49    let content = fs::read_to_string(path).context("Failed to read changelog file")?;
50
51    let today = Local::now().format("%Y-%m-%d").to_string();
52
53    // Try to find and replace an unreleased section using regex
54    let mut new_content = content.clone();
55    let mut found = false;
56
57    // Format the version header
58    let version_header = format!("## [{}] - {}", version, today);
59
60    // Try each pattern until one matches
61    for pattern in &UNRELEASED_PATTERNS {
62        // Compile the regex with proper flags
63        let re = match Regex::new(pattern) {
64            Ok(re) => re,
65            Err(e) => {
66                warn!("Invalid regex pattern: {} - {}", pattern, e);
67                continue;
68            }
69        };
70
71        // Check if this pattern matches
72        if re.is_match(&content) {
73            // It matched! Replace the first occurrence only
74            new_content = re.replace(&content, &version_header).to_string();
75            found = true;
76            debug!(
77                "Matched pattern: {} - replacing with: {}",
78                pattern, version_header
79            );
80            break;
81        }
82    }
83
84    if !found {
85        warn!(
86            "No unreleased section found in changelog at {}",
87            path.display()
88        );
89        // We'll just keep the file as is to avoid incorrect modifications
90    } else {
91        // Write the updated content back to the file
92        fs::write(path, new_content).context("Failed to write updated changelog")?;
93        debug!(
94            "Updated unreleased section to version {} in {}",
95            version,
96            path.display()
97        );
98    }
99
100    Ok(())
101}
102
103/// Preview the changelog update without making changes (dry run)
104pub fn dry_run_update_changelog(path: &Path, version: &Version) -> Result<String> {
105    let content = fs::read_to_string(path).context("Failed to read changelog file")?;
106
107    let today = Local::now().format("%Y-%m-%d").to_string();
108    let mut diff = String::new();
109    let mut found = false;
110
111    let version_header = format!("## [{}] - {}", version, today);
112
113    // Try each pattern until one matches
114    for pattern in &UNRELEASED_PATTERNS {
115        // Compile the regex with proper flags
116        let re = match Regex::new(pattern) {
117            Ok(re) => re,
118            Err(e) => {
119                warn!("Invalid regex pattern: {} - {}", pattern, e);
120                continue;
121            }
122        };
123
124        if let Some(m) = re.find(&content) {
125            let header = m.as_str();
126
127            diff = format!(
128                "Would update changelog {}:\n  {} → {}",
129                path.display(),
130                header.trim(),
131                version_header
132            );
133            found = true;
134            break;
135        }
136    }
137
138    if !found {
139        diff = format!(
140            "No unreleased section found in changelog {}",
141            path.display()
142        );
143    }
144
145    Ok(diff)
146}