heddle_object_model/object/
frontier_ref.rs1use super::{
14 ChangeId, ChangeIdParseError, RESERVED_REF_SEGMENT, ThreadName, is_reserved_heddle_namespace,
15};
16
17pub const SYNTHETIC_FRONTIER_PREFIX: &str = "heddle/frontier/";
19
20pub const GIT_SYNTHETIC_FRONTIER_PREFIX: &str = "refs/heddle/frontier/";
22
23#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
28pub struct SyntheticFrontierName {
29 thread: String,
30 change_id: ChangeId,
31}
32
33#[derive(Debug, Clone, thiserror::Error)]
35pub enum SyntheticFrontierNameError {
36 #[error("synthetic frontier thread name is empty")]
37 EmptyThread,
38 #[error("synthetic frontier thread '{name}' occupies the reserved heddle/ namespace")]
39 ReservedThread { name: String },
40 #[error("synthetic frontier name '{name}' is not heddle/frontier/<thread>/<full-changeid>")]
41 InvalidForm { name: String },
42 #[error("synthetic frontier change id is not a full ChangeId: {0}")]
43 ChangeId(#[from] ChangeIdParseError),
44}
45
46impl SyntheticFrontierName {
47 pub fn new(
52 thread: impl AsRef<str>,
53 change_id: ChangeId,
54 ) -> Result<Self, SyntheticFrontierNameError> {
55 let thread = thread.as_ref();
56 if thread.is_empty() {
57 return Err(SyntheticFrontierNameError::EmptyThread);
58 }
59 if is_reserved_heddle_namespace(thread) {
60 return Err(SyntheticFrontierNameError::ReservedThread {
61 name: thread.to_string(),
62 });
63 }
64 Ok(Self {
65 thread: thread.to_string(),
66 change_id,
67 })
68 }
69
70 pub fn parse(name: &str) -> Result<Self, SyntheticFrontierNameError> {
76 let Some(rest) = strip_frontier_prefix(name) else {
77 return Err(SyntheticFrontierNameError::InvalidForm {
78 name: name.to_string(),
79 });
80 };
81 let Some((thread, change_id)) = rest.rsplit_once('/') else {
82 return Err(SyntheticFrontierNameError::InvalidForm {
83 name: name.to_string(),
84 });
85 };
86 let parsed = ChangeId::parse(change_id)?;
87 if change_id != parsed.to_string_full() {
88 return Err(SyntheticFrontierNameError::InvalidForm {
89 name: name.to_string(),
90 });
91 }
92 Self::new(thread, parsed)
93 }
94
95 pub fn looks_like(name: &str) -> bool {
97 Self::parse(name).is_ok()
98 }
99
100 pub fn thread(&self) -> &str {
101 &self.thread
102 }
103
104 pub fn change_id(&self) -> ChangeId {
105 self.change_id
106 }
107
108 pub fn as_name(&self) -> String {
110 format!(
111 "{SYNTHETIC_FRONTIER_PREFIX}{}/{}",
112 self.thread,
113 self.change_id.to_string_full()
114 )
115 }
116
117 pub fn git_ref(&self) -> String {
119 format!(
120 "{GIT_SYNTHETIC_FRONTIER_PREFIX}{}/{}",
121 self.thread,
122 self.change_id.to_string_full()
123 )
124 }
125
126 pub fn owning_thread(&self) -> ThreadName {
131 ThreadName::new(&self.thread)
132 }
133}
134
135impl std::fmt::Display for SyntheticFrontierName {
136 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
137 f.write_str(&self.as_name())
138 }
139}
140
141fn strip_frontier_prefix(name: &str) -> Option<&str> {
142 if let Some(rest) = name.strip_prefix(SYNTHETIC_FRONTIER_PREFIX) {
143 return nonempty(rest);
144 }
145 if let Some(rest) = name.strip_prefix(GIT_SYNTHETIC_FRONTIER_PREFIX) {
146 return nonempty(rest);
147 }
148 let mut parts = name.splitn(3, '/');
149 let first = parts.next()?;
150 let second = parts.next()?;
151 let rest = parts.next()?;
152 if first.eq_ignore_ascii_case(RESERVED_REF_SEGMENT) && second.eq_ignore_ascii_case("frontier") {
153 nonempty(rest)
154 } else {
155 None
156 }
157}
158
159fn nonempty(rest: &str) -> Option<&str> {
160 if rest.is_empty() { None } else { Some(rest) }
161}
162
163#[cfg(test)]
164mod tests {
165 use super::*;
166
167 fn cid(last: u8) -> ChangeId {
168 let mut bytes = [0u8; 16];
169 bytes[15] = last;
170 ChangeId::from_bytes(bytes)
171 }
172
173 #[test]
174 fn full_change_id_keeps_prefix_sharing_siblings_distinct() {
175 let a = cid(0);
176 let mut shared = [0u8; 16];
177 shared[0] = 0xaa;
178 shared[1] = 0xbb;
179 shared[15] = 1;
180 let b = ChangeId::from_bytes(shared);
181 let mut c_bytes = shared;
182 c_bytes[15] = 2;
183 let c = ChangeId::from_bytes(c_bytes);
184
185 let left = SyntheticFrontierName::new("main", b).unwrap();
186 let right = SyntheticFrontierName::new("main", c).unwrap();
187 assert_ne!(left.as_name(), right.as_name());
188 assert_ne!(left.git_ref(), right.git_ref());
189 assert!(left.as_name().contains(&b.to_string_full()));
190 assert!(right.as_name().contains(&c.to_string_full()));
191 assert!(!left.as_name().ends_with(&b.short()));
192 let _ = a;
193 }
194
195 #[test]
196 fn parse_round_trips_slashed_thread_and_full_change_id() {
197 let change = cid(9);
198 let name = SyntheticFrontierName::new("feature/auth", change).unwrap();
199 let parsed = SyntheticFrontierName::parse(&name.as_name()).unwrap();
200 assert_eq!(parsed.thread(), "feature/auth");
201 assert_eq!(parsed.change_id(), change);
202 assert_eq!(
203 parsed.git_ref(),
204 format!(
205 "refs/heddle/frontier/feature/auth/{}",
206 change.to_string_full()
207 )
208 );
209 }
210
211 #[test]
212 fn rejects_short_change_id_suffix() {
213 let change = cid(3);
214 let short = format!("heddle/frontier/main/{}", change.short());
215 assert!(SyntheticFrontierName::parse(&short).is_err());
216 }
217
218 #[test]
219 fn user_thread_at_change_id_is_not_a_synthetic_root() {
220 let change = cid(4);
221 let user = format!("main@{}", change.to_string_full());
222 assert!(!is_reserved_heddle_namespace(&user));
223 assert!(SyntheticFrontierName::parse(&user).is_err());
224 assert_ne!(
225 user,
226 SyntheticFrontierName::new("main", change)
227 .unwrap()
228 .as_name()
229 );
230 }
231
232 #[test]
233 fn refuses_a_reserved_thread_component() {
234 let change = cid(5);
235 assert!(SyntheticFrontierName::new("heddle/nested", change).is_err());
236 }
237}