Skip to main content

borderless_pkg/
git_info.rs

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/// Represents Git “describe” information in the form:
8/// - `tag-<commits>-<hash>[-dirty]`
9/// - or, if there is no tag, just `<hash>[-dirty]`
10#[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            // Format as "tag-<commits>-<hash>"
38            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            // No tag: just emit the hash
45            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        // Check for "-dirty" suffix
64        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        // Attempt to parse "tag-<commits>-<hash>" by finding the last two hyphens in `base`.
74        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            // Now find the previous '-' before idx1
80            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                // Parse commits count
92                let commits_num: usize = commits_part.parse().map_err(|e: ParseIntError| {
93                    format!("Invalid commits-past-tag `{}`: {}", commits_part, e)
94                })?;
95
96                // Validate that hash_part is hex
97                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            // If we found one hyphen but not a second one, fall back to "only hash" logic below.
109        }
110
111        // Treat `base` as just a hash (no tag/commits)
112        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        // Tag containing hyphens
182        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        // Hyphens in tag
222        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        // Missing hash after last hyphen
295        let err = GitInfo::from_str("v1.0.0-5-").unwrap_err();
296        assert!(err.contains("Empty hash"), "Unexpected error: {}", err);
297
298        // Non-numeric commits
299        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        // Invalid hex in hash
307        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        // Empty string
315        let err4 = GitInfo::from_str("").unwrap_err();
316        assert!(err4.contains("Empty input string"));
317
318        // Just "-dirty"
319        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        // With tag, clean
326        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        // With tag, dirty
334        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        // Without tag, clean
342        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        // Without tag, dirty
350        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-""#; // empty hash
361        let res: Result<GitInfo, _> = serde_json::from_str(bad);
362        assert!(res.is_err());
363
364        let bad_dirty = r#""-dirty""#; // no base before "-dirty"
365        let res2: Result<GitInfo, _> = serde_json::from_str(bad_dirty);
366        assert!(res2.is_err());
367    }
368}