use std::ffi::OsString;
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct ServiceCommitArgs {
pub messages: Vec<String>,
pub file: Option<OsString>,
pub quiet: bool,
pub author: Option<String>,
pub date: Option<String>,
pub trailers: Vec<String>,
pub verbatim: bool,
pub allow_empty_message: bool,
pub signoff: bool,
pub reuse: Option<String>,
pub fixup: Option<(FixupKind, String)>,
pub strip: bool,
pub no_edit: bool,
pub amend: bool,
pub reset_author: bool,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum FixupKind {
Fixup,
}
const FLAG_LETTERS: [char; 7] = ['q', 'n', 'o', 'a', 's', 'i', 'p'];
const FLAG_WORDS: [&str; 16] = [
"--quiet", "--no-verify", "--only", "--no-status", "--no-post-rewrite", "--no-edit", "--no-gpg-sign", "--all", "--signoff", "--allow-empty",
"--amend", "--reset-author", "--include", "--patch", "--interactive", "--pathspec-file-nul",
];
const VALUE_WORDS: [&str; 9] = ["--message", "--file", "--author", "--date", "--trailer", "--cleanup", "--reuse-message", "--fixup", "--pathspec-from-file"];
fn long_value<'a>(token: &'a str, name: &str, arguments: &'a [OsString], index: &mut usize) -> Option<Option<&'a str>> {
if token == name {
let value: &str = arguments.get(*index)?.to_str()?;
*index += 1;
return Some(Some(value));
}
return token.strip_prefix(name).and_then(|rest| { return rest.strip_prefix('='); }).map(Some);
}
pub fn parse_service_commit(arguments: &[OsString]) -> Option<ServiceCommitArgs> {
let mut parsed: ServiceCommitArgs = ServiceCommitArgs::default();
let mut index: usize = 0;
while index < arguments.len() {
let token: &str = arguments[index].to_str()?;
index += 1;
if token == "--" {
break;
}
if !token.starts_with('-') || token == "-" {
continue;
}
if token.starts_with("--") {
if FLAG_WORDS.contains(&token) {
parsed.quiet |= token == "--quiet";
parsed.signoff |= token == "--signoff";
parsed.no_edit |= token == "--no-edit";
parsed.amend |= token == "--amend";
parsed.reset_author |= token == "--reset-author";
} else if token == "--allow-empty-message" {
parsed.allow_empty_message = true;
} else {
let mut matched: bool = false;
for name in VALUE_WORDS {
let Some(Some(value)) = long_value(token, name, arguments, &mut index) else { continue };
matched = true;
match name {
"--message" => parsed.messages.push(value.to_string()),
"--file" => parsed.file = Some(OsString::from(value)),
"--author" => parsed.author = Some(value.to_string()),
"--date" => parsed.date = Some(value.to_string()),
"--trailer" => parsed.trailers.push(value.to_string()),
"--reuse-message" => parsed.reuse = Some(value.to_string()),
"--pathspec-from-file" => {}
"--fixup" => {
if value.contains(':') || value.is_empty() {
return None;
}
parsed.fixup = Some((FixupKind::Fixup, value.to_string()));
}
_ => match value {
"verbatim" => parsed.verbatim = true,
"strip" => parsed.strip = true,
"whitespace" | "default" => {
parsed.verbatim = false;
parsed.strip = false;
}
_ => return None,
},
}
break;
}
if !matched {
return None;
}
}
} else {
let letters: Vec<char> = token.chars().skip(1).collect();
let mut at: usize = 0;
while at < letters.len() {
let letter: char = letters[at];
at += 1;
if letter == 'm' || letter == 'F' || letter == 'C' {
let inline: String = letters[at..].iter().collect();
let value: String = if inline.is_empty() {
index += 1;
arguments.get(index - 1)?.to_str()?.to_string()
} else {
inline
};
match letter {
'm' => parsed.messages.push(value),
'F' => parsed.file = Some(OsString::from(value)),
_ => parsed.reuse = Some(value),
}
at = letters.len();
} else if FLAG_LETTERS.contains(&letter) {
parsed.quiet |= letter == 'q';
parsed.signoff |= letter == 's';
} else {
return None;
}
}
}
}
let sources: usize = usize::from(!parsed.messages.is_empty())
+ usize::from(parsed.file.is_some())
+ usize::from(parsed.reuse.is_some())
+ usize::from(parsed.fixup.is_some());
let has_text: bool = sources == 1 || (parsed.amend && parsed.no_edit && sources == 0);
let author_ok: bool = parsed.author.as_deref().is_none_or(|author| { return author.contains('<') && author.trim_end().ends_with('>'); });
let reuse_ok: bool = parsed.reuse.is_none() || (parsed.author.is_none() && parsed.date.is_none());
let reset_ok: bool = !parsed.reset_author || (parsed.amend && parsed.reuse.is_none() && parsed.author.is_none() && parsed.date.is_none());
return if has_text && reuse_ok && reset_ok && author_ok && parsed.file.as_deref().is_none_or(|file| { return file != "-"; }) { Some(parsed) } else { None };
}
const NEUTRAL_LONG: [&str; 15] = [
"--quiet", "--no-verify", "--only", "--no-status", "--no-post-rewrite", "--no-gpg-sign", "--all", "--signoff", "--allow-empty",
"--amend", "--reset-author", "--allow-empty-message", "--verbose", "--include", "--pathspec-file-nul",
];
const NEUTRAL_VALUE_LONG: [&str; 7] = ["--author", "--date", "--trailer", "--cleanup", "--gpg-sign", "--template", "--pathspec-from-file"];
pub fn opens_editor(arguments: &[OsString]) -> bool {
let mut message_source: bool = false;
let mut edit: Option<bool> = None;
let mut index: usize = 0;
while index < arguments.len() {
let Some(token) = arguments[index].to_str() else { return true };
index += 1;
if token == "--" {
break;
}
if !token.starts_with('-') || token == "-" {
continue;
}
if let Some(long) = token.strip_prefix("--") {
let (name, value): (&str, Option<&str>) = match long.split_once('=') {
Some((name, value)) => (name, Some(value)),
None => (long, None),
};
let dashed: String = format!("--{name}");
let takes_value: bool = ["--message", "--file", "--reuse-message", "--reedit-message", "--fixup", "--squash"].contains(&dashed.as_str())
|| NEUTRAL_VALUE_LONG.contains(&dashed.as_str());
let detached: Option<&str> = if takes_value && value.is_none() && !["--gpg-sign", "--squash"].contains(&dashed.as_str()) {
index += 1;
arguments.get(index - 1).and_then(|next| { return next.to_str(); })
} else {
None
};
let given: Option<&str> = value.or(detached);
match dashed.as_str() {
"--edit" => edit = Some(true),
"--no-edit" => edit = Some(false),
"--message" | "--file" | "--reuse-message" => message_source = true,
"--reedit-message" | "--squash" => return true,
"--fixup" => {
if given.is_none_or(|text| { return text.contains(':'); }) {
return true;
}
message_source = true;
}
other if NEUTRAL_LONG.contains(&other) || NEUTRAL_VALUE_LONG.contains(&other) => {}
_ => return true,
}
} else {
let letters: Vec<char> = token.chars().skip(1).collect();
let mut at: usize = 0;
while at < letters.len() {
let letter: char = letters[at];
at += 1;
match letter {
'e' => edit = Some(true),
'c' => return true,
'm' | 'F' | 'C' | 't' => {
message_source |= letter != 't';
if at == letters.len() {
index += 1;
}
at = letters.len();
}
'S' => at = letters.len(),
'q' | 'n' | 'o' | 'a' | 's' | 'v' | 'i' => {}
_ => return true,
}
}
}
}
return match edit {
Some(wanted) => wanted,
None => !message_source,
};
}
pub fn cleanup_message(messages: &[String]) -> Option<String> {
let joined: String = messages.join("\n\n");
let mut lines: Vec<&str> = Vec::new();
let mut previous_blank: bool = true;
for line in joined.split('\n') {
let trimmed: &str = line.trim_end();
let blank: bool = trimmed.is_empty();
if blank && previous_blank {
continue;
}
lines.push(trimmed);
previous_blank = blank;
}
while lines.last().is_some_and(|line| { return line.is_empty(); }) {
lines.pop();
}
if lines.is_empty() {
return None;
}
return Some(format!("{}\n", lines.join("\n")));
}
const MESSAGE_VALUE_LONG: [&str; 5] = ["--message", "--file", "--template", "--trailer", "--cleanup"];
const MESSAGE_FLAG_LONG: [&str; 4] = ["--edit", "--no-edit", "--signoff", "--no-signoff"];
const BORROWING_LONG: [&str; 5] = ["--reedit-message", "--reuse-message", "--squash", "--fixup", "--amend"];
pub fn with_fixed_message(arguments: &[OsString], subcommand_index: usize, file: &std::path::Path) -> Option<Vec<OsString>> {
let text: Vec<String> = arguments.iter().map(|token| { return token.to_string_lossy().into_owned(); }).collect();
let region: Vec<String> = crate::commit_args::normalise_commit_args(text.get(subcommand_index + 1..)?);
let split: usize = crate::commit_args::separator_index(region.as_slice()).unwrap_or(region.len());
let (options, tail): (&[String], &[String]) = region.split_at(split);
let mut kept: Vec<OsString> = arguments[..=subcommand_index].to_vec();
let mut index: usize = 0;
while index < options.len() {
let token: &str = options[index].as_str();
index += 1;
if let Some(long) = token.strip_prefix("--") {
let (name, attached): (String, bool) = match long.split_once('=') {
Some((name, _)) => (format!("--{name}"), true),
None => (format!("--{long}"), false),
};
let all: Vec<&str> = MESSAGE_VALUE_LONG.iter().chain(&MESSAGE_FLAG_LONG).chain(&BORROWING_LONG).copied().collect();
if BORROWING_LONG.contains(&name.as_str()) || (!all.contains(&name.as_str()) && all.iter().any(|known| { return known.starts_with(name.as_str()); })) {
return None;
}
if MESSAGE_VALUE_LONG.contains(&name.as_str()) {
if !attached {
index += 1;
}
continue;
}
if MESSAGE_FLAG_LONG.contains(&name.as_str()) {
continue;
}
kept.push(OsString::from(token));
continue;
}
if token.starts_with('-') && token.len() > 1 {
let mut cluster: String = String::from("-");
let mut value_next: bool = false;
let letters: Vec<char> = token.chars().skip(1).collect();
for (at, letter) in letters.iter().enumerate() {
match letter {
'c' | 'C' => return None,
'm' | 'F' | 't' => value_next = true,
'e' | 's' => {}
'S' => {
cluster.extend(letters[at..].iter());
break;
}
other => cluster.push(*other),
}
}
if cluster != "-" {
kept.push(OsString::from(cluster));
}
if value_next {
index += 1;
}
continue;
}
kept.push(OsString::from(token));
}
kept.push(OsString::from("-F"));
kept.push(file.as_os_str().to_os_string());
kept.push(OsString::from("--cleanup=verbatim"));
kept.push(OsString::from("--no-edit"));
kept.extend(tail.iter().map(OsString::from));
return Some(kept);
}
#[cfg(test)]
#[path = "service_commit_args_tests.rs"]
mod tests;