Skip to main content

qail_core/
branch.rs

1//! Branch Context for Data Virtualization
2//!
3//! Provides branch identity for row-level branching ("GitHub for Databases").
4//! Each request can target a specific branch via the `X-Branch-ID` header.
5//!
6//! When on main (no branch), queries hit tables directly.
7//! When on a branch, reads merge main + overlay, writes go to overlay.
8//!
9//! # Example
10//!
11//! ```
12//! use qail_core::branch::BranchContext;
13//!
14//! // Main branch — no overlay
15//! let ctx = BranchContext::main();
16//! assert!(ctx.is_main());
17//!
18//! // Feature branch — reads merge, writes go to overlay
19//! let ctx = BranchContext::branch("feature-auth");
20//! assert_eq!(ctx.branch_name(), Some("feature-auth"));
21//! ```
22
23/// Branch context for data virtualization.
24///
25/// Determines which branch a request targets. When a branch is active,
26/// the gateway applies Copy-on-Write semantics:
27/// - **Reads**: main rows UNION branch overlay (overlay wins on PK conflict)
28/// - **Writes**: inserted/updated rows go to `_qail_branch_rows` overlay
29/// - **Deletes**: a tombstone marker is added to the overlay
30#[derive(Debug, Clone, PartialEq, Eq)]
31pub struct BranchContext {
32    /// Branch name (None = main/default branch)
33    branch_id: Option<String>,
34}
35
36impl BranchContext {
37    /// Maximum branch name length.
38    pub const MAX_NAME_LEN: usize = 64;
39
40    /// Create a context targeting the main branch (no overlay).
41    pub fn main() -> Self {
42        Self { branch_id: None }
43    }
44
45    /// Create a context targeting a named branch.
46    ///
47    /// # Panics
48    /// Panics if the branch name is invalid. Use `try_branch` for fallible creation.
49    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    /// Try to create a branch context, returning None if the name is invalid.
62    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    /// Parse an optional branch header value into a [`BranchContext`].
73    ///
74    /// Rules:
75    /// - `None`, empty string, and `main` (case-insensitive) map to main.
76    /// - Any other value must pass [`Self::is_valid_name`].
77    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    /// Create from an optional branch name (None = main).
93    pub fn from_header(value: Option<&str>) -> Result<Self, String> {
94        Self::parse_header(value)
95    }
96
97    /// Validate a branch name.
98    ///
99    /// Rules:
100    /// - 1 to 64 characters
101    /// - Only alphanumeric, hyphens (`-`), underscores (`_`), and dots (`.`)
102    /// - Must not start with `.` or `-`
103    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    /// Returns true if this is the main branch.
114    pub fn is_main(&self) -> bool {
115        self.branch_id.is_none()
116    }
117
118    /// Returns true if this is a named branch (not main).
119    pub fn has_branch(&self) -> bool {
120        self.branch_id.is_some()
121    }
122
123    /// Get the branch name, if any.
124    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    // ================================================================
199    // Branch name validation tests
200    // ================================================================
201
202    #[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")); // single char
209    }
210
211    #[test]
212    fn test_invalid_branch_names() {
213        assert!(!BranchContext::is_valid_name("")); // empty
214        assert!(!BranchContext::is_valid_name(".hidden")); // starts with dot
215        assert!(!BranchContext::is_valid_name("-flag")); // starts with hyphen
216        assert!(!BranchContext::is_valid_name("has space")); // space
217        assert!(!BranchContext::is_valid_name("has;semicolon")); // SQL injection char
218        assert!(!BranchContext::is_valid_name("it's bad")); // single quote
219        assert!(!BranchContext::is_valid_name("a/b")); // slash (path traversal)
220        assert!(!BranchContext::is_valid_name(&"x".repeat(65))); // too long
221    }
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}