Skip to main content

heddle_object_model/object/
frontier_ref.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Synthetic frontier-root names.
3//!
4//! Sibling-line roots advertised for a merge-frontier antichain live in the
5//! reserved `heddle/` namespace as `heddle/frontier/<thread>/<full-changeid>`.
6//! The Git projection of the same root is
7//! `refs/heddle/frontier/<thread>/<full-changeid>`.
8//!
9//! The ChangeId suffix is always [`ChangeId::to_string_full`] — never the
10//! truncatable [`ChangeId::short`] / `Display` form — so two siblings that
11//! share a prefix remain distinct refs.
12
13use super::{
14    ChangeId, ChangeIdParseError, RESERVED_REF_SEGMENT, ThreadName, is_reserved_heddle_namespace,
15};
16
17/// Wire and local-store prefix for synthetic frontier roots.
18pub const SYNTHETIC_FRONTIER_PREFIX: &str = "heddle/frontier/";
19
20/// Git-side prefix for the same roots. Disjoint from `refs/heads/`.
21pub const GIT_SYNTHETIC_FRONTIER_PREFIX: &str = "refs/heddle/frontier/";
22
23/// Type-distinct name of a synthetic frontier root.
24///
25/// This is not a [`ThreadName`] and not a [`MarkerName`]. Consume, store, and
26/// mirror sites must persist it through the synthetic-ref path.
27#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
28pub struct SyntheticFrontierName {
29    thread: String,
30    change_id: ChangeId,
31}
32
33/// Why a synthetic frontier name could not be built or parsed.
34#[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    /// Build a synthetic root for `thread` at `change_id`.
48    ///
49    /// `thread` is the user thread whose sibling line this root names. It must
50    /// itself be a user name (not `heddle/`-rooted).
51    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    /// Parse a wire/store name of the form `heddle/frontier/<thread>/<full-changeid>`.
71    ///
72    /// The last `/`-segment is the ChangeId (`hc-` + full Crockford base32).
73    /// Everything between `heddle/frontier/` and that segment is the thread
74    /// name, which may itself contain `/`.
75    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    /// True when `name` is a well-formed synthetic frontier root.
96    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    /// Wire / local-store name: `heddle/frontier/<thread>/<full-changeid>`.
109    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    /// Git-side name: `refs/heddle/frontier/<thread>/<full-changeid>`.
118    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    /// The user thread this synthetic root belongs to, as a [`ThreadName`].
127    ///
128    /// Only the *owning* user thread is a ThreadName. The synthetic root
129    /// itself must never be coerced into one.
130    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}