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; 5] = ['q', 'n', 'o', 'a', 's'];
61
62const FLAG_WORDS: [&str; 12] = [
64 "--quiet", "--no-verify", "--only", "--no-status", "--no-post-rewrite", "--no-edit", "--no-gpg-sign", "--all", "--signoff", "--allow-empty",
65 "--amend", "--reset-author",
66];
67
68const VALUE_WORDS: [&str; 8] = ["--message", "--file", "--author", "--date", "--trailer", "--cleanup", "--reuse-message", "--fixup"];
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 "--fixup" => {
125 if value.contains(':') || value.is_empty() {
127 return None;
128 }
129 parsed.fixup = Some((FixupKind::Fixup, value.to_string()));
130 }
131 _ => match value {
132 "verbatim" => parsed.verbatim = true,
133 "strip" => parsed.strip = true,
134 "whitespace" | "default" => {
135 parsed.verbatim = false;
136 parsed.strip = false;
137 }
138 _ => return None,
139 },
140 }
141 break;
142 }
143 if !matched {
144 return None;
145 }
146 }
147 } else {
148 let letters: Vec<char> = token.chars().skip(1).collect();
149 let mut at: usize = 0;
150 while at < letters.len() {
151 let letter: char = letters[at];
152 at += 1;
153 if letter == 'm' || letter == 'F' || letter == 'C' {
154 let inline: String = letters[at..].iter().collect();
155 let value: String = if inline.is_empty() {
156 index += 1;
157 arguments.get(index - 1)?.to_str()?.to_string()
158 } else {
159 inline
160 };
161 match letter {
162 'm' => parsed.messages.push(value),
163 'F' => parsed.file = Some(OsString::from(value)),
164 _ => parsed.reuse = Some(value),
165 }
166 at = letters.len();
167 } else if FLAG_LETTERS.contains(&letter) {
168 parsed.quiet |= letter == 'q';
169 parsed.signoff |= letter == 's';
170 } else {
171 return None;
172 }
173 }
174 }
175 }
176 let sources: usize = usize::from(!parsed.messages.is_empty())
178 + usize::from(parsed.file.is_some())
179 + usize::from(parsed.reuse.is_some())
180 + usize::from(parsed.fixup.is_some());
181 let has_text: bool = sources == 1 || (parsed.amend && parsed.no_edit && sources == 0);
184 let author_ok: bool = parsed.author.as_deref().is_none_or(|author| { return author.contains('<') && author.trim_end().ends_with('>'); });
185 let reuse_ok: bool = parsed.reuse.is_none() || (parsed.author.is_none() && parsed.date.is_none());
187 let reset_ok: bool = !parsed.reset_author || (parsed.amend && parsed.reuse.is_none() && parsed.author.is_none() && parsed.date.is_none());
189 return if has_text && reuse_ok && reset_ok && author_ok && parsed.file.as_deref().is_none_or(|file| { return file != "-"; }) { Some(parsed) } else { None };
190}
191
192const NEUTRAL_LONG: [&str; 13] = [
194 "--quiet", "--no-verify", "--only", "--no-status", "--no-post-rewrite", "--no-gpg-sign", "--all", "--signoff", "--allow-empty",
195 "--amend", "--reset-author", "--allow-empty-message", "--verbose",
196];
197
198const NEUTRAL_VALUE_LONG: [&str; 6] = ["--author", "--date", "--trailer", "--cleanup", "--gpg-sign", "--template"];
200
201pub fn opens_editor(arguments: &[OsString]) -> bool {
214 let mut message_source: bool = false;
215 let mut edit: Option<bool> = None;
216 let mut index: usize = 0;
217 while index < arguments.len() {
218 let Some(token) = arguments[index].to_str() else { return true };
219 index += 1;
220 if token == "--" {
221 break;
222 }
223 if !token.starts_with('-') || token == "-" {
224 continue;
225 }
226 if let Some(long) = token.strip_prefix("--") {
227 let (name, value): (&str, Option<&str>) = match long.split_once('=') {
228 Some((name, value)) => (name, Some(value)),
229 None => (long, None),
230 };
231 let dashed: String = format!("--{name}");
232 let takes_value: bool = ["--message", "--file", "--reuse-message", "--reedit-message", "--fixup", "--squash"].contains(&dashed.as_str())
233 || NEUTRAL_VALUE_LONG.contains(&dashed.as_str());
234 let detached: Option<&str> = if takes_value && value.is_none() && !["--gpg-sign", "--squash"].contains(&dashed.as_str()) {
236 index += 1;
237 arguments.get(index - 1).and_then(|next| { return next.to_str(); })
238 } else {
239 None
240 };
241 let given: Option<&str> = value.or(detached);
242 match dashed.as_str() {
243 "--edit" => edit = Some(true),
244 "--no-edit" => edit = Some(false),
245 "--message" | "--file" | "--reuse-message" => message_source = true,
246 "--reedit-message" | "--squash" => return true,
247 "--fixup" => {
248 if given.is_none_or(|text| { return text.contains(':'); }) {
249 return true;
250 }
251 message_source = true;
252 }
253 other if NEUTRAL_LONG.contains(&other) || NEUTRAL_VALUE_LONG.contains(&other) => {}
254 _ => return true,
255 }
256 } else {
257 let letters: Vec<char> = token.chars().skip(1).collect();
258 let mut at: usize = 0;
259 while at < letters.len() {
260 let letter: char = letters[at];
261 at += 1;
262 match letter {
263 'e' => edit = Some(true),
264 'c' => return true,
265 'm' | 'F' | 'C' | 't' => {
266 message_source |= letter != 't';
267 if at == letters.len() {
268 index += 1;
269 }
270 at = letters.len();
271 }
272 'S' => at = letters.len(),
273 'q' | 'n' | 'o' | 'a' | 's' | 'v' => {}
274 _ => return true,
275 }
276 }
277 }
278 }
279 return match edit {
280 Some(wanted) => wanted,
281 None => !message_source,
282 };
283}
284
285pub fn cleanup_message(messages: &[String]) -> Option<String> {
296 let joined: String = messages.join("\n\n");
297 let mut lines: Vec<&str> = Vec::new();
298 let mut previous_blank: bool = true;
299 for line in joined.split('\n') {
300 let trimmed: &str = line.trim_end();
301 let blank: bool = trimmed.is_empty();
302 if blank && previous_blank {
303 continue;
304 }
305 lines.push(trimmed);
306 previous_blank = blank;
307 }
308 while lines.last().is_some_and(|line| { return line.is_empty(); }) {
309 lines.pop();
310 }
311 if lines.is_empty() {
312 return None;
313 }
314 return Some(format!("{}\n", lines.join("\n")));
315}
316
317#[cfg(test)]
319#[path = "service_commit_args_tests.rs"]
320mod tests;