Skip to main content

jj_cz/commit/types/
description.rs

1#[derive(Debug, Clone, PartialEq, Eq)]
2#[repr(transparent)]
3pub struct Description(String);
4
5impl Description {
6    /// Soft limit for description length.
7    ///
8    /// Descriptions over this length are warned about at the prompt layer but
9    /// are not rejected here - the hard limit is the 72-character total first
10    /// line enforced by [`crate::ConventionalCommit`].
11    pub const MAX_LENGTH: usize = 50;
12
13    /// Parse and validate a description string
14    ///
15    /// # Validation
16    /// - Trims leading/trailing whitespace
17    /// - Rejects empty or whitespace-only input
18    ///
19    /// The 50-character soft limit is enforced at the prompt layer with a
20    /// warning rather than here, to allow descriptions slightly over the
21    /// limit where the 72-character total first-line limit is still satisfied.
22    pub fn parse(value: impl Into<String>) -> Result<Self, DescriptionError> {
23        let value = value.into().trim().to_owned();
24        if value.is_empty() {
25            Err(DescriptionError::Empty)
26        } else {
27            Ok(Self(value))
28        }
29    }
30
31    /// Returns the inner string slice
32    pub fn as_str(&self) -> &str {
33        &self.0
34    }
35
36    /// Returns the length in characters
37    ///
38    /// `is_empty()` is intentionally absent: `Description` is guaranteed
39    /// non-empty by its constructor, so the concept does not apply.
40    #[allow(clippy::len_without_is_empty)]
41    pub fn len(&self) -> usize {
42        self.0.chars().count()
43    }
44}
45
46impl AsRef<str> for Description {
47    fn as_ref(&self) -> &str {
48        &self.0
49    }
50}
51
52impl std::fmt::Display for Description {
53    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
54        write!(f, "{}", self.0)
55    }
56}
57
58#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
59pub enum DescriptionError {
60    #[error("Description cannot be empty")]
61    Empty,
62}
63
64#[cfg(test)]
65mod tests {
66    use super::*;
67
68    /// Test that valid description is accepted
69    #[test]
70    fn valid_description_accepted() {
71        let result = Description::parse("add new feature");
72        assert!(result.is_ok());
73        assert_eq!(result.unwrap().as_str(), "add new feature");
74    }
75
76    /// Test that single character description is accepted
77    #[test]
78    fn single_character_description_accepted() {
79        let result = Description::parse("a");
80        assert!(result.is_ok());
81        assert_eq!(result.unwrap().as_str(), "a");
82    }
83
84    /// Test that description with numbers is accepted
85    #[test]
86    fn description_with_numbers_accepted() {
87        let result = Description::parse("fix issue #123");
88        assert!(result.is_ok());
89        assert_eq!(result.unwrap().as_str(), "fix issue #123");
90    }
91
92    /// Test that description with special characters is accepted
93    #[test]
94    fn description_with_special_chars_accepted() {
95        let result = Description::parse("add @decorator support (beta)");
96        assert!(result.is_ok());
97        assert_eq!(result.unwrap().as_str(), "add @decorator support (beta)");
98    }
99
100    /// Test that description with punctuation is accepted
101    #[test]
102    fn description_with_punctuation_accepted() {
103        let result = Description::parse("fix: handle edge case!");
104        assert!(result.is_ok());
105        assert_eq!(result.unwrap().as_str(), "fix: handle edge case!");
106    }
107
108    /// Test that empty string is rejected with DescriptionError::Empty
109    #[test]
110    fn empty_string_rejected() {
111        let result = Description::parse("");
112        assert!(result.is_err());
113        assert_eq!(result.unwrap_err(), DescriptionError::Empty);
114    }
115
116    /// Test that whitespace-only is rejected with DescriptionError::Empty
117    #[test]
118    fn whitespace_only_rejected() {
119        let result = Description::parse("   ");
120        assert!(result.is_err());
121        assert_eq!(result.unwrap_err(), DescriptionError::Empty);
122    }
123
124    /// Test that tabs-only is rejected
125    #[test]
126    fn tabs_only_rejected() {
127        let result = Description::parse("\t\t");
128        assert!(result.is_err());
129        assert_eq!(result.unwrap_err(), DescriptionError::Empty);
130    }
131
132    /// Test that mixed whitespace is rejected
133    #[test]
134    fn mixed_whitespace_rejected() {
135        let result = Description::parse("  \t  \n  ");
136        assert!(result.is_err());
137        assert_eq!(result.unwrap_err(), DescriptionError::Empty);
138    }
139
140    /// Test that newline-only is rejected
141    #[test]
142    fn newline_only_rejected() {
143        let result = Description::parse("\n");
144        assert!(result.is_err());
145        assert_eq!(result.unwrap_err(), DescriptionError::Empty);
146    }
147
148    /// Test that leading whitespace is trimmed
149    #[test]
150    fn leading_whitespace_trimmed() {
151        let result = Description::parse("  add feature");
152        assert!(result.is_ok());
153        assert_eq!(result.unwrap().as_str(), "add feature");
154    }
155
156    /// Test that trailing whitespace is trimmed
157    #[test]
158    fn trailing_whitespace_trimmed() {
159        let result = Description::parse("add feature  ");
160        assert!(result.is_ok());
161        assert_eq!(result.unwrap().as_str(), "add feature");
162    }
163
164    /// Test that both leading and trailing whitespace is trimmed
165    #[test]
166    fn leading_and_trailing_whitespace_trimmed() {
167        let result = Description::parse("  add feature  ");
168        assert!(result.is_ok());
169        assert_eq!(result.unwrap().as_str(), "add feature");
170    }
171
172    /// Test that internal whitespace is preserved
173    #[test]
174    fn internal_whitespace_preserved() {
175        let result = Description::parse("add   multiple   spaces");
176        assert!(result.is_ok());
177        assert_eq!(result.unwrap().as_str(), "add   multiple   spaces");
178    }
179
180    /// Test that descriptions over the 50-char soft limit are accepted
181    ///
182    /// The 50-char limit is enforced as a prompt-layer warning only.
183    /// The hard limit is the 72-char total first line (ConventionalCommit).
184    #[test]
185    fn description_over_soft_limit_accepted() {
186        let desc_51 = "a".repeat(51);
187        let result = Description::parse(&desc_51);
188        assert!(result.is_ok());
189        assert_eq!(result.unwrap().len(), 51);
190
191        let desc_72 = "a".repeat(72);
192        let result = Description::parse(&desc_72);
193        assert!(result.is_ok());
194        assert_eq!(result.unwrap().len(), 72);
195    }
196
197    /// Test that length is checked after trimming
198    #[test]
199    fn length_checked_after_trimming() {
200        // 50 chars + leading/trailing spaces = should be valid after trim
201        let desc_with_spaces = format!("  {}  ", "a".repeat(50));
202        let result = Description::parse(&desc_with_spaces);
203        assert!(result.is_ok());
204        assert_eq!(result.unwrap().as_str().len(), 50);
205    }
206
207    /// Test that 50 characters is accepted without issue
208    #[test]
209    fn fifty_characters_accepted() {
210        let desc_50 = "a".repeat(50);
211        let result = Description::parse(&desc_50);
212        assert!(result.is_ok());
213        assert_eq!(result.unwrap().as_str().len(), 50);
214    }
215
216    /// Test MAX_LENGTH constant is 50 (soft limit)
217    #[test]
218    fn max_length_constant_is_50() {
219        assert_eq!(Description::MAX_LENGTH, 50);
220    }
221
222    /// Test as_str() returns inner string
223    #[test]
224    fn as_str_returns_inner_string() {
225        let desc = Description::parse("my description").unwrap();
226        assert_eq!(desc.as_str(), "my description");
227    }
228
229    /// Test len() returns correct length for ASCII input
230    #[test]
231    fn len_returns_correct_length() {
232        let desc = Description::parse("hello").unwrap();
233        assert_eq!(desc.len(), 5);
234    }
235
236    /// Test len() counts Unicode scalar values, not bytes
237    ///
238    /// Multi-byte characters (accented letters, CJK, emoji) must count as one
239    /// character each so that the 72-char first-line limit is applied correctly.
240    #[test]
241    fn len_counts_unicode_chars_not_bytes() {
242        // "café" = 4 chars, 5 bytes (é is 2 bytes in UTF-8)
243        let desc = Description::parse("café").unwrap();
244        assert_eq!(desc.len(), 4);
245
246        // Emoji: "fix 🐛" = 5 chars, 9 bytes (🐛 is 4 bytes)
247        let desc = Description::parse("fix 🐛").unwrap();
248        assert_eq!(desc.len(), 5);
249    }
250
251    /// Test Display trait implementation
252    #[test]
253    fn display_outputs_inner_string() {
254        let desc = Description::parse("add feature").unwrap();
255        assert_eq!(format!("{}", desc), "add feature");
256    }
257
258    /// Test Clone trait
259    #[test]
260    fn description_is_cloneable() {
261        let original = Description::parse("add feature").unwrap();
262        let cloned = original.clone();
263        assert_eq!(original, cloned);
264    }
265
266    /// Test PartialEq trait
267    #[test]
268    fn description_equality() {
269        let desc1 = Description::parse("add feature").unwrap();
270        let desc2 = Description::parse("add feature").unwrap();
271        let desc3 = Description::parse("fix bug").unwrap();
272        assert_eq!(desc1, desc2);
273        assert_ne!(desc1, desc3);
274    }
275
276    /// Test Debug trait
277    #[test]
278    fn description_has_debug() {
279        let desc = Description::parse("add feature").unwrap();
280        let debug_output = format!("{:?}", desc);
281        assert!(debug_output.contains("Description"));
282        assert!(debug_output.contains("add feature"));
283    }
284
285    /// Test AsRef<str> trait
286    #[test]
287    fn description_as_ref_str() {
288        let desc = Description::parse("add feature").unwrap();
289        let s: &str = desc.as_ref();
290        assert_eq!(s, "add feature");
291    }
292
293    /// Test DescriptionError::Empty displays correctly
294    #[test]
295    fn empty_error_display() {
296        let err = DescriptionError::Empty;
297        let msg = format!("{}", err);
298        assert!(msg.contains("cannot be empty"));
299    }
300
301    /// Test description with only whitespace after trim becomes empty
302    #[test]
303    fn whitespace_after_trim_is_empty() {
304        // Ensure various whitespace combinations all result in Empty error
305        let whitespace_variants = [" ", "  ", "\t", "\n", "\r\n", " \t \n "];
306        for ws in whitespace_variants {
307            let result = Description::parse(ws);
308            assert!(result.is_err(), "Expected error for whitespace: {:?}", ws);
309            assert_eq!(
310                result.unwrap_err(),
311                DescriptionError::Empty,
312                "Expected Empty error for whitespace: {:?}",
313                ws
314            );
315        }
316    }
317
318    /// Test description at exact boundary after trimming
319    #[test]
320    fn boundary_length_after_trim() {
321        // 50 chars + 2 spaces on each side = 54 chars total, but 50 after trim
322        let desc = format!("  {}  ", "x".repeat(50));
323        let result = Description::parse(&desc);
324        assert!(result.is_ok());
325        assert_eq!(result.unwrap().len(), 50);
326    }
327
328    /// Test description just over soft limit is accepted after trimming
329    ///
330    /// 51 chars (trimmed) is over the soft limit but still valid as a Description.
331    #[test]
332    fn over_soft_limit_after_trim_accepted() {
333        let desc = format!("  {}  ", "x".repeat(51));
334        let result = Description::parse(&desc);
335        assert!(result.is_ok());
336        assert_eq!(result.unwrap().len(), 51);
337    }
338}