Skip to main content

heddle_object_model/object/
identifiers.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Newtype wrappers for string identifiers that were previously bare
3//! `String` / `&str`. The compiler enforces that a `ThreadName` cannot
4//! be passed where a `MarkerName` is expected, catching mix-ups at
5//! build time with zero runtime cost.
6//!
7//! Each type is `#[serde(transparent)]` so the on-disk / wire format
8//! is byte-identical to a bare `String`. Existing oplog entries,
9//! packed refs, and rmp-serde payloads decode unchanged.
10
11use 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    /// Name of a heddle thread (branch-like construct).
101    ThreadName
102);
103
104string_newtype!(
105    /// Name of a heddle marker (tag-like construct).
106    MarkerName
107);
108
109/// First path segment reserved for Heddle-internal refs.
110///
111/// A user may still name a thread `heddle`, `heddlefoo`, or `my/heddle`.
112/// Only a `heddle/`-rooted name is reserved (Invariant C).
113pub const RESERVED_REF_SEGMENT: &str = "heddle";
114
115/// True when `name` occupies the reserved `heddle/` namespace.
116///
117/// The check is case-insensitive on the first segment so a raw-Git branch
118/// `Heddle/frontier/...` cannot slip past the reservation.
119pub 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/// Rejection when a user thread or marker name occupies `heddle/`.
128#[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    /// Fallible constructor for user or imported names. Rejects the reserved
138    /// `heddle/` namespace. Trusted reconstruction of already-stored names
139    /// still uses [`ThreadName::new`].
140    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    /// Fallible constructor for user or imported names. Rejects the reserved
151    /// `heddle/` namespace. Trusted reconstruction of already-stored names
152    /// still uses [`MarkerName::new`].
153    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/// Invalid external Git ref syntax or the signed full-ref resource limit.
163#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
164#[error("invalid Git ref name: {0}")]
165pub struct GitRefNameError(pub String);
166
167impl ThreadName {
168    /// Validate a short Git branch and map only native namespace collisions.
169    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    /// Validate an external tag in its full namespace before native mapping.
182    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    /// Checkout/lane scope identifier for scoped operations.
193    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        // These are different types — the compiler prevents mixing them.
272    }
273
274    #[test]
275    fn comparison_with_str() {
276        let t = ThreadName::from("main");
277        assert!(t == "main");
278        assert!(t == *"main");
279        // Bind the owned value so the `PartialEq<String>` impl is still
280        // exercised without triggering `clippy::cmp_owned`.
281        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}