Skip to main content

git_atomic/core/
refspec.rs

1/// Parsed ref specification: either a single ref or a two-dot range.
2#[derive(Debug, Clone, PartialEq, Eq)]
3pub enum RefSpec {
4    /// A single commit reference (e.g. "HEAD", "abc123", "HEAD~3").
5    Single(String),
6    /// A range of commits using `A..B` syntax.
7    Range { start: String, end: String },
8}
9
10impl RefSpec {
11    /// Parse a ref string into a `RefSpec`.
12    ///
13    /// - Contains `...` → error (triple-dot not supported)
14    /// - Contains `..` → `Range` (empty sides default to HEAD)
15    /// - Otherwise → `Single`
16    pub fn parse(input: &str) -> Result<Self, String> {
17        if input.contains("...") {
18            return Err("triple-dot syntax is not supported; use `A..B`".into());
19        }
20        match input.split_once("..") {
21            Some((start, end)) => {
22                let start = if start.is_empty() { "HEAD" } else { start };
23                let end = if end.is_empty() { "HEAD" } else { end };
24                Ok(RefSpec::Range {
25                    start: start.to_string(),
26                    end: end.to_string(),
27                })
28            }
29            None => Ok(RefSpec::Single(input.to_string())),
30        }
31    }
32}
33
34#[cfg(test)]
35mod tests {
36    use super::*;
37
38    #[test]
39    fn parse_single_ref() {
40        assert_eq!(
41            RefSpec::parse("HEAD").unwrap(),
42            RefSpec::Single("HEAD".into())
43        );
44        assert_eq!(
45            RefSpec::parse("abc123").unwrap(),
46            RefSpec::Single("abc123".into())
47        );
48        assert_eq!(
49            RefSpec::parse("HEAD~3").unwrap(),
50            RefSpec::Single("HEAD~3".into())
51        );
52    }
53
54    #[test]
55    fn parse_range() {
56        assert_eq!(
57            RefSpec::parse("main..feature").unwrap(),
58            RefSpec::Range {
59                start: "main".into(),
60                end: "feature".into()
61            }
62        );
63        assert_eq!(
64            RefSpec::parse("HEAD~3..HEAD").unwrap(),
65            RefSpec::Range {
66                start: "HEAD~3".into(),
67                end: "HEAD".into()
68            }
69        );
70    }
71
72    #[test]
73    fn parse_empty_sides_default_to_head() {
74        assert_eq!(
75            RefSpec::parse("..feature").unwrap(),
76            RefSpec::Range {
77                start: "HEAD".into(),
78                end: "feature".into()
79            }
80        );
81        assert_eq!(
82            RefSpec::parse("main..").unwrap(),
83            RefSpec::Range {
84                start: "main".into(),
85                end: "HEAD".into()
86            }
87        );
88        assert_eq!(
89            RefSpec::parse("..").unwrap(),
90            RefSpec::Range {
91                start: "HEAD".into(),
92                end: "HEAD".into()
93            }
94        );
95    }
96
97    #[test]
98    fn parse_triple_dot_errors() {
99        let err = RefSpec::parse("main...feature").unwrap_err();
100        assert!(err.contains("triple-dot"));
101    }
102
103    #[test]
104    fn parse_unicode_refs() {
105        // Emoji ref
106        assert_eq!(
107            RefSpec::parse("\u{1F680}").unwrap(),
108            RefSpec::Single("\u{1F680}".into())
109        );
110        // CJK characters
111        assert_eq!(
112            RefSpec::parse("\u{4E16}\u{754C}").unwrap(),
113            RefSpec::Single("\u{4E16}\u{754C}".into())
114        );
115        // Unicode range
116        assert_eq!(
117            RefSpec::parse("\u{1F680}..\u{4E16}\u{754C}").unwrap(),
118            RefSpec::Range {
119                start: "\u{1F680}".into(),
120                end: "\u{4E16}\u{754C}".into(),
121            }
122        );
123    }
124
125    #[test]
126    fn parse_special_chars() {
127        // Spaces are preserved as-is (git will reject, but parser doesn't)
128        assert_eq!(
129            RefSpec::parse("my branch").unwrap(),
130            RefSpec::Single("my branch".into())
131        );
132        // Backslashes
133        assert_eq!(
134            RefSpec::parse("refs\\heads\\main").unwrap(),
135            RefSpec::Single("refs\\heads\\main".into())
136        );
137    }
138
139    #[test]
140    fn parse_whitespace_only() {
141        assert_eq!(
142            RefSpec::parse("   ").unwrap(),
143            RefSpec::Single("   ".into())
144        );
145        assert_eq!(RefSpec::parse("\t").unwrap(), RefSpec::Single("\t".into()));
146    }
147
148    #[test]
149    fn parse_very_long_string() {
150        let long = "a".repeat(250);
151        assert_eq!(
152            RefSpec::parse(&long).unwrap(),
153            RefSpec::Single(long.clone())
154        );
155        // Long range
156        let long_range = format!("{}..{}", "b".repeat(200), "c".repeat(200));
157        assert_eq!(
158            RefSpec::parse(&long_range).unwrap(),
159            RefSpec::Range {
160                start: "b".repeat(200),
161                end: "c".repeat(200),
162            }
163        );
164    }
165
166    #[test]
167    fn parse_empty_string() {
168        assert_eq!(RefSpec::parse("").unwrap(), RefSpec::Single("".into()));
169    }
170}