Skip to main content

ralph_workflow/reducer/domain/
branch.rs

1#[derive(Debug, Clone, PartialEq, Eq)]
2pub struct PushRefspec(String);
3
4#[derive(Debug, PartialEq, Eq)]
5pub enum BranchParseError {
6    Empty,
7    StartsWithDash,
8    ContainsColon,
9    ContainsDisallowedCharacters,
10    EmptyRefsHeadsSuffix,
11    UnsupportedRefNamespace,
12}
13
14pub fn parse_head_push_refspec(branch: &str) -> Result<PushRefspec, BranchParseError> {
15    let trimmed = branch.trim();
16    if trimmed.is_empty() {
17        return Err(BranchParseError::Empty);
18    }
19
20    if trimmed.starts_with('-') {
21        return Err(BranchParseError::StartsWithDash);
22    }
23
24    if trimmed.contains(':') {
25        return Err(BranchParseError::ContainsColon);
26    }
27
28    if trimmed.chars().any(|c| c.is_whitespace() || c == '\0') {
29        return Err(BranchParseError::ContainsDisallowedCharacters);
30    }
31
32    let full_ref = if let Some(rest) = trimmed.strip_prefix("refs/heads/") {
33        if rest.is_empty() {
34            return Err(BranchParseError::EmptyRefsHeadsSuffix);
35        }
36        trimmed.to_string()
37    } else if trimmed.starts_with("refs/") {
38        return Err(BranchParseError::UnsupportedRefNamespace);
39    } else {
40        format!("refs/heads/{trimmed}")
41    };
42
43    Ok(PushRefspec(format!("HEAD:{full_ref}")))
44}
45
46impl PushRefspec {
47    pub fn into_string(self) -> String {
48        self.0
49    }
50
51    pub fn as_str(&self) -> &str {
52        &self.0
53    }
54}
55
56#[cfg(test)]
57mod tests {
58    use super::{parse_head_push_refspec, BranchParseError};
59
60    #[test]
61    fn rejects_empty_branch_name() {
62        assert_eq!(
63            parse_head_push_refspec("").unwrap_err(),
64            BranchParseError::Empty
65        );
66    }
67
68    #[test]
69    fn rejects_dash_prefixed_branch() {
70        assert_eq!(
71            parse_head_push_refspec("-feature").unwrap_err(),
72            BranchParseError::StartsWithDash
73        );
74    }
75
76    #[test]
77    fn rejects_colon_in_branch_name() {
78        assert_eq!(
79            parse_head_push_refspec("feature:alpha").unwrap_err(),
80            BranchParseError::ContainsColon
81        );
82    }
83
84    #[test]
85    fn rejects_whitespace_in_branch_name() {
86        assert_eq!(
87            parse_head_push_refspec("has space").unwrap_err(),
88            BranchParseError::ContainsDisallowedCharacters
89        );
90    }
91
92    #[test]
93    fn rejects_null_character_in_branch_name() {
94        assert_eq!(
95            parse_head_push_refspec("\0main").unwrap_err(),
96            BranchParseError::ContainsDisallowedCharacters
97        );
98    }
99
100    #[test]
101    fn rejects_empty_refs_heads_suffix() {
102        assert_eq!(
103            parse_head_push_refspec("refs/heads/").unwrap_err(),
104            BranchParseError::EmptyRefsHeadsSuffix
105        );
106    }
107
108    #[test]
109    fn rejects_other_refs_namespace() {
110        assert_eq!(
111            parse_head_push_refspec("refs/tags/v1").unwrap_err(),
112            BranchParseError::UnsupportedRefNamespace
113        );
114    }
115
116    #[test]
117    fn accepts_simple_branch_name() {
118        assert_eq!(
119            parse_head_push_refspec("main").unwrap().as_str(),
120            "HEAD:refs/heads/main"
121        );
122    }
123
124    #[test]
125    fn accepts_refs_heads_input() {
126        assert_eq!(
127            parse_head_push_refspec("refs/heads/feature")
128                .unwrap()
129                .as_str(),
130            "HEAD:refs/heads/feature"
131        );
132    }
133
134    #[test]
135    fn trims_branch_name_before_processing() {
136        assert_eq!(
137            parse_head_push_refspec("  feature  ").unwrap().as_str(),
138            "HEAD:refs/heads/feature"
139        );
140    }
141}
142
143#[cfg(test)]
144mod proptest_parsers {
145    use super::parse_head_push_refspec;
146    use proptest::prelude::*;
147
148    proptest! {
149        /// `parse_head_push_refspec` must never panic on any string input.
150        #[test]
151        fn parse_head_push_refspec_never_panics(s in ".*") {
152            let _ = parse_head_push_refspec(&s);
153        }
154
155        /// A simple alphanumeric branch name (no dashes, colons, spaces) always succeeds
156        /// and produces a refspec starting with `HEAD:refs/heads/`.
157        #[test]
158        fn parse_head_push_refspec_valid_name_produces_correct_prefix(
159            name in "[a-zA-Z][a-zA-Z0-9_]{0,30}",
160        ) {
161            let result = parse_head_push_refspec(&name);
162            prop_assert!(result.is_ok());
163            let refspec = result.unwrap();
164            prop_assert!(refspec.as_str().starts_with("HEAD:refs/heads/"));
165            prop_assert!(refspec.as_str().ends_with(&name));
166        }
167
168        /// A string with internal whitespace always returns an error.
169        #[test]
170        fn parse_head_push_refspec_rejects_whitespace(
171            prefix in "[a-zA-Z]+",
172            suffix in "[a-zA-Z]+",
173        ) {
174            let s = format!("{prefix} {suffix}");
175            prop_assert!(parse_head_push_refspec(&s).is_err());
176        }
177
178        /// A string containing `:` always returns an error.
179        #[test]
180        fn parse_head_push_refspec_rejects_colon(
181            prefix in "[a-zA-Z]+",
182            suffix in "[a-zA-Z]+",
183        ) {
184            let s = format!("{prefix}:{suffix}");
185            prop_assert!(parse_head_push_refspec(&s).is_err());
186        }
187    }
188}