heddle_object_model/object/
identifiers.rs1use std::{fmt, hash::Hash};
12
13use serde::{Deserialize, Serialize};
14
15macro_rules! string_newtype {
16 ($(#[$meta:meta])* $name:ident) => {
17 $(#[$meta])*
18 #[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
19 #[serde(transparent)]
20 pub struct $name(pub String);
21
22 impl $name {
23 pub fn new(s: impl Into<String>) -> Self {
24 Self(s.into())
25 }
26
27 pub fn as_str(&self) -> &str {
28 &self.0
29 }
30
31 pub fn into_string(self) -> String {
32 self.0
33 }
34 }
35
36 impl fmt::Display for $name {
37 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
38 f.write_str(&self.0)
39 }
40 }
41
42 impl AsRef<str> for $name {
43 fn as_ref(&self) -> &str {
44 &self.0
45 }
46 }
47
48 impl std::ops::Deref for $name {
49 type Target = str;
50 fn deref(&self) -> &str {
51 &self.0
52 }
53 }
54
55 impl From<String> for $name {
56 fn from(s: String) -> Self {
57 Self(s)
58 }
59 }
60
61 impl From<&str> for $name {
62 fn from(s: &str) -> Self {
63 Self(s.to_string())
64 }
65 }
66
67 impl From<$name> for String {
68 fn from(n: $name) -> String {
69 n.0
70 }
71 }
72
73 impl PartialEq<str> for $name {
74 fn eq(&self, other: &str) -> bool {
75 self.0 == other
76 }
77 }
78
79 impl PartialEq<&str> for $name {
80 fn eq(&self, other: &&str) -> bool {
81 self.0 == *other
82 }
83 }
84
85 impl PartialEq<String> for $name {
86 fn eq(&self, other: &String) -> bool {
87 self.0 == *other
88 }
89 }
90
91 impl std::borrow::Borrow<str> for $name {
92 fn borrow(&self) -> &str {
93 &self.0
94 }
95 }
96 };
97}
98
99string_newtype!(
100 ThreadName
102);
103
104string_newtype!(
105 MarkerName
107);
108
109pub const RESERVED_REF_SEGMENT: &str = "heddle";
114
115pub fn is_reserved_heddle_namespace(name: &str) -> bool {
120 let mut parts = name.split('/');
121 match (parts.next(), parts.next()) {
122 (Some(first), Some(_)) => first.eq_ignore_ascii_case(RESERVED_REF_SEGMENT),
123 _ => false,
124 }
125}
126
127#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
129#[error(
130 "ref name '{name}' is reserved: the heddle/ namespace is internal and cannot be a user thread or marker"
131)]
132pub struct ReservedRefNameError {
133 pub name: String,
134}
135
136impl ThreadName {
137 pub fn try_new(s: impl Into<String>) -> Result<Self, ReservedRefNameError> {
141 let name = s.into();
142 if is_reserved_heddle_namespace(&name) {
143 return Err(ReservedRefNameError { name });
144 }
145 Ok(Self(name))
146 }
147}
148
149impl MarkerName {
150 pub fn try_new(s: impl Into<String>) -> Result<Self, ReservedRefNameError> {
154 let name = s.into();
155 if is_reserved_heddle_namespace(&name) {
156 return Err(ReservedRefNameError { name });
157 }
158 Ok(Self(name))
159 }
160}
161
162#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
164#[error("invalid Git ref name: {0}")]
165pub struct GitRefNameError(pub String);
166
167impl ThreadName {
168 pub fn from_git_branch(name: &str) -> Result<Self, GitRefNameError> {
170 if name == "HEAD"
171 || name.len() + "refs/heads/".len() > 1024
172 || sley_refs::BranchRefNameBuf::from_branch_name(name).is_err()
173 {
174 return Err(GitRefNameError(name.to_owned()));
175 }
176 Ok(Self(crate::name_encoding::native_git_name(name)))
177 }
178}
179
180impl MarkerName {
181 pub fn from_git_tag(name: &str) -> Result<Self, GitRefNameError> {
183 let full = format!("refs/tags/{name}");
184 if full.len() > 1024 || sley_refs::check_refname_format(&full, false).is_err() {
185 return Err(GitRefNameError(full));
186 }
187 Ok(Self(crate::name_encoding::native_git_name(name)))
188 }
189}
190
191string_newtype!(
192 Scope
194);
195
196#[cfg(test)]
197mod tests {
198 use super::*;
199
200 #[test]
201 fn git_branch_admission_matches_git_and_rejects_invalid_names() {
202 for name in [
203 "feat/mcp=timeout",
204 "a,b",
205 "ünicode/ブランチ",
206 "@",
207 "x+y",
208 "trailing\u{a0}",
209 "literal\u{fffd}",
210 "heddle/foo",
211 &"界".repeat(337),
212 ] {
213 let output = std::process::Command::new("git")
214 .args(["check-ref-format", "--branch", name])
215 .output()
216 .expect("Git oracle");
217 assert!(output.status.success(), "Git rejects {name:?}");
218 let native = ThreadName::from_git_branch(name).expect("Sley branch");
219 assert_eq!(crate::name_encoding::git_name(&native), name);
220 }
221 for name in [
222 "a..b",
223 "a@{b",
224 "a.lock",
225 "a.lock/b",
226 "a\n",
227 "-flag",
228 "HEAD",
229 ".",
230 "team:scope",
231 "a~b",
232 "a^b",
233 "a?b",
234 "a*b",
235 "a[b",
236 "a\\b",
237 "a//b",
238 "a/",
239 "a.",
240 "a\x7f",
241 ] {
242 assert!(
243 ThreadName::from_git_branch(name).is_err(),
244 "accepted {name:?}"
245 );
246 }
247 assert!(ThreadName::from_git_branch(&"界".repeat(338)).is_err());
248 }
249
250 #[test]
251 fn thread_name_display() {
252 let t = ThreadName::new("main");
253 assert_eq!(t.0, "main");
254 assert_eq!(t.0, "main");
255 assert_eq!(&*t, "main");
256 }
257
258 #[test]
259 fn serde_transparent_roundtrip() {
260 let t = ThreadName::new("feature/foo");
261 let json = serde_json::to_string(&t).unwrap();
262 assert_eq!(json, "\"feature/foo\"");
263 let back: ThreadName = serde_json::from_str(&json).unwrap();
264 assert_eq!(back, t);
265 }
266
267 #[test]
268 fn marker_name_distinct_from_thread_name() {
269 let _t: ThreadName = "main".into();
270 let _m: MarkerName = "v1.0".into();
271 }
273
274 #[test]
275 fn comparison_with_str() {
276 let t = ThreadName::from("main");
277 assert!(t == "main");
278 assert!(t == *"main");
279 let owned = String::from("main");
282 assert!(t == owned);
283 }
284
285 #[test]
286 fn borrow_for_hashmap_lookup() {
287 use std::collections::HashMap;
288 let mut map = HashMap::new();
289 map.insert(ThreadName::new("main"), 1);
290 assert_eq!(map.get("main"), Some(&1));
291 }
292
293 #[test]
294 fn reserved_namespace_is_heddle_rooted_only() {
295 assert!(!is_reserved_heddle_namespace("heddle"));
296 assert!(!is_reserved_heddle_namespace("heddlefoo"));
297 assert!(!is_reserved_heddle_namespace("my/heddle"));
298 assert!(!is_reserved_heddle_namespace("main@review"));
299 assert!(is_reserved_heddle_namespace("heddle/frontier/main/hc-abc"));
300 assert!(is_reserved_heddle_namespace("Heddle/x"));
301 }
302
303 #[test]
304 fn try_new_rejects_reserved_thread_and_marker_names() {
305 assert!(ThreadName::try_new("heddle/frontier/main/hc-1").is_err());
306 assert!(MarkerName::try_new("heddle/notes").is_err());
307 assert_eq!(ThreadName::try_new("heddle").unwrap().as_str(), "heddle");
308 assert_eq!(
309 ThreadName::try_new("main@hd-abc").unwrap().as_str(),
310 "main@hd-abc"
311 );
312 }
313}