git_policy_cli/
service_commit_args.rs1use 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}
47
48#[derive(Clone, Copy, Debug, Eq, PartialEq)]
50pub enum FixupKind {
51 Fixup,
53}
54
55const FLAG_LETTERS: [char; 5] = ['q', 'n', 'o', 'a', 's'];
57
58const FLAG_WORDS: [&str; 10] = [
60 "--quiet", "--no-verify", "--only", "--no-status", "--no-post-rewrite", "--no-edit", "--no-gpg-sign", "--all", "--signoff", "--allow-empty",
61];
62
63const VALUE_WORDS: [&str; 8] = ["--message", "--file", "--author", "--date", "--trailer", "--cleanup", "--reuse-message", "--fixup"];
65
66fn long_value<'a>(token: &'a str, name: &str, arguments: &'a [OsString], index: &mut usize) -> Option<Option<&'a str>> {
68 if token == name {
69 let value: &str = arguments.get(*index)?.to_str()?;
70 *index += 1;
71 return Some(Some(value));
72 }
73 return token.strip_prefix(name).and_then(|rest| { return rest.strip_prefix('='); }).map(Some);
74}
75
76pub fn parse_service_commit(arguments: &[OsString]) -> Option<ServiceCommitArgs> {
86 let mut parsed: ServiceCommitArgs = ServiceCommitArgs::default();
87 let mut index: usize = 0;
88 while index < arguments.len() {
89 let token: &str = arguments[index].to_str()?;
90 index += 1;
91 if token == "--" {
92 break;
93 }
94 if !token.starts_with('-') || token == "-" {
95 continue;
97 }
98 if token.starts_with("--") {
99 if FLAG_WORDS.contains(&token) {
100 parsed.quiet |= token == "--quiet";
101 parsed.signoff |= token == "--signoff";
102 parsed.no_edit |= token == "--no-edit";
103 } else if token == "--allow-empty-message" {
104 parsed.allow_empty_message = true;
105 } else {
106 let mut matched: bool = false;
107 for name in VALUE_WORDS {
108 let Some(Some(value)) = long_value(token, name, arguments, &mut index) else { continue };
109 matched = true;
110 match name {
111 "--message" => parsed.messages.push(value.to_string()),
112 "--file" => parsed.file = Some(OsString::from(value)),
113 "--author" => parsed.author = Some(value.to_string()),
114 "--date" => parsed.date = Some(value.to_string()),
115 "--trailer" => parsed.trailers.push(value.to_string()),
116 "--reuse-message" => parsed.reuse = Some(value.to_string()),
117 "--fixup" => {
118 if value.contains(':') || value.is_empty() {
120 return None;
121 }
122 parsed.fixup = Some((FixupKind::Fixup, value.to_string()));
123 }
124 _ => match value {
125 "verbatim" => parsed.verbatim = true,
126 "strip" => parsed.strip = true,
127 "whitespace" | "default" => {
128 parsed.verbatim = false;
129 parsed.strip = false;
130 }
131 _ => return None,
132 },
133 }
134 break;
135 }
136 if !matched {
137 return None;
138 }
139 }
140 } else {
141 let letters: Vec<char> = token.chars().skip(1).collect();
142 let mut at: usize = 0;
143 while at < letters.len() {
144 let letter: char = letters[at];
145 at += 1;
146 if letter == 'm' || letter == 'F' || letter == 'C' {
147 let inline: String = letters[at..].iter().collect();
148 let value: String = if inline.is_empty() {
149 index += 1;
150 arguments.get(index - 1)?.to_str()?.to_string()
151 } else {
152 inline
153 };
154 match letter {
155 'm' => parsed.messages.push(value),
156 'F' => parsed.file = Some(OsString::from(value)),
157 _ => parsed.reuse = Some(value),
158 }
159 at = letters.len();
160 } else if FLAG_LETTERS.contains(&letter) {
161 parsed.quiet |= letter == 'q';
162 parsed.signoff |= letter == 's';
163 } else {
164 return None;
165 }
166 }
167 }
168 }
169 let sources: usize = usize::from(!parsed.messages.is_empty())
171 + usize::from(parsed.file.is_some())
172 + usize::from(parsed.reuse.is_some())
173 + usize::from(parsed.fixup.is_some());
174 let has_text: bool = sources == 1;
175 let author_ok: bool = parsed.author.as_deref().is_none_or(|author| { return author.contains('<') && author.trim_end().ends_with('>'); });
176 let reuse_ok: bool = parsed.reuse.is_none() || (parsed.author.is_none() && parsed.date.is_none());
178 return if has_text && reuse_ok && author_ok && parsed.file.as_deref().is_none_or(|file| { return file != "-"; }) { Some(parsed) } else { None };
179}
180
181pub fn cleanup_message(messages: &[String]) -> Option<String> {
192 let joined: String = messages.join("\n\n");
193 let mut lines: Vec<&str> = Vec::new();
194 let mut previous_blank: bool = true;
195 for line in joined.split('\n') {
196 let trimmed: &str = line.trim_end();
197 let blank: bool = trimmed.is_empty();
198 if blank && previous_blank {
199 continue;
200 }
201 lines.push(trimmed);
202 previous_blank = blank;
203 }
204 while lines.last().is_some_and(|line| { return line.is_empty(); }) {
205 lines.pop();
206 }
207 if lines.is_empty() {
208 return None;
209 }
210 return Some(format!("{}\n", lines.join("\n")));
211}
212
213#[cfg(test)]
215#[path = "service_commit_args_tests.rs"]
216mod tests;