standard_commit/
format.rs1use crate::parse::ConventionalCommit;
2
3const MAX_HEADER_LENGTH: usize = 100;
5const BODY_LINE_WIDTH: usize = 72;
7
8pub fn format(commit: &ConventionalCommit) -> String {
15 let mut msg = String::new();
16
17 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 if msg.len() > MAX_HEADER_LENGTH {
32 msg.truncate(MAX_HEADER_LENGTH);
33 }
34
35 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 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
63fn 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 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}