commit_wizard/core/usecases/commit/
make.rs1use std::time::Instant;
2
3use scriba::{SelectOption, SelectRequest};
4
5use crate::{
6 core::{Context, CoreResult},
7 engine::{
8 ErrorCode, PromptTrait,
9 constants::emoji::{ERROR, PREVIEW, SUCCESS},
10 },
11};
12
13#[allow(clippy::too_many_arguments)]
14pub fn run(
15 ctx: &Context,
16 allow_empty: bool,
17 commit_type: Option<String>,
18 scope: Option<String>,
19 message: Option<String>,
20 breaking: bool,
21 breaking_message: Option<String>,
22 body: Option<String>,
23 footer: Vec<String>,
24) -> CoreResult<()> {
25 let ui = ctx.ui();
26 let start = Instant::now();
27 let policy = &ctx.policy().commit;
28
29 let non_interactive = !ctx.is_interactive() || (commit_type.is_some() && message.is_some());
30
31 if breaking
33 && breaking_message
34 .as_ref()
35 .map(|s| s.trim().is_empty())
36 .unwrap_or(true)
37 {
38 return Err(ErrorCode::InvalidInput.error().with_context(
39 "reason",
40 "breaking change flag was provided but no breaking message was supplied",
41 ));
42 }
43
44 let commit_type = match commit_type {
46 Some(value) => {
47 if !policy.allows_type(&value) {
48 return Err(ErrorCode::InvalidInput
49 .error()
50 .with_context("field", "type")
51 .with_context("value", value));
52 }
53 value
54 }
55 None if non_interactive => {
56 return Err(ErrorCode::InvalidInput
57 .error()
58 .with_context("field", "type")
59 .with_context("reason", "type is required in non-interactive mode"));
60 }
61 None => {
62 let request = SelectRequest::new(
63 "What type of change are you committing?".to_string(),
64 policy
65 .types
66 .iter()
67 .map(|t| {
68 let label = if policy.use_emojis && t.emoji.is_some() {
69 format!("{} {}", t.emoji.as_deref().unwrap(), t.key)
70 } else {
71 t.key.clone()
72 };
73 SelectOption::new(t.key.clone(), label)
74 .description(t.description.as_deref().unwrap_or_default().to_string())
75 })
76 .collect(),
77 )
78 .with_page_size(10);
79
80 ui.select(&request).map_err(|_| {
81 ErrorCode::InvalidInput
82 .error()
83 .with_context("field", "type")
84 })?
85 }
86 };
87
88 let scope: Option<String> = match scope {
90 Some(value) => {
91 if policy.restrict_scopes_to_defined && !policy.allows_scope(&value) {
92 return Err(ErrorCode::InvalidInput
93 .error()
94 .with_context("field", "scope")
95 .with_context("value", value));
96 }
97 Some(value)
98 }
99 None if policy.header_format.require_scope && non_interactive => {
100 return Err(ErrorCode::InvalidInput
101 .error()
102 .with_context("field", "scope")
103 .with_context(
104 "reason",
105 "scope is required by policy in non-interactive mode",
106 ));
107 }
108 None if policy.header_format.require_scope => {
109 let value = ui.text("Scope", None, Some("Required by current commit policy"))?;
110 let trimmed: String = value.trim().to_string();
111
112 if trimmed.is_empty() {
113 return Err(ErrorCode::InvalidInput
114 .error()
115 .with_context("field", "scope")
116 .with_context("reason", "scope is required by policy"));
117 }
118
119 if policy.restrict_scopes_to_defined && !policy.allows_scope(&trimmed) {
120 return Err(ErrorCode::InvalidInput
121 .error()
122 .with_context("field", "scope")
123 .with_context("value", trimmed));
124 }
125
126 Some(trimmed)
127 }
128 None if non_interactive => None,
129 None => {
130 if ui.confirm("Add a scope?", false)? {
131 let value = ui.text("Scope", None, Some("Leave empty to omit"))?;
132 let trimmed: String = value.trim().to_string();
133
134 if trimmed.is_empty() {
135 None
136 } else {
137 if policy.restrict_scopes_to_defined && !policy.allows_scope(&trimmed) {
138 return Err(ErrorCode::InvalidInput
139 .error()
140 .with_context("field", "scope")
141 .with_context("value", trimmed));
142 }
143 Some(trimmed)
144 }
145 } else {
146 None
147 }
148 }
149 };
150
151 let summary = match message {
153 Some(value) if value.trim().is_empty() => {
154 return Err(ErrorCode::InvalidInput
155 .error()
156 .with_context("field", "message")
157 .with_context("reason", "commit summary cannot be empty"));
158 }
159 Some(value) => value.trim().to_string(),
160 None if non_interactive => {
161 return Err(ErrorCode::InvalidInput
162 .error()
163 .with_context("field", "message")
164 .with_context("reason", "message is required in non-interactive mode"));
165 }
166 None => loop {
167 let value = ui.text(
168 "Write a short summary",
169 None,
170 Some("Imperative tone, e.g. 'add parser'"),
171 )?;
172 let trimmed = value.trim().to_string();
173 if !trimmed.is_empty() {
174 break trimmed;
175 }
176 ui.logger().warn("Summary cannot be empty");
177 },
178 };
179
180 let subject_max_length = usize::try_from(policy.subject_max_length).unwrap();
181
182 if summary.len() > subject_max_length {
183 return Err(ErrorCode::InvalidInput
184 .error()
185 .with_context("field", "message")
186 .with_context("reason", "commit summary exceeds configured max length")
187 .with_context("length", summary.len().to_string())
188 .with_context("max", policy.subject_max_length.to_string()));
189 }
190
191 let (breaking, breaking_message) = if breaking {
193 (true, breaking_message.map(|s| s.trim().to_string()))
194 } else if non_interactive {
195 (false, None)
196 } else if ui.confirm("Does this commit include breaking changes?", false)? {
197 let msg = ui.text(
198 "Describe the breaking change",
199 None,
200 Some("Required when marking a commit as breaking"),
201 )?;
202 let trimmed = msg.trim().to_string();
203 if trimmed.is_empty() {
204 return Err(ErrorCode::InvalidInput
205 .error()
206 .with_context("field", "breaking_message")
207 .with_context("reason", "breaking change description cannot be empty"));
208 }
209 (true, Some(trimmed))
210 } else {
211 (false, None)
212 };
213
214 let body = match body {
215 Some(value) if value.trim().is_empty() => None,
216 Some(value) => Some(value.trim().to_string()),
217 None if non_interactive => None,
218 None => {
219 if ui.confirm("Add a longer description?", false)? {
220 let value = ui.text("Body", None, Some("Optional detailed description"))?;
221 let trimmed = value.trim().to_string();
222 if trimmed.is_empty() {
223 None
224 } else {
225 Some(trimmed)
226 }
227 } else {
228 None
229 }
230 }
231 };
232
233 let footers = if !footer.is_empty() || non_interactive {
234 footer
235 .into_iter()
236 .map(|v| v.trim().to_string())
237 .filter(|v| !v.is_empty())
238 .collect::<Vec<_>>()
239 } else {
240 let mut lines = Vec::new();
241 if ui.confirm("Add footer lines?", false)? {
242 loop {
243 let value = ui.text(
244 "Footer",
245 None,
246 Some("Examples: Closes #123, Co-authored-by: Name <email>"),
247 )?;
248 let trimmed = value.trim().to_string();
249 if trimmed.is_empty() {
250 break;
251 }
252 lines.push(trimmed);
253 }
254 }
255 lines
256 };
257
258 let type_emoji = policy
259 .find_type(&commit_type)
260 .and_then(|t| t.emoji.as_deref());
261
262 let header = build_header(
263 &commit_type,
264 scope.as_deref(),
265 &summary,
266 breaking,
267 type_emoji,
268 policy.use_emojis,
269 );
270 let commit_message = build_commit_message(
271 &header,
272 body.as_deref(),
273 if breaking {
274 breaking_message.as_deref()
275 } else {
276 None
277 },
278 &footers,
279 policy.breaking_footer_required,
280 &policy.breaking_footer_key,
281 );
282
283 ui.logger().heading(&format!("{} Commit Preview", PREVIEW));
284 eprintln!("-------------------------");
285 for line in commit_message.lines() {
286 eprintln!("{}", line);
287 }
288 eprintln!("-------------------------");
289 eprintln!();
290
291 let duration_ms = start.elapsed().as_millis() as u64;
292
293 if ctx.dry_run() {
294 let meta = ui
295 .new_output_meta()
296 .with_duration_ms(duration_ms)
297 .with_timestamp(chrono::Utc::now().to_string())
298 .with_command("commit".to_string())
299 .with_dry_run(true);
300
301 let content = ui
302 .new_output_content()
303 .title(format!("{} Commit Preview", PREVIEW))
304 .subtitle("Dry run: no commit was created")
305 .data("type", commit_type)
306 .data("scope", scope.unwrap_or_default())
307 .data("breaking", breaking.to_string())
308 .data("message", commit_message);
309
310 return ui.print_with_meta(&content, Some(&meta), true);
311 }
312
313 if !non_interactive && !ui.confirm("Create this commit?", true)? {
314 ui.logger().warn(&format!("{} Commit cancelled", ERROR));
315 return Ok(());
316 }
317
318 ctx.git().commit(&commit_message, allow_empty)?;
319
320 let meta = ui
321 .new_output_meta()
322 .with_duration_ms(duration_ms)
323 .with_timestamp(chrono::Utc::now().to_string())
324 .with_command("commit".to_string())
325 .with_dry_run(false);
326
327 let content = ui
328 .new_output_content()
329 .title(format!("{} Commit Created", SUCCESS))
330 .subtitle("Git commit completed successfully")
331 .data("type", commit_type)
332 .data("scope", scope.unwrap_or_default())
333 .data("breaking", breaking.to_string())
334 .data("header", header);
335
336 ui.print_with_meta(&content, Some(&meta), true)
337}
338
339fn build_header(
340 commit_type: &str,
341 scope: Option<&str>,
342 summary: &str,
343 breaking: bool,
344 emoji: Option<&str>,
345 use_emojis: bool,
346) -> String {
347 let type_prefix = if let (true, Some(e)) = (use_emojis, emoji) {
348 format!("{e} {commit_type}")
349 } else {
350 commit_type.to_string()
351 };
352
353 match (scope, breaking) {
354 (Some(scope), true) => format!("{type_prefix}({scope})!: {summary}"),
355 (Some(scope), false) => format!("{type_prefix}({scope}): {summary}"),
356 (None, true) => format!("{type_prefix}!: {summary}"),
357 (None, false) => format!("{type_prefix}: {summary}"),
358 }
359}
360
361fn build_commit_message(
362 header: &str,
363 body: Option<&str>,
364 breaking_message: Option<&str>,
365 footers: &[String],
366 breaking_footer_required: bool,
367 breaking_footer_key: &str,
368) -> String {
369 let mut out = String::from(header);
370
371 if let Some(body) = body {
372 let trimmed = body.trim();
373 if !trimmed.is_empty() {
374 out.push_str("\n\n");
375 out.push_str(trimmed);
376 }
377 }
378
379 if let Some(message) = breaking_message {
380 let trimmed = message.trim();
381 if !trimmed.is_empty() && breaking_footer_required {
382 out.push_str("\n\n");
383 out.push_str(breaking_footer_key);
384 out.push_str(": ");
385 out.push_str(trimmed);
386 }
387 }
388
389 for footer in footers {
390 let trimmed = footer.trim();
391 if !trimmed.is_empty() {
392 out.push('\n');
393 out.push_str(trimmed);
394 }
395 }
396
397 out
398}