commit_wizard/core/usecases/version/
bump.rs1use crate::{
2 core::{Context, CoreResult},
3 engine::{
4 capabilities::versioning,
5 constants::emoji::{ARROW, SUCCESS},
6 error::ErrorCode,
7 models::policy::{
8 commit::CommitModel,
9 enforcement::BumpLevel,
10 versioning::{Version, VersioningModel},
11 },
12 },
13};
14use std::time::Instant;
15
16pub fn run(ctx: &Context, from: Option<String>, to: String, tail: Option<u32>) -> CoreResult<()> {
17 let ui = ctx.ui();
18 let start = Instant::now();
19
20 let resolved_config = ctx.config().ok_or_else(|| {
22 ErrorCode::ConfigUnreadable
23 .error()
24 .with_context("context", "Config not resolved")
25 })?;
26
27 let versioning_model = VersioningModel::from_config(resolved_config);
28 let commit_model = CommitModel::from_config(resolved_config);
29
30 let git = ctx.git();
32
33 let last_tag = git.latest_tag().map_err(|_| {
35 ErrorCode::GitCommandFailed
36 .error()
37 .with_context("operation", "get latest tag")
38 })?;
39
40 let current_version = last_tag
42 .as_ref()
43 .and_then(|tag| Version::parse(tag, &versioning_model.tag_prefix));
44
45 let from_ref = from.clone().or_else(|| last_tag.clone());
47
48 let commits = git.list_commits(from_ref.as_deref(), &to).map_err(|_| {
50 ErrorCode::GitCommandFailed
51 .error()
52 .with_context("operation", "list commits")
53 })?;
54
55 let commits = if let Some(n) = tail {
57 if n == 0 {
58 commits
59 } else {
60 let n = n as usize;
61 if commits.len() > n {
62 commits[commits.len() - n..].to_vec()
63 } else {
64 commits
65 }
66 }
67 } else {
68 commits
69 };
70
71 let classified = versioning::classify_commits(&commits, &commit_model);
73
74 let next_version = versioning::calculate_next_version(current_version.clone(), &classified);
76
77 let current_str = current_version
79 .as_ref()
80 .map(|v| v.to_semver())
81 .unwrap_or_else(|| "initial".to_string());
82 let next_str = next_version.to_semver();
83 ui.logger().ok(&format!(
84 "{} Version bump: {} {} {}",
85 SUCCESS, current_str, ARROW, next_str
86 ));
87
88 let duration_ms = start.elapsed().as_millis() as u64;
90 let meta = ui
91 .new_output_meta()
92 .with_duration_ms(duration_ms)
93 .with_timestamp(chrono::Utc::now().to_string())
94 .with_command("bump".to_string())
95 .with_dry_run(ctx.dry_run());
96
97 let mut content = ui
99 .new_output_content()
100 .title("Version Bump Analysis")
101 .subtitle("Semantic version calculation from commits")
102 .data("commits_analyzed", commits.len().to_string())
103 .data("next_version", next_version.to_semver())
104 .data(
105 "next_tag",
106 next_version.format(&versioning_model.tag_prefix),
107 );
108
109 if let Some(tag) = &last_tag {
110 content = content.data(
111 "current_tag",
112 current_version
113 .as_ref()
114 .map(|v| v.format(&versioning_model.tag_prefix))
115 .unwrap_or_else(|| tag.clone()),
116 );
117 if let Some(version) = current_version.as_ref() {
118 content = content.data("current_version", version.to_semver());
119 }
120 } else {
121 content = content.data("current_version", "initial");
122 }
123
124 if let Some(from) = &from {
125 content = content.data("from", from.clone());
126 }
127 content = content.data("to", to.clone());
128 if let Some(tail) = tail {
129 content = content.data("tail", tail.to_string());
130 }
131
132 let mut major_commits: Vec<String> = Vec::new();
134 let mut minor_commits: Vec<String> = Vec::new();
135 let mut patch_commits: Vec<String> = Vec::new();
136 let mut none_commits: Vec<String> = Vec::new();
137
138 for (commit, bump) in &classified {
139 let line = format!(
140 "{} {}",
141 &commit.hash[..8.min(commit.hash.len())],
142 &commit.summary
143 );
144 match bump {
145 BumpLevel::Major => major_commits.push(line),
146 BumpLevel::Minor => minor_commits.push(line),
147 BumpLevel::Patch => patch_commits.push(line),
148 BumpLevel::None => none_commits.push(line),
149 }
150 }
151
152 if !major_commits.is_empty() {
153 content = content.section(
154 "Major Changes (Breaking)",
155 major_commits.join("\n"),
156 "sh".to_string(),
157 );
158 }
159 if !minor_commits.is_empty() {
160 content = content.section(
161 "Minor Changes (Features)",
162 minor_commits.join("\n"),
163 "sh".to_string(),
164 );
165 }
166 if !patch_commits.is_empty() {
167 content = content.section(
168 "Patch Changes (Fixes)",
169 patch_commits.join("\n"),
170 "sh".to_string(),
171 );
172 }
173 if !none_commits.is_empty() {
174 content = content.section(
175 "Unclassified Commits",
176 none_commits.join("\n"),
177 "sh".to_string(),
178 );
179 }
180
181 let plain = next_version.to_semver();
183 content = content.plain(plain);
184
185 let success = !classified.is_empty() || current_version.is_some();
186 ui.print_with_meta(&content, Some(&meta), success)
187}