1use std::borrow::Cow;
2
3use crate::Comment;
4
5const SCISSORS_MARKER: &str = "------------------------ >8 ------------------------";
6
7#[derive(Debug, PartialEq, Eq, Clone)]
12pub struct Scissors<'a> {
13 scissors: Cow<'a, str>,
14}
15
16impl<'a> Scissors<'a> {
17 pub(crate) fn guess_comment_character(message: &str) -> Option<char> {
27 Self::guess_comment_char_from_scissors(message)
28 .or_else(|| Self::guess_comment_char_from_last_possibility(message))
29 }
30
31 fn guess_comment_char_from_last_possibility(message: &str) -> Option<char> {
41 message
42 .lines()
43 .filter_map(|line| {
44 line.chars()
45 .next()
46 .filter(|first_letter| Comment::is_legal_comment_char(*first_letter))
47 })
48 .next_back()
49 }
50
51 fn guess_comment_char_from_scissors(message: &str) -> Option<char> {
61 message
62 .lines()
63 .filter_map(|line| {
64 let mut line_chars = line.chars();
65 let first_character = line_chars.next();
66 first_character.filter(|cc| Comment::is_legal_comment_char(*cc))?;
67 line_chars.next().filter(|cc| *cc == ' ')?;
68
69 if SCISSORS_MARKER != line_chars.as_str() {
70 return None;
71 }
72
73 first_character
74 })
75 .next_back()
76 }
77
78 pub(crate) fn parse_sections(message: &str) -> (Cow<'a, str>, Option<Self>) {
88 message
89 .lines()
90 .position(|line| line.ends_with(SCISSORS_MARKER))
91 .map_or_else(
92 || (message.to_string().into(), None),
93 |scissors_position| {
94 let lines: Vec<&str> = message.lines().collect();
95 let body = lines[..scissors_position].join("\n");
96 let scissors_string = lines[scissors_position..].join("\n");
97
98 let scissors = if message.ends_with('\n') {
99 Self::from(format!("{scissors_string}\n"))
100 } else {
101 Self::from(scissors_string)
102 };
103
104 (body.into(), Some(scissors))
105 },
106 )
107 }
108}
109
110impl<'a> From<Cow<'a, str>> for Scissors<'a> {
111 fn from(scissors: Cow<'a, str>) -> Self {
112 Self { scissors }
113 }
114}
115
116impl<'a> From<&'a str> for Scissors<'a> {
117 fn from(scissors: &'a str) -> Self {
118 Self {
119 scissors: scissors.into(),
120 }
121 }
122}
123
124impl From<String> for Scissors<'_> {
125 fn from(scissors: String) -> Self {
126 Self {
127 scissors: scissors.into(),
128 }
129 }
130}
131
132impl<'a> From<Scissors<'a>> for String {
133 fn from(scissors: Scissors<'a>) -> Self {
134 scissors.scissors.into()
135 }
136}
137
138#[cfg(test)]
139mod tests {
140 use super::*;
141 use indoc::indoc;
142
143 #[test]
144 fn can_give_me_it_as_string() {
145 let message = String::from(Scissors::from("hello, world!"));
146
147 assert_eq!(
148 message,
149 String::from("hello, world!"),
150 "Converting Scissors to String should preserve the content"
151 );
152 }
153
154 #[test]
155 fn it_can_be_created_from_a_string() {
156 let message = String::from(Scissors::from(String::from("hello, world!")));
157
158 assert_eq!(
159 message,
160 String::from("hello, world!"),
161 "Creating Scissors from String and converting back should preserve the content"
162 );
163 }
164
165 #[test]
166 fn it_can_guess_the_comment_character_from_scissors_without_other_parts() {
167 let comment_char = Scissors::guess_comment_character(
168 "# ------------------------ >8 ------------------------\n! Not the comment",
169 );
170
171 assert_eq!(
172 comment_char,
173 Some('#'),
174 "Should identify '#' as the comment character from the scissors line"
175 );
176 }
177
178 #[test]
179 fn it_can_guess_the_comment_character_from_scissors_without_comment() {
180 let comment_char = Scissors::guess_comment_character(indoc!(
181 "
182 Some text
183
184 ------------------------ >8 ------------------------
185 ; ------------------------ >8 ------------------------
186 ; \u{00E4}ndern oder entfernen Sie nicht die obige Zeile.
187 ; Alles unterhalb von ihr wird ignoriert.
188 diff --git a/file b/file
189 "
190 ));
191
192 assert_eq!(
193 comment_char,
194 Some(';'),
195 "Should identify ';' as the comment character from the scissors line"
196 );
197 }
198
199 #[test]
200 fn it_only_needs_the_scissors_and_no_there_lines() {
201 let comment_char = Scissors::guess_comment_character(indoc!(
202 "
203 Some text
204 ; ------------------------ >8 ------------------------
205 diff --git a/file b/file
206 "
207 ));
208
209 assert_eq!(
210 comment_char,
211 Some(';'),
212 "Should identify ';' as the comment character from a single scissors line"
213 );
214 }
215
216 #[test]
217 fn it_checks_a_space_must_be_after_the_comment_character_for_scissors_comment_guess() {
218 let comment_char = Scissors::guess_comment_character(indoc!(
219 "
220 Some text
221
222 ##------------------------ >8 ------------------------
223 ; ------------------------ >8 ------------------------
224 ; \u{00E4}ndern oder entfernen Sie nicht die obige Zeile.
225 ; Alles unterhalb von ihr wird ignoriert.
226 diff --git a/file b/file
227 "
228 ));
229
230 assert_eq!(
231 comment_char,
232 Some(';'),
233 "Should require a space after the comment character in scissors line"
234 );
235 }
236
237 #[test]
238 fn it_checks_there_are_no_additional_characters() {
239 let comment_char = Scissors::guess_comment_character(indoc!(
240 "
241 Some text
242
243 # !!!!!!!------------------------ >8 ------------------------
244 ; ------------------------ >8 ------------------------
245 ; \u{00E4}ndern oder entfernen Sie nicht die obige Zeile.
246 ; Alles unterhalb von ihr wird ignoriert.
247 diff --git a/file b/file
248 "
249 ));
250
251 assert_eq!(
252 comment_char,
253 Some(';'),
254 "Should not recognize lines with additional characters between comment and scissors marker"
255 );
256 }
257
258 #[test]
259 fn it_takes_the_last_scissors_if_there_are_multiple() {
260 let comment_char = Scissors::guess_comment_character(indoc!(
261 "
262 Some text
263
264 # ------------------------ >8 ------------------------
265 ; ------------------------ >8 ------------------------
266 ; \u{00E4}ndern oder entfernen Sie nicht die obige Zeile.
267 ; Alles unterhalb von ihr wird ignoriert.
268 diff --git a/file b/file
269 "
270 ));
271
272 assert_eq!(
273 comment_char,
274 Some(';'),
275 "Should use the last scissors line's comment character when multiple are present"
276 );
277 }
278
279 #[test]
280 fn it_returns_none_on_a_failure_to_find_the_comment_char_from_scissors() {
281 let comment_char = Scissors::guess_comment_character(indoc!(
282 "
283 Some text
284 "
285 ));
286
287 assert_eq!(
288 comment_char, None,
289 "Should return None when no scissors line is found"
290 );
291 }
292
293 #[test]
294 fn it_returns_none_on_empty_string() {
295 let comment_char = Scissors::guess_comment_character("");
296
297 assert_eq!(comment_char, None, "Should return None for empty string");
298 }
299
300 #[test]
301 fn it_returns_none_on_just_newlines() {
302 let comment_char = Scissors::guess_comment_character(&"\n".repeat(5));
303
304 assert_eq!(
305 comment_char, None,
306 "Should return None for string with only newlines"
307 );
308 }
309
310 #[test]
311 fn it_returns_the_last_valid_comment_when_there_are_multiple_options() {
312 let comment_char = Scissors::guess_comment_character(indoc!(
313 "
314 # I am a potential comment
315 @ I am a potential comment
316 ? I am a potential comment
317 "
318 ));
319
320 assert_eq!(
321 comment_char,
322 Some('@'),
323 "Should return the last valid comment character when no scissors line is found"
324 );
325 }
326
327 #[test]
328 fn it_can_extract_itself_from_commit() {
329 let sections = Scissors::parse_sections(indoc!(
330 "
331 Some text
332
333 # ------------------------ >8 ------------------------
334 # \u{00E4}ndern oder entfernen Sie nicht die obige Zeile.
335 # Alles unterhalb von ihr wird ignoriert.
336 diff --git a/file b/file
337 "
338 ));
339
340 assert_eq!(
341 sections,
342 (
343 Cow::from("Some text\n"),
344 Some(Scissors::from(indoc!(
345 "
346 # ------------------------ >8 ------------------------
347 # \u{00E4}ndern oder entfernen Sie nicht die obige Zeile.
348 # Alles unterhalb von ihr wird ignoriert.
349 diff --git a/file b/file
350 "
351 )))
352 ),
353 "Should correctly split the commit message at the scissors line"
354 );
355 }
356
357 #[test]
358 fn it_can_extract_itself_from_commit_with_a_standard_commit() {
359 let sections = Scissors::parse_sections(indoc!(
360 "
361 Some text
362
363 \u{00A3} ------------------------ >8 ------------------------
364 \u{00A3} \u{00E4}ndern oder entfernen Sie nicht die obige Zeile.
365 \u{00A3} Alles unterhalb von ihr wird ignoriert.
366 diff --git a/file b/file"
367 ));
368
369 assert_eq!(
370 sections,
371 (
372 Cow::from("Some text\n"),
373 Some(Scissors::from(indoc!(
374 "
375 \u{00A3} ------------------------ >8 ------------------------
376 \u{00A3} \u{00E4}ndern oder entfernen Sie nicht die obige Zeile.
377 \u{00A3} Alles unterhalb von ihr wird ignoriert.
378 diff --git a/file b/file"
379 )))
380 ),
381 "Should correctly split the commit message with non-ASCII comment characters"
382 );
383 }
384}