Skip to main content

commit_wizard/core/usecases/commit/
make.rs

1use 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    // breaking message validation
32    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    // commit type
45    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    // scope
89    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    // summary
152    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    // breaking details
192    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}