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