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
162string_newtype!(
163    /// Checkout/lane scope identifier for scoped operations.
164    Scope
165);
166
167#[cfg(test)]
168mod tests {
169    use super::*;
170
171    #[test]
172    fn thread_name_display() {
173        let t = ThreadName::new("main");
174        assert_eq!(t.0, "main");
175        assert_eq!(t.0, "main");
176        assert_eq!(&*t, "main");
177    }
178
179    #[test]
180    fn serde_transparent_roundtrip() {
181        let t = ThreadName::new("feature/foo");
182        let json = serde_json::to_string(&t).unwrap();
183        assert_eq!(json, "\"feature/foo\"");
184        let back: ThreadName = serde_json::from_str(&json).unwrap();
185        assert_eq!(back, t);
186    }
187
188    #[test]
189    fn marker_name_distinct_from_thread_name() {
190        let _t: ThreadName = "main".into();
191        let _m: MarkerName = "v1.0".into();
192        // These are different types — the compiler prevents mixing them.
193    }
194
195    #[test]
196    #[allow(clippy::cmp_owned)] // exercising PartialEq<String> impl by design
197    fn comparison_with_str() {
198        let t = ThreadName::from("main");
199        assert!(t == "main");
200        assert!(t == *"main");
201        assert!(t == String::from("main"));
202    }
203
204    #[test]
205    fn borrow_for_hashmap_lookup() {
206        use std::collections::HashMap;
207        let mut map = HashMap::new();
208        map.insert(ThreadName::new("main"), 1);
209        assert_eq!(map.get("main"), Some(&1));
210    }
211
212    #[test]
213    fn reserved_namespace_is_heddle_rooted_only() {
214        assert!(!is_reserved_heddle_namespace("heddle"));
215        assert!(!is_reserved_heddle_namespace("heddlefoo"));
216        assert!(!is_reserved_heddle_namespace("my/heddle"));
217        assert!(!is_reserved_heddle_namespace("main@review"));
218        assert!(is_reserved_heddle_namespace("heddle/frontier/main/hc-abc"));
219        assert!(is_reserved_heddle_namespace("Heddle/x"));
220    }
221
222    #[test]
223    fn try_new_rejects_reserved_thread_and_marker_names() {
224        assert!(ThreadName::try_new("heddle/frontier/main/hc-1").is_err());
225        assert!(MarkerName::try_new("heddle/notes").is_err());
226        assert_eq!(ThreadName::try_new("heddle").unwrap().as_str(), "heddle");
227        assert_eq!(
228            ThreadName::try_new("main@hd-abc").unwrap().as_str(),
229            "main@hd-abc"
230        );
231    }
232}