mit_commit/
scissors.rs

1use std::borrow::Cow;
2
3use crate::Comment;
4
5const SCISSORS_MARKER: &str = "------------------------ >8 ------------------------";
6
7/// The [`Scissors`] from a [`CommitMessage`]
8///
9/// Represents the scissors section of a commit message, which separates the commit message
10/// from the diff or other content that should not be included in the commit message.
11#[derive(Debug, PartialEq, Eq, Clone)]
12pub struct Scissors<'a> {
13    scissors: Cow<'a, str>,
14}
15
16impl<'a> Scissors<'a> {
17    /// Attempts to guess the comment character used in a commit message.
18    ///
19    /// # Arguments
20    ///
21    /// * `message` - The commit message to analyze
22    ///
23    /// # Returns
24    ///
25    /// The comment character if one can be determined, or None if no comment character is found
26    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    /// Attempts to guess the comment character by looking at the first character of each line.
32    ///
33    /// # Arguments
34    ///
35    /// * `message` - The commit message to analyze
36    ///
37    /// # Returns
38    ///
39    /// The last valid comment character found, or None if no valid comment character is found
40    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    /// Attempts to guess the comment character by looking for scissors markers.
52    ///
53    /// # Arguments
54    ///
55    /// * `message` - The commit message to analyze
56    ///
57    /// # Returns
58    ///
59    /// The comment character from the scissors line, or None if no scissors line is found
60    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    /// Parses a commit message into body and scissors sections.
79    ///
80    /// # Arguments
81    ///
82    /// * `message` - The commit message to parse
83    ///
84    /// # Returns
85    ///
86    /// A tuple containing the body of the commit message and an optional scissors section
87    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            // No scissors section found
114            (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}