1#[derive(Debug, Clone, PartialEq, Eq)]
31pub struct BranchContext {
32 branch_id: Option<String>,
34}
35
36impl BranchContext {
37 pub const MAX_NAME_LEN: usize = 64;
39
40 pub fn main() -> Self {
42 Self { branch_id: None }
43 }
44
45 pub fn branch(name: &str) -> Self {
50 assert!(
51 Self::is_valid_name(name),
52 "Invalid branch name: '{}'. Must be 1-{} chars, alphanumeric/hyphen/underscore/dot only.",
53 name,
54 Self::MAX_NAME_LEN
55 );
56 Self {
57 branch_id: Some(name.to_string()),
58 }
59 }
60
61 pub fn try_branch(name: &str) -> Option<Self> {
63 if Self::is_valid_name(name) {
64 Some(Self {
65 branch_id: Some(name.to_string()),
66 })
67 } else {
68 None
69 }
70 }
71
72 pub fn parse_header(value: Option<&str>) -> Result<Self, String> {
78 match value {
79 None => Ok(Self::main()),
80 Some(name) if name.is_empty() || name.eq_ignore_ascii_case("main") => Ok(Self::main()),
81 Some(name) if Self::is_valid_name(name) => Ok(Self {
82 branch_id: Some(name.to_string()),
83 }),
84 Some(name) => Err(format!(
85 "Invalid branch name '{}'. Use 1-{} ASCII alphanumeric/._- characters",
86 name,
87 Self::MAX_NAME_LEN
88 )),
89 }
90 }
91
92 pub fn from_header(value: Option<&str>) -> Result<Self, String> {
94 Self::parse_header(value)
95 }
96
97 pub fn is_valid_name(name: &str) -> bool {
104 !name.is_empty()
105 && name.len() <= Self::MAX_NAME_LEN
106 && !name.starts_with('.')
107 && !name.starts_with('-')
108 && name
109 .chars()
110 .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.')
111 }
112
113 pub fn is_main(&self) -> bool {
115 self.branch_id.is_none()
116 }
117
118 pub fn has_branch(&self) -> bool {
120 self.branch_id.is_some()
121 }
122
123 pub fn branch_name(&self) -> Option<&str> {
125 self.branch_id.as_deref()
126 }
127}
128
129impl std::fmt::Display for BranchContext {
130 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
131 match &self.branch_id {
132 Some(name) => write!(f, "BranchContext({})", name),
133 None => write!(f, "BranchContext(main)"),
134 }
135 }
136}
137
138#[cfg(test)]
139mod tests {
140 use super::*;
141
142 #[test]
143 fn test_main_branch() {
144 let ctx = BranchContext::main();
145 assert!(ctx.is_main());
146 assert!(!ctx.has_branch());
147 assert_eq!(ctx.branch_name(), None);
148 }
149
150 #[test]
151 fn test_named_branch() {
152 let ctx = BranchContext::branch("feature-auth");
153 assert!(!ctx.is_main());
154 assert!(ctx.has_branch());
155 assert_eq!(ctx.branch_name(), Some("feature-auth"));
156 }
157
158 #[test]
159 fn test_from_header() {
160 assert!(BranchContext::from_header(None).unwrap().is_main());
161 assert!(BranchContext::from_header(Some("")).unwrap().is_main());
162 assert!(BranchContext::from_header(Some("main")).unwrap().is_main());
163 assert!(BranchContext::from_header(Some("MAIN")).unwrap().is_main());
164 assert_eq!(
165 BranchContext::from_header(Some("feat-1"))
166 .unwrap()
167 .branch_name(),
168 Some("feat-1")
169 );
170 }
171
172 #[test]
173 fn test_parse_header_strict_rejects_invalid() {
174 assert!(BranchContext::parse_header(Some("feat-1")).is_ok());
175 assert!(BranchContext::parse_header(Some("main")).is_ok());
176 assert!(BranchContext::parse_header(Some("MAIN")).is_ok());
177 assert!(BranchContext::parse_header(None).is_ok());
178 assert!(BranchContext::parse_header(Some("bad name")).is_err());
179 assert!(BranchContext::parse_header(Some("🚀")).is_err());
180 }
181
182 #[test]
183 fn test_display() {
184 assert_eq!(BranchContext::main().to_string(), "BranchContext(main)");
185 assert_eq!(
186 BranchContext::branch("dev").to_string(),
187 "BranchContext(dev)"
188 );
189 }
190
191 #[test]
192 fn test_equality() {
193 assert_eq!(BranchContext::main(), BranchContext::main());
194 assert_eq!(BranchContext::branch("a"), BranchContext::branch("a"));
195 assert_ne!(BranchContext::main(), BranchContext::branch("a"));
196 }
197
198 #[test]
203 fn test_valid_branch_names() {
204 assert!(BranchContext::is_valid_name("feature-auth"));
205 assert!(BranchContext::is_valid_name("dev"));
206 assert!(BranchContext::is_valid_name("release.1.0"));
207 assert!(BranchContext::is_valid_name("my_branch_2"));
208 assert!(BranchContext::is_valid_name("a")); }
210
211 #[test]
212 fn test_invalid_branch_names() {
213 assert!(!BranchContext::is_valid_name("")); assert!(!BranchContext::is_valid_name(".hidden")); assert!(!BranchContext::is_valid_name("-flag")); assert!(!BranchContext::is_valid_name("has space")); assert!(!BranchContext::is_valid_name("has;semicolon")); assert!(!BranchContext::is_valid_name("it's bad")); assert!(!BranchContext::is_valid_name("a/b")); assert!(!BranchContext::is_valid_name(&"x".repeat(65))); }
222
223 #[test]
224 fn test_try_branch() {
225 assert!(BranchContext::try_branch("valid-name").is_some());
226 assert!(BranchContext::try_branch("has;injection").is_none());
227 assert!(BranchContext::try_branch("").is_none());
228 }
229
230 #[test]
231 fn test_from_header_errors_on_invalid() {
232 let err = BranchContext::from_header(Some("has;semicolon"))
233 .expect_err("invalid header should fail");
234 assert!(err.contains("Invalid branch name"));
235 }
236
237 #[test]
238 #[should_panic(expected = "Invalid branch name")]
239 fn test_branch_panics_on_invalid() {
240 let _ = BranchContext::branch("'; DROP TABLE users; --");
241 }
242}