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
203const VALUE_LONG: [&str; 11] = [
205 "--message", "--file", "--reuse-message", "--reedit-message", "--fixup", "--author", "--date", "--trailer", "--cleanup", "--template",
206 "--pathspec-from-file",
207];
208
209const VALUE_LETTERS: [char; 5] = ['m', 'F', 'C', 'c', 't'];
211
212const ATTACHED_LETTERS: [char; 2] = ['S', 'u'];
214
215pub fn asks_for_quiet(arguments: &[OsString]) -> bool {
227 let mut index: usize = 0;
228 while index < arguments.len() {
229 let Some(token) = arguments[index].to_str() else { return false };
230 index += 1;
231 if token == "--" {
232 break;
233 }
234 if let Some(long) = token.strip_prefix("--") {
235 let (name, attached): (&str, bool) = match long.split_once('=') {
236 Some((name, _)) => (name, true),
237 None => (long, false),
238 };
239 let dashed: String = format!("--{name}");
240 if dashed == "--quiet" {
241 return true;
242 }
243 if !attached && VALUE_LONG.contains(&dashed.as_str()) {
244 index += 1;
245 }
246 } else if token.starts_with('-') && token != "-" {
247 let letters: Vec<char> = token.chars().skip(1).collect();
248 for (at, letter) in letters.iter().enumerate() {
249 if *letter == 'q' {
250 return true;
251 }
252 if ATTACHED_LETTERS.contains(letter) {
253 break;
254 }
255 if VALUE_LETTERS.contains(letter) {
256 if at + 1 == letters.len() {
257 index += 1;
258 }
259 break;
260 }
261 }
262 }
263 }
264 return false;
265}
266
267pub fn opens_editor(arguments: &[OsString]) -> bool {
280 let mut message_source: bool = false;
281 let mut edit: Option<bool> = None;
282 let mut index: usize = 0;
283 while index < arguments.len() {
284 let Some(token) = arguments[index].to_str() else { return true };
285 index += 1;
286 if token == "--" {
287 break;
288 }
289 if !token.starts_with('-') || token == "-" {
290 continue;
291 }
292 if let Some(long) = token.strip_prefix("--") {
293 let (name, value): (&str, Option<&str>) = match long.split_once('=') {
294 Some((name, value)) => (name, Some(value)),
295 None => (long, None),
296 };
297 let dashed: String = format!("--{name}");
298 let takes_value: bool = ["--message", "--file", "--reuse-message", "--reedit-message", "--fixup", "--squash"].contains(&dashed.as_str())
299 || NEUTRAL_VALUE_LONG.contains(&dashed.as_str());
300 let detached: Option<&str> = if takes_value && value.is_none() && !["--gpg-sign", "--squash"].contains(&dashed.as_str()) {
302 index += 1;
303 arguments.get(index - 1).and_then(|next| { return next.to_str(); })
304 } else {
305 None
306 };
307 let given: Option<&str> = value.or(detached);
308 match dashed.as_str() {
309 "--edit" => edit = Some(true),
310 "--no-edit" => edit = Some(false),
311 "--message" | "--file" | "--reuse-message" => message_source = true,
312 "--reedit-message" | "--squash" => return true,
313 "--fixup" => {
314 if given.is_none_or(|text| { return text.contains(':'); }) {
315 return true;
316 }
317 message_source = true;
318 }
319 other if NEUTRAL_LONG.contains(&other) || NEUTRAL_VALUE_LONG.contains(&other) => {}
320 _ => return true,
321 }
322 } else {
323 let letters: Vec<char> = token.chars().skip(1).collect();
324 let mut at: usize = 0;
325 while at < letters.len() {
326 let letter: char = letters[at];
327 at += 1;
328 match letter {
329 'e' => edit = Some(true),
330 'c' => return true,
331 'm' | 'F' | 'C' | 't' => {
332 message_source |= letter != 't';
333 if at == letters.len() {
334 index += 1;
335 }
336 at = letters.len();
337 }
338 'S' => at = letters.len(),
339 'q' | 'n' | 'o' | 'a' | 's' | 'v' | 'i' => {}
340 _ => return true,
341 }
342 }
343 }
344 }
345 return match edit {
346 Some(wanted) => wanted,
347 None => !message_source,
348 };
349}
350
351pub fn cleanup_message(messages: &[String]) -> Option<String> {
362 let joined: String = messages.join("\n\n");
363 let mut lines: Vec<&str> = Vec::new();
364 let mut previous_blank: bool = true;
365 for line in joined.split('\n') {
366 let trimmed: &str = line.trim_end();
367 let blank: bool = trimmed.is_empty();
368 if blank && previous_blank {
369 continue;
370 }
371 lines.push(trimmed);
372 previous_blank = blank;
373 }
374 while lines.last().is_some_and(|line| { return line.is_empty(); }) {
375 lines.pop();
376 }
377 if lines.is_empty() {
378 return None;
379 }
380 return Some(format!("{}\n", lines.join("\n")));
381}
382
383const MESSAGE_VALUE_LONG: [&str; 5] = ["--message", "--file", "--template", "--trailer", "--cleanup"];
385
386const MESSAGE_FLAG_LONG: [&str; 4] = ["--edit", "--no-edit", "--signoff", "--no-signoff"];
388
389const BORROWING_LONG: [&str; 5] = ["--reedit-message", "--reuse-message", "--squash", "--fixup", "--amend"];
391
392pub fn with_fixed_message(arguments: &[OsString], subcommand_index: usize, file: &std::path::Path) -> Option<Vec<OsString>> {
406 let text: Vec<String> = arguments.iter().map(|token| { return token.to_string_lossy().into_owned(); }).collect();
407 let region: Vec<String> = super::commit_args::normalise_commit_args(text.get(subcommand_index + 1..)?);
408 let split: usize = region.iter().position(|token| { return token == "--"; }).unwrap_or(region.len());
409 let (options, tail): (&[String], &[String]) = region.split_at(split);
410 let mut kept: Vec<OsString> = arguments[..=subcommand_index].to_vec();
411 let mut index: usize = 0;
412 while index < options.len() {
413 let token: &str = options[index].as_str();
414 index += 1;
415 if let Some(long) = token.strip_prefix("--") {
416 let (name, attached): (String, bool) = match long.split_once('=') {
417 Some((name, _)) => (format!("--{name}"), true),
418 None => (format!("--{long}"), false),
419 };
420 let all: Vec<&str> = MESSAGE_VALUE_LONG.iter().chain(&MESSAGE_FLAG_LONG).chain(&BORROWING_LONG).copied().collect();
421 if BORROWING_LONG.contains(&name.as_str()) || (!all.contains(&name.as_str()) && all.iter().any(|known| { return known.starts_with(name.as_str()); })) {
422 return None;
423 }
424 if MESSAGE_VALUE_LONG.contains(&name.as_str()) {
425 if !attached {
426 index += 1;
427 }
428 continue;
429 }
430 if MESSAGE_FLAG_LONG.contains(&name.as_str()) {
431 continue;
432 }
433 kept.push(OsString::from(token));
434 continue;
435 }
436 if token.starts_with('-') && token.len() > 1 {
437 let mut cluster: String = String::from("-");
438 let mut value_next: bool = false;
439 let letters: Vec<char> = token.chars().skip(1).collect();
440 for (at, letter) in letters.iter().enumerate() {
441 match letter {
442 'c' | 'C' => return None,
443 'm' | 'F' | 't' => value_next = true,
444 'e' | 's' => {}
445 'S' => {
446 cluster.extend(letters[at..].iter());
447 break;
448 }
449 other => cluster.push(*other),
450 }
451 }
452 if cluster != "-" {
453 kept.push(OsString::from(cluster));
454 }
455 if value_next {
456 index += 1;
457 }
458 continue;
459 }
460 kept.push(OsString::from(token));
461 }
462 kept.push(OsString::from("-F"));
463 kept.push(file.as_os_str().to_os_string());
464 kept.push(OsString::from("--cleanup=verbatim"));
465 kept.push(OsString::from("--no-edit"));
466 kept.extend(tail.iter().map(OsString::from));
467 return Some(kept);
468}
469
470#[cfg(test)]
472#[path = "service_commit_args_tests.rs"]
473mod tests;