1use serde::de::{Error as DeError, Visitor};
2use serde::{Deserialize, Deserializer, Serialize, Serializer};
3use std::fmt;
4use std::num::ParseIntError;
5use std::str::FromStr;
6
7#[derive(Debug, Clone, PartialEq, Eq)]
11pub struct GitInfo {
12 pub tag: Option<String>,
13 pub commits_past_tag: Option<usize>,
14 pub commit_hash_short: String,
15 pub dirty: bool,
16}
17
18impl GitInfo {
19 pub fn new<T: Into<String>>(
20 tag: Option<T>,
21 commits_past_tag: Option<usize>,
22 commit_hash_short: T,
23 dirty: bool,
24 ) -> Self {
25 GitInfo {
26 tag: tag.map(Into::into),
27 commits_past_tag,
28 commit_hash_short: commit_hash_short.into(),
29 dirty,
30 }
31 }
32}
33
34impl fmt::Display for GitInfo {
35 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
36 if let (Some(tag), Some(count)) = (&self.tag, &self.commits_past_tag) {
37 if self.dirty {
39 write!(f, "{}-{}-{}-dirty", tag, count, self.commit_hash_short)
40 } else {
41 write!(f, "{}-{}-{}", tag, count, self.commit_hash_short)
42 }
43 } else {
44 if self.dirty {
46 write!(f, "{}-dirty", self.commit_hash_short)
47 } else {
48 write!(f, "{}", self.commit_hash_short)
49 }
50 }
51 }
52}
53
54impl FromStr for GitInfo {
55 type Err = String;
56
57 fn from_str(s: &str) -> Result<Self, Self::Err> {
58 let s = s.trim();
59 if s.is_empty() {
60 return Err("Empty input string".into());
61 }
62
63 let (base, dirty) = if let Some(stripped) = s.strip_suffix("-dirty") {
65 if stripped.is_empty() {
66 return Err("Empty GitInfo before `-dirty`".into());
67 }
68 (stripped, true)
69 } else {
70 (s, false)
71 };
72
73 if let Some(idx1) = base.rfind('-') {
75 let after_idx1 = &base[idx1 + 1..];
76 if after_idx1.is_empty() {
77 return Err("Empty hash after last '-'".into());
78 }
79 if let Some(idx2) = base[..idx1].rfind('-') {
81 let tag_part = base[..idx2].trim();
82 let commits_part = base[idx2 + 1..idx1].trim();
83 let hash_part = after_idx1.trim();
84
85 if tag_part.is_empty() {
86 return Err("Tag is empty before commits count".into());
87 }
88 if commits_part.is_empty() {
89 return Err("Commits-past-tag portion is empty".into());
90 }
91 let commits_num: usize = commits_part.parse().map_err(|e: ParseIntError| {
93 format!("Invalid commits-past-tag `{}`: {}", commits_part, e)
94 })?;
95
96 if !hash_part.chars().all(|c| c.is_ascii_hexdigit()) {
98 return Err(format!("Invalid commit-hash `{}`", hash_part));
99 }
100
101 return Ok(GitInfo {
102 tag: Some(tag_part.to_string()),
103 commits_past_tag: Some(commits_num),
104 commit_hash_short: hash_part.to_string(),
105 dirty,
106 });
107 }
108 }
110
111 let hash_candidate = base;
113 if !hash_candidate.chars().all(|c| c.is_ascii_hexdigit()) {
114 return Err(format!("Invalid commit-hash `{}`", hash_candidate));
115 }
116 Ok(GitInfo {
117 tag: None,
118 commits_past_tag: None,
119 commit_hash_short: hash_candidate.to_string(),
120 dirty,
121 })
122 }
123}
124
125impl Serialize for GitInfo {
126 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
127 where
128 S: Serializer,
129 {
130 let as_str = self.to_string();
131 serializer.serialize_str(&as_str)
132 }
133}
134
135struct GitInfoVisitor;
136
137impl<'de> Visitor<'de> for GitInfoVisitor {
138 type Value = GitInfo;
139
140 fn expecting(&self, fmt: &mut fmt::Formatter) -> fmt::Result {
141 write!(
142 fmt,
143 "a string of the form \"tag-<count>-<hash>[-dirty]\" or just \"<hash>[-dirty]\""
144 )
145 }
146
147 fn visit_str<E>(self, v: &str) -> Result<GitInfo, E>
148 where
149 E: DeError,
150 {
151 GitInfo::from_str(v).map_err(DeError::custom)
152 }
153
154 fn visit_string<E>(self, v: String) -> Result<GitInfo, E>
155 where
156 E: DeError,
157 {
158 GitInfo::from_str(&v).map_err(DeError::custom)
159 }
160}
161
162impl<'de> Deserialize<'de> for GitInfo {
163 fn deserialize<D>(deserializer: D) -> Result<GitInfo, D::Error>
164 where
165 D: Deserializer<'de>,
166 {
167 deserializer.deserialize_string(GitInfoVisitor)
168 }
169}
170
171#[cfg(test)]
172mod tests {
173 use super::*;
174 use serde_json;
175
176 #[test]
177 fn gitinfo_display_with_tag_clean() {
178 let gi = GitInfo::new(Some("v0.2.0"), Some(95), "5a85959", false);
179 assert_eq!(gi.to_string(), "v0.2.0-95-5a85959");
180
181 let gi2 = GitInfo::new(Some("release-1.2.3"), Some(4), "abcd123", false);
183 assert_eq!(gi2.to_string(), "release-1.2.3-4-abcd123");
184 }
185
186 #[test]
187 fn gitinfo_display_with_tag_dirty() {
188 let gi = GitInfo::new(Some("v0.2.0"), Some(95), "5a85959", true);
189 assert_eq!(gi.to_string(), "v0.2.0-95-5a85959-dirty");
190
191 let gi2 = GitInfo::new(Some("release-1.2.3"), Some(4), "abcd123", true);
192 assert_eq!(gi2.to_string(), "release-1.2.3-4-abcd123-dirty");
193 }
194
195 #[test]
196 fn gitinfo_display_without_tag_clean() {
197 let gi = GitInfo::new(None::<&str>, None, "deadbeef", false);
198 assert_eq!(gi.to_string(), "deadbeef");
199 }
200
201 #[test]
202 fn gitinfo_display_without_tag_dirty() {
203 let gi = GitInfo::new(None::<&str>, None, "deadbeef", true);
204 assert_eq!(gi.to_string(), "deadbeef-dirty");
205 }
206
207 #[test]
208 fn gitinfo_from_str_with_tag_clean() {
209 let s = "v0.2.0-95-5a85959";
210 let gi: GitInfo = s.parse().expect("Parsing failed");
211 assert_eq!(
212 gi,
213 GitInfo {
214 tag: Some("v0.2.0".into()),
215 commits_past_tag: Some(95),
216 commit_hash_short: "5a85959".into(),
217 dirty: false,
218 }
219 );
220
221 let s2 = "release-1.2.3-4-abcd123";
223 let gi2: GitInfo = s2.parse().expect("Parsing failed");
224 assert_eq!(
225 gi2,
226 GitInfo {
227 tag: Some("release-1.2.3".into()),
228 commits_past_tag: Some(4),
229 commit_hash_short: "abcd123".into(),
230 dirty: false,
231 }
232 );
233 }
234
235 #[test]
236 fn gitinfo_from_str_with_tag_dirty() {
237 let s = "v0.2.0-95-5a85959-dirty";
238 let gi: GitInfo = s.parse().expect("Parsing failed");
239 assert_eq!(
240 gi,
241 GitInfo {
242 tag: Some("v0.2.0".into()),
243 commits_past_tag: Some(95),
244 commit_hash_short: "5a85959".into(),
245 dirty: true,
246 }
247 );
248
249 let s2 = "release-1.2.3-4-abcd123-dirty";
250 let gi2: GitInfo = s2.parse().expect("Parsing failed");
251 assert_eq!(
252 gi2,
253 GitInfo {
254 tag: Some("release-1.2.3".into()),
255 commits_past_tag: Some(4),
256 commit_hash_short: "abcd123".into(),
257 dirty: true,
258 }
259 );
260 }
261
262 #[test]
263 fn gitinfo_from_str_without_tag_clean() {
264 let s = "abcdef";
265 let gi: GitInfo = s.parse().expect("Parsing failed");
266 assert_eq!(
267 gi,
268 GitInfo {
269 tag: None,
270 commits_past_tag: None,
271 commit_hash_short: "abcdef".into(),
272 dirty: false,
273 }
274 );
275 }
276
277 #[test]
278 fn gitinfo_from_str_without_tag_dirty() {
279 let s = "abcdef-dirty";
280 let gi: GitInfo = s.parse().expect("Parsing failed");
281 assert_eq!(
282 gi,
283 GitInfo {
284 tag: None,
285 commits_past_tag: None,
286 commit_hash_short: "abcdef".into(),
287 dirty: true,
288 }
289 );
290 }
291
292 #[test]
293 fn gitinfo_from_str_invalid() {
294 let err = GitInfo::from_str("v1.0.0-5-").unwrap_err();
296 assert!(err.contains("Empty hash"), "Unexpected error: {}", err);
297
298 let err2 = GitInfo::from_str("v1.0.0-xx-abcdef").unwrap_err();
300 assert!(
301 err2.contains("Invalid commits-past-tag"),
302 "Unexpected error: {}",
303 err2
304 );
305
306 let err3 = GitInfo::from_str("v1.0.0-5-ghijk").unwrap_err();
308 assert!(
309 err3.contains("Invalid commit-hash"),
310 "Unexpected error: {}",
311 err3
312 );
313
314 let err4 = GitInfo::from_str("").unwrap_err();
316 assert!(err4.contains("Empty input string"));
317
318 let err5 = GitInfo::from_str("-dirty").unwrap_err();
320 assert!(err5.contains("Empty GitInfo before `-dirty`"));
321 }
322
323 #[test]
324 fn gitinfo_serialize_deserialize() {
325 let gi = GitInfo::new(Some("v0.2.0"), Some(95), "5a85959", false);
327 let json = serde_json::to_string(&gi).expect("Serialization failed");
328 assert_eq!(json, r#""v0.2.0-95-5a85959""#);
329
330 let parsed: GitInfo = serde_json::from_str(&json).expect("Deserialization failed");
331 assert_eq!(parsed, gi);
332
333 let gi2 = GitInfo::new(Some("v0.2.0"), Some(95), "5a85959", true);
335 let json2 = serde_json::to_string(&gi2).expect("Serialization failed");
336 assert_eq!(json2, r#""v0.2.0-95-5a85959-dirty""#);
337
338 let parsed2: GitInfo = serde_json::from_str(&json2).expect("Deserialization failed");
339 assert_eq!(parsed2, gi2);
340
341 let gi3 = GitInfo::new(None::<&str>, None, "deadbeef", false);
343 let json3 = serde_json::to_string(&gi3).expect("Serialization failed");
344 assert_eq!(json3, r#""deadbeef""#);
345
346 let parsed3: GitInfo = serde_json::from_str(&json3).expect("Deserialization failed");
347 assert_eq!(parsed3, gi3);
348
349 let gi4 = GitInfo::new(None::<&str>, None, "deadbeef", true);
351 let json4 = serde_json::to_string(&gi4).expect("Serialization failed");
352 assert_eq!(json4, r#""deadbeef-dirty""#);
353
354 let parsed4: GitInfo = serde_json::from_str(&json4).expect("Deserialization failed");
355 assert_eq!(parsed4, gi4);
356 }
357
358 #[test]
359 fn gitinfo_serde_error() {
360 let bad = r#""v1.0.0-5-""#; let res: Result<GitInfo, _> = serde_json::from_str(bad);
362 assert!(res.is_err());
363
364 let bad_dirty = r#""-dirty""#; let res2: Result<GitInfo, _> = serde_json::from_str(bad_dirty);
366 assert!(res2.is_err());
367 }
368}