gitcc_core/
changelog.rs

1//! Changelog
2
3use std::path::Path;
4
5use gitcc_changelog::{Changelog, Release, Section};
6use gitcc_git::{discover_repo, get_origin_url};
7use indexmap::{indexmap, IndexMap};
8use itertools::Itertools;
9use serde::{Deserialize, Serialize};
10use time::OffsetDateTime;
11
12use crate::{Commit, CommitHistory, Config, Error};
13
14/// Changelog configuration
15#[derive(Debug, Serialize, Deserialize)]
16pub struct ChangelogConfig {
17    /// Release sections
18    ///
19    /// The key is the group label, and the value is a list of commit types
20    ///
21    /// # Notes
22    ///
23    /// [IndexMap] is used to maintain an order of groups
24    pub sections: IndexMap<String, Vec<String>>,
25}
26
27impl Default for ChangelogConfig {
28    fn default() -> Self {
29        let sections = indexmap! {
30            "New features".to_string() => vec!["feat".to_string()],
31            "Bug fixes".to_string() => vec!["fix".to_string()],
32            "Documentation".to_string() => vec!["docs".to_string()],
33            "Performance improvements".to_string() => vec!["perf".to_string()],
34            "Tooling".to_string() => vec![
35                "build".to_string(),
36                "ci".to_string(),
37                "cd".to_string()
38            ],
39        };
40        Self { sections }
41    }
42}
43
44impl ChangelogConfig {
45    /// Returns the section label for a specific commit type
46    fn find_section_for_commit_type(&self, r#type: &str) -> Option<String> {
47        for (section_label, commit_types) in &self.sections {
48            if commit_types.contains(&r#type.to_string()) {
49                return Some(section_label.clone());
50            }
51        }
52        None
53    }
54}
55
56/// Changelog build options
57#[derive(Debug, Clone, Default)]
58pub struct ChangelogBuildOptions {
59    /// Origin name (`origin` by default)
60    pub origin_name: Option<String>,
61    /// Includes all commits
62    pub all: bool,
63    /// Next version
64    pub next_version: Option<String>,
65}
66
67/// Builds the changelog
68///
69/// # Arguments
70/// - the `origin` is the origin name (`origin` by default)
71pub fn build_changelog(
72    cwd: &Path,
73    cfg: &Config,
74    history: &CommitHistory,
75    opts: Option<ChangelogBuildOptions>,
76) -> Result<Changelog, Error> {
77    let opts = opts.unwrap_or_default();
78    let repo = discover_repo(cwd)?;
79
80    let origin_name = opts.origin_name.unwrap_or("origin".to_owned());
81    let origin_url = get_origin_url(&repo, &origin_name)?.ok_or(Error::msg(
82        format!("remote origin '{origin_name}' not found").as_str(),
83    ))?;
84
85    let mut releases = vec![];
86    for (release_tag, release_commits) in history
87        .commits
88        .iter()
89        .group_by(|c| c.version_tag.clone())
90        .into_iter()
91    {
92        // eprintln!(
93        //     "RELEASE: {}",
94        //     release_tag
95        //         .as_ref()
96        //         .map(|t| t.name.to_string())
97        //         .unwrap_or("unreleased".to_string())
98        // );
99
100        let mut sections: IndexMap<String, Section> = IndexMap::new();
101        for (s_label, _) in &cfg.changelog.sections {
102            sections.insert(s_label.to_string(), Section::new(s_label));
103        }
104        const UNCATEGORIZED: &str = "Uncategorized"; // commits with no type
105        const HIDDEN: &str = "__Hidden__"; // ignored commits (types not included)
106        sections.insert(UNCATEGORIZED.to_string(), Section::new(UNCATEGORIZED));
107        sections.insert(HIDDEN.to_string(), Section::new(HIDDEN));
108
109        for c in release_commits {
110            // eprintln!("{}", c.subject());
111            let c_sect_label = match &c.conv_message {
112                Some(m) => {
113                    // eprint!("=> is conventional: {}", m.r#type);
114                    match cfg.changelog.find_section_for_commit_type(&m.r#type) {
115                        Some(label) => {
116                            // eprintln!("=> section: {}", label);
117                            label
118                        }
119                        None => {
120                            // eprintln!("=> HIDDEN");
121                            HIDDEN.to_string()
122                        }
123                    }
124                }
125                None => UNCATEGORIZED.to_string(),
126            };
127
128            if c_sect_label == HIDDEN && !opts.all {
129                continue;
130            }
131
132            let section = sections.get_mut(&c_sect_label).unwrap();
133            section.items.push(commit_oneliner(&origin_url, c));
134        }
135
136        // remove empty sections
137        let sections: Vec<_> = sections
138            .into_iter()
139            .filter_map(|(_, v)| if v.items.is_empty() { None } else { Some(v) })
140            .collect();
141
142        let release_version = release_tag
143            .as_ref()
144            .map(|t| t.name.to_string())
145            .unwrap_or_else(|| {
146                if let Some(next_version) = &opts.next_version {
147                    next_version.to_string()
148                } else {
149                    "Unreleased".to_string()
150                }
151            });
152        let release_date = release_tag
153            .as_ref()
154            .map(|t| t.date)
155            .unwrap_or(OffsetDateTime::now_utc());
156        let release_url = release_tag
157            .as_ref()
158            .map(|t| build_release_url(&origin_url, &t.name));
159
160        let release = Release {
161            version: release_version,
162            date: release_date,
163            url: release_url,
164            sections,
165        };
166        releases.push(release);
167    }
168
169    Ok(Changelog { releases })
170}
171
172/// Builds the release URL
173///
174/// eg. https://github.com/nlargueze/gitcc/releases/tag/v0.0.15
175/// eg. https://github.com/nlargueze/repo/compare/v0.1.1...v0.1.2
176fn build_release_url(origin_url: &str, version: &str) -> String {
177    format!("{origin_url}/releases/tag/{version}")
178}
179
180/// Builders the commit
181///
182/// eg: chore!: refactoring [#e88da](https://github.com/nlargueze/repo/commit/e88dae6d48fd85b094f58eab029a883969436101)
183fn commit_oneliner(origin_url: &str, commit: &Commit) -> String {
184    format!(
185        "{} [{}]({}/commit/{})",
186        commit.subject(),
187        commit.short_id(),
188        origin_url,
189        commit.id
190    )
191}
192
193#[cfg(test)]
194mod tests {
195    use crate::commit_history;
196
197    use super::*;
198
199    #[test]
200    fn test_changelog() {
201        let cwd = std::env::current_dir().unwrap();
202        let cfg = Config::load_from_fs(&cwd).unwrap().unwrap_or_default();
203        let history = commit_history(&cwd, &cfg).unwrap();
204        let _changelog = build_changelog(&cwd, &cfg, &history, None).unwrap();
205        // eprintln!("{:#?}", changelog);
206        // let changelog_str = changelog.generate(TEMPLATE_CHANGELOG_STD).unwrap();
207        // eprintln!("{}", changelog_str);
208    }
209}