ralph_workflow/reducer/domain/
branch.rs1#[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 #[test]
151 fn parse_head_push_refspec_never_panics(s in ".*") {
152 let _ = parse_head_push_refspec(&s);
153 }
154
155 #[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 #[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 #[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}