1use std::{
2 borrow::Cow,
3 convert::TryFrom,
4 hash::{Hash, Hasher},
5};
6
7use miette::Diagnostic;
8use thiserror::Error;
9
10use crate::{Fragment, body::Body};
11
12#[derive(Debug, Clone, Eq, Ord, PartialOrd)]
15pub struct Trailer<'a> {
16 key: Cow<'a, str>,
17 value: Cow<'a, str>,
18}
19
20impl<'a> Trailer<'a> {
21 #[must_use]
41 pub const fn new(key: Cow<'a, str>, value: Cow<'a, str>) -> Self {
42 Self { key, value }
43 }
44
45 #[must_use]
59 pub fn get_key(&self) -> String {
60 format!("{}", self.key)
61 }
62
63 #[must_use]
77 pub fn get_value(&self) -> String {
78 self.value.to_string()
79 }
80}
81
82impl PartialEq for Trailer<'_> {
83 fn eq(&self, other: &Self) -> bool {
84 self.key == other.key && self.value.trim_end() == other.value.trim_end()
85 }
86}
87
88impl Hash for Trailer<'_> {
89 fn hash<H: Hasher>(&self, state: &mut H) {
90 self.key.hash(state);
91 self.value.trim_end().hash(state);
92 }
93}
94
95impl From<Trailer<'_>> for String {
96 fn from(trailer: Trailer<'_>) -> Self {
97 format!("{}: {}", trailer.key, trailer.value)
98 }
99}
100
101impl<'a> From<Trailer<'a>> for Fragment<'a> {
102 fn from(trailer: Trailer<'_>) -> Self {
103 let trailer: String = trailer.into();
104 Body::from(trailer).into()
105 }
106}
107
108impl<'a> TryFrom<Body<'a>> for Trailer<'a> {
109 type Error = Error;
110
111 fn try_from(body: Body<'a>) -> Result<Self, Self::Error> {
112 let content: String = body.into();
113 let mut value_and_key = content.splitn(2, ": ").map(ToString::to_string);
114
115 let key: String = value_and_key
116 .next()
117 .ok_or_else(|| Error::new_not_a_trailer(&content))?;
118
119 let value: String = value_and_key
120 .next()
121 .ok_or_else(|| Error::new_not_a_trailer(&content))?;
122
123 Ok(Trailer::new(key.into(), value.into()))
124 }
125}
126
127#[derive(Error, Debug, Diagnostic)]
129pub enum Error {
130 #[error("not a trailer")]
132 #[diagnostic(url(docsrs), code(mit_commit::trailer::error::not_atrailer))]
133 NotATrailer(
134 #[source_code] String,
135 #[label("no colon in body line")] (usize, usize),
136 ),
137}
138
139impl Error {
140 fn new_not_a_trailer(text: &str) -> Self {
141 Self::NotATrailer(text.to_string(), (0, text.len()))
142 }
143}
144
145#[cfg(test)]
146mod tests {
147 use std::{
148 collections::hash_map::DefaultHasher,
149 convert::TryFrom,
150 hash::{Hash, Hasher},
151 };
152
153 use super::Trailer;
154 use crate::{Fragment, body::Body};
155
156 #[test]
157 fn it_can_tell_me_its_key() {
158 let trailer = Trailer::new("Relates-to".into(), "#128".into());
159
160 assert_eq!(trailer.get_key(), String::from("Relates-to"));
161 }
162
163 #[test]
164 fn it_can_tell_me_its_value() {
165 let trailer = Trailer::new("Relates-to".into(), "#128".into());
166
167 assert_eq!(trailer.get_value(), String::from("#128"));
168 }
169
170 #[test]
171 fn it_does_not_take_trailing_whitespace_into_account_in_equality_checks() {
172 let a = Trailer::new("Relates-to".into(), "#128\n".into());
173 let b = Trailer::new("Relates-to".into(), "#128".into());
174
175 assert_eq!(a, b);
176 }
177
178 #[test]
179 fn it_does_not_match_on_differing_vales() {
180 let a = Trailer::new("Relates-to".into(), "#129".into());
181 let b = Trailer::new("Relates-to".into(), "#128".into());
182
183 assert_ne!(a, b);
184 }
185
186 #[test]
187 fn it_does_not_match_on_differing_names() {
188 let a = Trailer::new("Another".into(), "#128".into());
189 let b = Trailer::new("Relates-to".into(), "#128".into());
190
191 assert_ne!(a, b);
192 }
193
194 #[test]
195 fn it_does_not_take_trailing_whitespace_into_account_in_hashing() {
196 let mut hasher_a = DefaultHasher::new();
197 Trailer::new("Relates-to".into(), "#128\n".into()).hash(&mut hasher_a);
198
199 let mut hasher_b = DefaultHasher::new();
200 Trailer::new("Relates-to".into(), "#128".into()).hash(&mut hasher_b);
201
202 assert_eq!(hasher_a.finish(), hasher_b.finish());
203 }
204
205 #[test]
206 fn it_differing_relates_headers_do_not_match_hashes() {
207 let mut hasher_a = DefaultHasher::new();
208 Trailer::new("Relates".into(), "#128".into()).hash(&mut hasher_a);
209
210 let mut hasher_b = DefaultHasher::new();
211 Trailer::new("Relates-to".into(), "#128".into()).hash(&mut hasher_b);
212
213 assert_ne!(hasher_a.finish(), hasher_b.finish());
214 }
215
216 #[test]
217 fn it_differing_relates_values_do_not_match_hashes() {
218 let mut hasher_a = DefaultHasher::new();
219 Trailer::new("Relates-to".into(), "#129".into()).hash(&mut hasher_a);
220
221 let mut hasher_b = DefaultHasher::new();
222 Trailer::new("Relates-to".into(), "#128".into()).hash(&mut hasher_b);
223
224 assert_ne!(hasher_a.finish(), hasher_b.finish());
225 }
226
227 #[test]
228 fn it_can_give_me_itself_as_a_string() {
229 let trailer = Trailer::new("Relates-to".into(), "#128".into());
230
231 assert_eq!(String::from(trailer), String::from("Relates-to: #128"));
232 }
233
234 #[test]
235 fn can_generate_itself_from_body() {
236 let trailer = Trailer::try_from(Body::from("Relates-to: #128"));
237
238 assert_eq!(
239 String::from(trailer.expect("Could not parse from string")),
240 String::from("Relates-to: #128")
241 );
242 }
243
244 #[test]
245 fn it_preserves_preceding_whitespace() {
246 let trailer = Trailer::try_from(Body::from("Relates-to: #128\n"));
247
248 assert_eq!(
249 String::from(trailer.expect("Could not parse from string")),
250 String::from("Relates-to: #128\n")
251 );
252 }
253
254 #[test]
255 fn can_generate_from_body() {
256 let trailer = Trailer::new("Relates-to".into(), "#128".into());
257 let body: Fragment<'_> = Fragment::from(trailer);
258 assert_eq!(body, Fragment::Body(Body::from("Relates-to: #128")));
259 }
260
261 #[test]
262 fn it_preserves_value_containing_colon_space() {
263 let trailer = Trailer::try_from(Body::from(
264 "See: fix crash in http://example.com:8080 handler",
265 ))
266 .expect("Should parse as a trailer");
267
268 assert_eq!(
269 String::from(trailer),
270 String::from("See: fix crash in http://example.com:8080 handler"),
271 "Trailer value containing ': ' should be preserved in full"
272 );
273 }
274
275 #[test]
276 fn it_preserves_value_with_multiple_colon_spaces() {
277 let trailer = Trailer::try_from(Body::from(
278 "Co-authored-by: Someone <someone@example.com>: extra",
279 ))
280 .expect("Should parse as a trailer");
281
282 assert_eq!(
283 trailer.get_key(),
284 "Co-authored-by",
285 "Key should only be the part before the first ': '"
286 );
287 assert_eq!(
288 trailer.get_value(),
289 "Someone <someone@example.com>: extra",
290 "Value should include everything after the first ': ', including subsequent ': '"
291 );
292 }
293}