Skip to main content

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        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}