1use std::{
4 cmp::max,
5 collections::{BTreeMap, HashMap},
6 fmt::Display,
7 path::Path,
8};
9
10use gitcc_convco::{ConvcoMessage, DEFAULT_CONVCO_INCR_MINOR_TYPES, DEFAULT_CONVCO_TYPES};
11use gitcc_git::discover_repo;
12use semver::Version;
13use serde::{Deserialize, Serialize};
14use time::OffsetDateTime;
15
16pub use gitcc_git::StatusShow;
17
18use crate::{Config, Error};
19
20#[derive(Debug, Serialize, Deserialize)]
22pub struct CommitConfig {
23 pub types: BTreeMap<String, String>,
25}
26
27impl Default for CommitConfig {
28 fn default() -> Self {
29 Self {
30 types: DEFAULT_CONVCO_TYPES
31 .iter()
32 .map(|(k, v)| (k.to_string(), v.to_string()))
33 .collect(),
34 }
35 }
36}
37
38#[derive(Debug, Serialize, Deserialize)]
40pub struct VersioningConfig {
41 pub types_incr_minor: Vec<String>,
45}
46
47impl Default for VersioningConfig {
48 fn default() -> Self {
49 Self {
50 types_incr_minor: DEFAULT_CONVCO_INCR_MINOR_TYPES
51 .map(|s| s.to_string())
52 .to_vec(),
53 }
54 }
55}
56
57#[derive(Debug)]
63pub struct Commit {
64 pub id: String,
66 pub date: OffsetDateTime,
68 pub author_name: String,
70 pub author_email: String,
72 pub committer_name: String,
74 pub committer_email: String,
76 pub raw_message: String,
78 pub conv_message: Option<ConvcoMessage>,
80 pub tag: Option<gitcc_git::Tag>,
82 pub version_tag: Option<gitcc_git::Tag>,
84}
85
86impl Commit {
87 pub fn short_id(&self) -> String {
89 let mut short_id = self.id.clone();
90 short_id.truncate(7);
91 short_id
92 }
93
94 pub fn subject(&self) -> String {
96 if let Some(line) = self.raw_message.lines().next() {
97 return line.to_string();
98 }
99 unreachable!()
100 }
101}
102
103#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
105pub enum VersionIncr {
106 None,
107 Patch,
108 Minor,
109 Major,
110}
111
112impl Display for VersionIncr {
113 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
114 match self {
115 VersionIncr::None => write!(f, "na"),
116 VersionIncr::Patch => write!(f, "patch"),
117 VersionIncr::Minor => write!(f, "minor"),
118 VersionIncr::Major => write!(f, "major"),
119 }
120 }
121}
122
123impl VersionIncr {
124 fn apply(&self, version: &Option<Version>) -> Version {
126 if let Some(v) = version {
127 if v.major == 0 {
128 match self {
129 VersionIncr::None => v.clone(),
130 VersionIncr::Patch => Version::new(0, v.minor + 1, 0),
131 VersionIncr::Minor => Version::new(0, v.minor + 1, 0),
132 VersionIncr::Major => Version::new(0, v.minor + 1, 0),
133 }
134 } else {
135 match self {
136 VersionIncr::None => v.clone(),
137 VersionIncr::Patch => Version::new(v.major, v.minor, v.patch + 1),
138 VersionIncr::Minor => Version::new(v.major, v.minor + 1, 0),
139 VersionIncr::Major => Version::new(v.major + 1, 0, 0),
140 }
141 }
142 } else {
143 Version::new(0, 1, 0)
144 }
145 }
146}
147
148pub trait ConvcoMessageExt {
150 fn version_incr_kind(&self, cfg: &VersioningConfig) -> VersionIncr;
152}
153
154impl ConvcoMessageExt for ConvcoMessage {
155 fn version_incr_kind(&self, cfg: &VersioningConfig) -> VersionIncr {
156 if self.is_breaking_change() {
157 return VersionIncr::Major;
158 }
159
160 if cfg.types_incr_minor.contains(&self.r#type) {
161 return VersionIncr::Minor;
162 }
163 VersionIncr::Patch
164 }
165}
166
167#[derive(Debug)]
169pub struct CommitHistory {
170 pub commits: Vec<Commit>,
174 pub curr_version: Option<Version>,
176 pub next_version: Version,
178}
179
180impl CommitHistory {
181 pub fn next_version_str(&self) -> String {
182 format!("v{}", self.next_version)
183 }
184}
185
186pub fn git_status(
188 cwd: &Path,
189 show: StatusShow,
190) -> Result<BTreeMap<String, gitcc_git::Status>, Error> {
191 let repo = discover_repo(cwd)?;
192 let files = gitcc_git::repo_status(&repo, show)?;
193 Ok(files)
194}
195
196pub fn git_add_all(cwd: &Path) -> Result<(), Error> {
198 let repo = discover_repo(cwd)?;
199 gitcc_git::add_all(&repo)?;
200 Ok(())
201}
202
203pub fn commit_history(cwd: &Path, cfg: &Config) -> Result<CommitHistory, Error> {
205 let repo = gitcc_git::discover_repo(cwd)?;
206 let git_commits = gitcc_git::commit_log(&repo)?;
207 let map_commit_to_tag: HashMap<_, _> = gitcc_git::get_tag_refs(&repo)?
208 .into_iter()
209 .map(|t| (t.commit_id.clone(), t))
210 .collect();
211
212 let mut commits = Vec::new();
213 let mut curr_version: Option<Version> = None; let mut latest_version_tag: Option<gitcc_git::Tag> = None;
215 let mut unreleased_incr_kind = VersionIncr::None; let mut is_commit_released = false;
217 for c in git_commits {
218 let conv_message = match c.message.parse::<ConvcoMessage>() {
220 Ok(m) => {
221 if !cfg.commit.types.contains_key(&m.r#type) {
222 log::debug!("commit {} has an invalid type: {}", c.id, m.r#type);
223 }
224 Some(m)
225 }
226 Err(err) => {
227 log::debug!(
228 "commit {} does not follow the conventional commit format: {}",
229 c.id,
230 err
231 );
232 None
233 }
234 };
235
236 let tag = map_commit_to_tag.get(&c.id).cloned();
237
238 let mut has_annotated_tag = false;
240 if let Some(tag) = &tag {
241 if tag.is_annotated() {
242 has_annotated_tag = true
243 }
244 }
245 if has_annotated_tag {
246 let tag = tag.clone().unwrap();
247 let tag_name = tag.name.trim();
248 let tag_version = tag_name.strip_prefix('v').unwrap_or(tag_name);
249 match tag_version.parse::<Version>() {
250 Ok(v) => {
251 latest_version_tag = Some(tag);
253 if curr_version.is_none() {
254 curr_version = Some(v);
255 }
256 is_commit_released = true;
257 }
258 Err(err) => {
259 log::debug!(
260 "commit {} has tag {} which is not a semver version: {}",
261 c.id,
262 tag.name,
263 err
264 );
265 }
266 }
267 }
268
269 if !is_commit_released {
271 if let Some(m) = &conv_message {
272 let commit_incr_kind = m.version_incr_kind(&cfg.version);
273 unreleased_incr_kind = max(unreleased_incr_kind, commit_incr_kind);
274 } else {
275 unreleased_incr_kind = max(unreleased_incr_kind, VersionIncr::Patch);
276 }
277 }
278
279 commits.push(Commit {
280 id: c.id,
281 date: c.date,
282 author_name: c.author_name,
283 author_email: c.author_email,
284 committer_name: c.committer_name,
285 committer_email: c.committer_email,
286 raw_message: c.message,
287 conv_message,
288 tag,
289 version_tag: latest_version_tag.clone(),
290 });
291 }
292
293 let next_version = unreleased_incr_kind.apply(&curr_version);
294
295 Ok(CommitHistory {
296 commits,
297 curr_version,
298 next_version,
299 })
300}
301
302pub fn commit_changes(cwd: &Path, message: &str) -> Result<gitcc_git::Commit, Error> {
304 let repo = gitcc_git::discover_repo(cwd)?;
305 Ok(gitcc_git::commit_to_head(&repo, message)?)
306}
307
308#[cfg(test)]
309mod tests {
310 use time::macros::format_description;
311
312 use super::*;
313
314 #[test]
315 fn test_history() {
316 let cwd = std::env::current_dir().unwrap();
317 let cfg = Config::load_from_fs(&cwd).unwrap().unwrap_or_default();
318 let history = commit_history(&cwd, &cfg).unwrap();
319 for c in &history.commits {
320 eprintln!(
321 "{}: {} | {} | {} {}",
322 c.date
323 .format(format_description!("[year]-[month]-[day]"))
324 .unwrap(),
325 c.conv_message
326 .as_ref()
327 .map(|m| m.r#type.clone())
328 .unwrap_or("--".to_string()),
329 if c.version_tag.is_none() {
330 c.conv_message
331 .as_ref()
332 .map(|m| m.version_incr_kind(&cfg.version).to_string())
333 .unwrap_or("--".to_string())
334 } else {
335 "--".to_string()
336 },
337 c.version_tag
338 .as_ref()
339 .map(|t| t.name.to_string())
340 .unwrap_or("unreleased".to_string()),
341 if let Some(tag) = &c.tag {
342 format!("<- {}", &tag.name)
343 } else {
344 "".to_string()
345 }
346 );
347 }
348 eprintln!();
349 eprintln!(
350 "current version: {}",
351 history
352 .curr_version
353 .map(|v| v.to_string())
354 .unwrap_or("unreleased".to_string())
355 );
356 eprintln!("next version: {}", history.next_version);
357 eprintln!();
358 }
359}