Skip to main content

standard_commit/
format.rs

1use crate::parse::ConventionalCommit;
2
3/// Maximum header (subject) line length.
4const MAX_HEADER_LENGTH: usize = 100;
5/// Maximum body/footer line width (git convention).
6const BODY_LINE_WIDTH: usize = 72;
7
8/// Format a [`ConventionalCommit`] back into a well-formed conventional commit message string.
9///
10/// Applies line width rules:
11/// - Header is truncated to [`MAX_HEADER_LENGTH`] characters
12/// - Body lines are word-wrapped at [`BODY_LINE_WIDTH`] characters
13/// - Footer values are word-wrapped at [`BODY_LINE_WIDTH`] characters
14pub fn format(commit: &ConventionalCommit) -> String {
15    let mut msg = String::new();
16
17    // Header: type[(scope)][!]: description
18    msg.push_str(&commit.r#type);
19    if let Some(scope) = &commit.scope {
20        msg.push('(');
21        msg.push_str(scope);
22        msg.push(')');
23    }
24    if commit.is_breaking {
25        msg.push('!');
26    }
27    msg.push_str(": ");
28    msg.push_str(&commit.description);
29
30    // Truncate header to max length
31    if msg.len() > MAX_HEADER_LENGTH {
32        msg.truncate(MAX_HEADER_LENGTH);
33    }
34
35    // Body — word-wrapped
36    if let Some(body) = &commit.body {
37        msg.push_str("\n\n");
38        msg.push_str(&wrap_text(body, BODY_LINE_WIDTH));
39    }
40
41    // Footers — values word-wrapped
42    if !commit.footers.is_empty() {
43        msg.push_str("\n\n");
44        for (i, footer) in commit.footers.iter().enumerate() {
45            if i > 0 {
46                msg.push('\n');
47            }
48            let prefix = format!("{}: ", footer.token);
49            let indent_width = BODY_LINE_WIDTH.saturating_sub(prefix.len());
50            if indent_width > 0 && prefix.len() + footer.value.len() > BODY_LINE_WIDTH {
51                msg.push_str(&prefix);
52                msg.push_str(&wrap_text(&footer.value, indent_width));
53            } else {
54                msg.push_str(&prefix);
55                msg.push_str(&footer.value);
56            }
57        }
58    }
59
60    msg
61}
62
63/// Word-wrap text to the given width, preserving paragraph breaks (`\n\n`).
64fn wrap_text(text: &str, width: usize) -> String {
65    text.split("\n\n")
66        .map(|paragraph| wrap_paragraph(paragraph, width))
67        .collect::<Vec<_>>()
68        .join("\n\n")
69}
70
71fn wrap_paragraph(paragraph: &str, width: usize) -> String {
72    let mut lines: Vec<String> = Vec::new();
73    let mut current_line = String::new();
74
75    for word in paragraph.split_whitespace() {
76        if current_line.is_empty() {
77            current_line.push_str(word);
78        } else if current_line.len() + 1 + word.len() > width {
79            lines.push(current_line);
80            current_line = word.to_string();
81        } else {
82            current_line.push(' ');
83            current_line.push_str(word);
84        }
85    }
86    if !current_line.is_empty() {
87        lines.push(current_line);
88    }
89    lines.join("\n")
90}
91
92#[cfg(test)]
93mod tests {
94    use super::*;
95    use crate::parse::Footer;
96
97    #[test]
98    fn minimal() {
99        let commit = ConventionalCommit {
100            r#type: "feat".into(),
101            scope: None,
102            description: "add login".into(),
103            body: None,
104            footers: vec![],
105            is_breaking: false,
106        };
107        assert_eq!(format(&commit), "feat: add login");
108    }
109
110    #[test]
111    fn with_scope() {
112        let commit = ConventionalCommit {
113            r#type: "fix".into(),
114            scope: Some("auth".into()),
115            description: "handle tokens".into(),
116            body: None,
117            footers: vec![],
118            is_breaking: false,
119        };
120        assert_eq!(format(&commit), "fix(auth): handle tokens");
121    }
122
123    #[test]
124    fn breaking_with_bang() {
125        let commit = ConventionalCommit {
126            r#type: "feat".into(),
127            scope: None,
128            description: "remove legacy API".into(),
129            body: None,
130            footers: vec![],
131            is_breaking: true,
132        };
133        assert_eq!(format(&commit), "feat!: remove legacy API");
134    }
135
136    #[test]
137    fn with_body() {
138        let commit = ConventionalCommit {
139            r#type: "feat".into(),
140            scope: None,
141            description: "add PKCE".into(),
142            body: Some("Full PKCE flow.".into()),
143            footers: vec![],
144            is_breaking: false,
145        };
146        assert_eq!(format(&commit), "feat: add PKCE\n\nFull PKCE flow.");
147    }
148
149    #[test]
150    fn with_footers() {
151        let commit = ConventionalCommit {
152            r#type: "fix".into(),
153            scope: None,
154            description: "fix bug".into(),
155            body: None,
156            footers: vec![
157                Footer {
158                    token: "Refs".into(),
159                    value: "#42".into(),
160                },
161                Footer {
162                    token: "Reviewed-by".into(),
163                    value: "Alice".into(),
164                },
165            ],
166            is_breaking: false,
167        };
168        assert_eq!(
169            format(&commit),
170            "fix: fix bug\n\nRefs: #42\nReviewed-by: Alice"
171        );
172    }
173
174    #[test]
175    fn roundtrip() {
176        let msg = "feat(auth): add OAuth2 PKCE flow";
177        let commit = crate::parse::parse(msg).unwrap();
178        assert_eq!(format(&commit), msg);
179    }
180
181    #[test]
182    fn body_wraps_at_72() {
183        let long_body = "This is a long body line that should be wrapped because it exceeds the maximum line width of seventy-two characters per line";
184        let commit = ConventionalCommit {
185            r#type: "feat".into(),
186            scope: None,
187            description: "test".into(),
188            body: Some(long_body.into()),
189            footers: vec![],
190            is_breaking: false,
191        };
192        let msg = format(&commit);
193        // Skip header line, check body lines
194        for line in msg.lines().skip(2) {
195            assert!(
196                line.len() <= 72,
197                "body line too long ({}): {}",
198                line.len(),
199                line
200            );
201        }
202    }
203
204    #[test]
205    fn body_preserves_paragraphs() {
206        let commit = ConventionalCommit {
207            r#type: "feat".into(),
208            scope: None,
209            description: "test".into(),
210            body: Some("First paragraph.\n\nSecond paragraph.".into()),
211            footers: vec![],
212            is_breaking: false,
213        };
214        let msg = format(&commit);
215        assert!(msg.contains("First paragraph.\n\nSecond paragraph."));
216    }
217
218    #[test]
219    fn header_truncated_at_100() {
220        let long_desc = "a".repeat(200);
221        let commit = ConventionalCommit {
222            r#type: "feat".into(),
223            scope: None,
224            description: long_desc,
225            body: None,
226            footers: vec![],
227            is_breaking: false,
228        };
229        let msg = format(&commit);
230        let header = msg.lines().next().unwrap();
231        assert_eq!(header.len(), 100);
232    }
233}