1use std::ffi::OsString;
16
17#[derive(Clone, Debug, Default, Eq, PartialEq)]
19pub struct ServiceCommitArgs {
20 pub messages: Vec<String>,
22 pub file: Option<OsString>,
24 pub quiet: bool,
26 pub author: Option<String>,
28 pub date: Option<String>,
30 pub trailers: Vec<String>,
32 pub verbatim: bool,
34 pub allow_empty_message: bool,
36 pub signoff: bool,
38 pub reuse: Option<String>,
40 pub fixup: Option<(FixupKind, String)>,
42 pub strip: bool,
44 pub no_edit: bool,
46 pub amend: bool,
48 pub reset_author: bool,
50}
51
52#[derive(Clone, Copy, Debug, Eq, PartialEq)]
54pub enum FixupKind {
55 Fixup,
57}
58
59const FLAG_LETTERS: [char; 7] = ['q', 'n', 'o', 'a', 's', 'i', 'p'];
61
62const FLAG_WORDS: [&str; 16] = [
64 "--quiet", "--no-verify", "--only", "--no-status", "--no-post-rewrite", "--no-edit", "--no-gpg-sign", "--all", "--signoff", "--allow-empty",
65 "--amend", "--reset-author", "--include", "--patch", "--interactive", "--pathspec-file-nul",
66];
67
68const VALUE_WORDS: [&str; 9] = ["--message", "--file", "--author", "--date", "--trailer", "--cleanup", "--reuse-message", "--fixup", "--pathspec-from-file"];
70
71fn long_value<'a>(token: &'a str, name: &str, arguments: &'a [OsString], index: &mut usize) -> Option<Option<&'a str>> {
73 if token == name {
74 let value: &str = arguments.get(*index)?.to_str()?;
75 *index += 1;
76 return Some(Some(value));
77 }
78 return token.strip_prefix(name).and_then(|rest| { return rest.strip_prefix('='); }).map(Some);
79}
80
81pub fn parse_service_commit(arguments: &[OsString]) -> Option<ServiceCommitArgs> {
91 let mut parsed: ServiceCommitArgs = ServiceCommitArgs::default();
92 let mut index: usize = 0;
93 while index < arguments.len() {
94 let token: &str = arguments[index].to_str()?;
95 index += 1;
96 if token == "--" {
97 break;
98 }
99 if !token.starts_with('-') || token == "-" {
100 continue;
102 }
103 if token.starts_with("--") {
104 if FLAG_WORDS.contains(&token) {
105 parsed.quiet |= token == "--quiet";
106 parsed.signoff |= token == "--signoff";
107 parsed.no_edit |= token == "--no-edit";
108 parsed.amend |= token == "--amend";
109 parsed.reset_author |= token == "--reset-author";
110 } else if token == "--allow-empty-message" {
111 parsed.allow_empty_message = true;
112 } else {
113 let mut matched: bool = false;
114 for name in VALUE_WORDS {
115 let Some(Some(value)) = long_value(token, name, arguments, &mut index) else { continue };
116 matched = true;
117 match name {
118 "--message" => parsed.messages.push(value.to_string()),
119 "--file" => parsed.file = Some(OsString::from(value)),
120 "--author" => parsed.author = Some(value.to_string()),
121 "--date" => parsed.date = Some(value.to_string()),
122 "--trailer" => parsed.trailers.push(value.to_string()),
123 "--reuse-message" => parsed.reuse = Some(value.to_string()),
124 "--pathspec-from-file" => {}
126 "--fixup" => {
127 if value.contains(':') || value.is_empty() {
129 return None;
130 }
131 parsed.fixup = Some((FixupKind::Fixup, value.to_string()));
132 }
133 _ => match value {
134 "verbatim" => parsed.verbatim = true,
135 "strip" => parsed.strip = true,
136 "whitespace" | "default" => {
137 parsed.verbatim = false;
138 parsed.strip = false;
139 }
140 _ => return None,
141 },
142 }
143 break;
144 }
145 if !matched {
146 return None;
147 }
148 }
149 } else {
150 let letters: Vec<char> = token.chars().skip(1).collect();
151 let mut at: usize = 0;
152 while at < letters.len() {
153 let letter: char = letters[at];
154 at += 1;
155 if letter == 'm' || letter == 'F' || letter == 'C' {
156 let inline: String = letters[at..].iter().collect();
157 let value: String = if inline.is_empty() {
158 index += 1;
159 arguments.get(index - 1)?.to_str()?.to_string()
160 } else {
161 inline
162 };
163 match letter {
164 'm' => parsed.messages.push(value),
165 'F' => parsed.file = Some(OsString::from(value)),
166 _ => parsed.reuse = Some(value),
167 }
168 at = letters.len();
169 } else if FLAG_LETTERS.contains(&letter) {
170 parsed.quiet |= letter == 'q';
171 parsed.signoff |= letter == 's';
172 } else {
173 return None;
174 }
175 }
176 }
177 }
178 let sources: usize = usize::from(!parsed.messages.is_empty())
180 + usize::from(parsed.file.is_some())
181 + usize::from(parsed.reuse.is_some())
182 + usize::from(parsed.fixup.is_some());
183 let has_text: bool = sources == 1 || (parsed.amend && parsed.no_edit && sources == 0);
186 let author_ok: bool = parsed.author.as_deref().is_none_or(|author| { return author.contains('<') && author.trim_end().ends_with('>'); });
187 let reuse_ok: bool = parsed.reuse.is_none() || (parsed.author.is_none() && parsed.date.is_none());
189 let reset_ok: bool = !parsed.reset_author || (parsed.amend && parsed.reuse.is_none() && parsed.author.is_none() && parsed.date.is_none());
191 return if has_text && reuse_ok && reset_ok && author_ok && parsed.file.as_deref().is_none_or(|file| { return file != "-"; }) { Some(parsed) } else { None };
192}
193
194const NEUTRAL_LONG: [&str; 15] = [
196 "--quiet", "--no-verify", "--only", "--no-status", "--no-post-rewrite", "--no-gpg-sign", "--all", "--signoff", "--allow-empty",
197 "--amend", "--reset-author", "--allow-empty-message", "--verbose", "--include", "--pathspec-file-nul",
198];
199
200const NEUTRAL_VALUE_LONG: [&str; 7] = ["--author", "--date", "--trailer", "--cleanup", "--gpg-sign", "--template", "--pathspec-from-file"];
202
203pub fn opens_editor(arguments: &[OsString]) -> bool {
216 let mut message_source: bool = false;
217 let mut edit: Option<bool> = None;
218 let mut index: usize = 0;
219 while index < arguments.len() {
220 let Some(token) = arguments[index].to_str() else { return true };
221 index += 1;
222 if token == "--" {
223 break;
224 }
225 if !token.starts_with('-') || token == "-" {
226 continue;
227 }
228 if let Some(long) = token.strip_prefix("--") {
229 let (name, value): (&str, Option<&str>) = match long.split_once('=') {
230 Some((name, value)) => (name, Some(value)),
231 None => (long, None),
232 };
233 let dashed: String = format!("--{name}");
234 let takes_value: bool = ["--message", "--file", "--reuse-message", "--reedit-message", "--fixup", "--squash"].contains(&dashed.as_str())
235 || NEUTRAL_VALUE_LONG.contains(&dashed.as_str());
236 let detached: Option<&str> = if takes_value && value.is_none() && !["--gpg-sign", "--squash"].contains(&dashed.as_str()) {
238 index += 1;
239 arguments.get(index - 1).and_then(|next| { return next.to_str(); })
240 } else {
241 None
242 };
243 let given: Option<&str> = value.or(detached);
244 match dashed.as_str() {
245 "--edit" => edit = Some(true),
246 "--no-edit" => edit = Some(false),
247 "--message" | "--file" | "--reuse-message" => message_source = true,
248 "--reedit-message" | "--squash" => return true,
249 "--fixup" => {
250 if given.is_none_or(|text| { return text.contains(':'); }) {
251 return true;
252 }
253 message_source = true;
254 }
255 other if NEUTRAL_LONG.contains(&other) || NEUTRAL_VALUE_LONG.contains(&other) => {}
256 _ => return true,
257 }
258 } else {
259 let letters: Vec<char> = token.chars().skip(1).collect();
260 let mut at: usize = 0;
261 while at < letters.len() {
262 let letter: char = letters[at];
263 at += 1;
264 match letter {
265 'e' => edit = Some(true),
266 'c' => return true,
267 'm' | 'F' | 'C' | 't' => {
268 message_source |= letter != 't';
269 if at == letters.len() {
270 index += 1;
271 }
272 at = letters.len();
273 }
274 'S' => at = letters.len(),
275 'q' | 'n' | 'o' | 'a' | 's' | 'v' | 'i' => {}
276 _ => return true,
277 }
278 }
279 }
280 }
281 return match edit {
282 Some(wanted) => wanted,
283 None => !message_source,
284 };
285}
286
287pub fn cleanup_message(messages: &[String]) -> Option<String> {
298 let joined: String = messages.join("\n\n");
299 let mut lines: Vec<&str> = Vec::new();
300 let mut previous_blank: bool = true;
301 for line in joined.split('\n') {
302 let trimmed: &str = line.trim_end();
303 let blank: bool = trimmed.is_empty();
304 if blank && previous_blank {
305 continue;
306 }
307 lines.push(trimmed);
308 previous_blank = blank;
309 }
310 while lines.last().is_some_and(|line| { return line.is_empty(); }) {
311 lines.pop();
312 }
313 if lines.is_empty() {
314 return None;
315 }
316 return Some(format!("{}\n", lines.join("\n")));
317}
318
319const MESSAGE_VALUE_LONG: [&str; 5] = ["--message", "--file", "--template", "--trailer", "--cleanup"];
321
322const MESSAGE_FLAG_LONG: [&str; 4] = ["--edit", "--no-edit", "--signoff", "--no-signoff"];
324
325const BORROWING_LONG: [&str; 5] = ["--reedit-message", "--reuse-message", "--squash", "--fixup", "--amend"];
327
328pub fn with_fixed_message(arguments: &[OsString], subcommand_index: usize, file: &std::path::Path) -> Option<Vec<OsString>> {
342 let text: Vec<String> = arguments.iter().map(|token| { return token.to_string_lossy().into_owned(); }).collect();
343 let region: Vec<String> = crate::commit_args::normalise_commit_args(text.get(subcommand_index + 1..)?);
344 let split: usize = crate::commit_args::separator_index(region.as_slice()).unwrap_or(region.len());
345 let (options, tail): (&[String], &[String]) = region.split_at(split);
346 let mut kept: Vec<OsString> = arguments[..=subcommand_index].to_vec();
347 let mut index: usize = 0;
348 while index < options.len() {
349 let token: &str = options[index].as_str();
350 index += 1;
351 if let Some(long) = token.strip_prefix("--") {
352 let (name, attached): (String, bool) = match long.split_once('=') {
353 Some((name, _)) => (format!("--{name}"), true),
354 None => (format!("--{long}"), false),
355 };
356 let all: Vec<&str> = MESSAGE_VALUE_LONG.iter().chain(&MESSAGE_FLAG_LONG).chain(&BORROWING_LONG).copied().collect();
357 if BORROWING_LONG.contains(&name.as_str()) || (!all.contains(&name.as_str()) && all.iter().any(|known| { return known.starts_with(name.as_str()); })) {
358 return None;
359 }
360 if MESSAGE_VALUE_LONG.contains(&name.as_str()) {
361 if !attached {
362 index += 1;
363 }
364 continue;
365 }
366 if MESSAGE_FLAG_LONG.contains(&name.as_str()) {
367 continue;
368 }
369 kept.push(OsString::from(token));
370 continue;
371 }
372 if token.starts_with('-') && token.len() > 1 {
373 let mut cluster: String = String::from("-");
374 let mut value_next: bool = false;
375 let letters: Vec<char> = token.chars().skip(1).collect();
376 for (at, letter) in letters.iter().enumerate() {
377 match letter {
378 'c' | 'C' => return None,
379 'm' | 'F' | 't' => value_next = true,
380 'e' | 's' => {}
381 'S' => {
382 cluster.extend(letters[at..].iter());
383 break;
384 }
385 other => cluster.push(*other),
386 }
387 }
388 if cluster != "-" {
389 kept.push(OsString::from(cluster));
390 }
391 if value_next {
392 index += 1;
393 }
394 continue;
395 }
396 kept.push(OsString::from(token));
397 }
398 kept.push(OsString::from("-F"));
399 kept.push(file.as_os_str().to_os_string());
400 kept.push(OsString::from("--cleanup=verbatim"));
401 kept.push(OsString::from("--no-edit"));
402 kept.extend(tail.iter().map(OsString::from));
403 return Some(kept);
404}
405
406#[cfg(test)]
408#[path = "service_commit_args_tests.rs"]
409mod tests;