Skip to main content

ssh_cli/domain/
ids.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2//! UUID newtypes — v4 correlation vs v7 ordered batch runs (G-DOM-05).
3//!
4//! - [`CorrelationId`]: pure random (`Uuid::new_v4`) for session/token-like ids.
5//! - [`BatchRunId`]: time-ordered (`Uuid::now_v7`) for multi-host fan-out envelopes.
6//!
7//! Wire: canonical hyphenated lowercase string. Rejects nil/max sentinels on parse.
8#![forbid(unsafe_code)]
9
10use super::error::DomainError;
11use serde::{Deserialize, Deserializer, Serialize, Serializer};
12use std::fmt;
13use uuid::{Uuid, Variant, Version};
14
15/// Random correlation id (UUID v4).
16#[repr(transparent)]
17#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
18pub struct CorrelationId(Uuid);
19
20/// Time-ordered batch run id (UUID v7) for multi-host JSON envelopes.
21#[repr(transparent)]
22#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
23pub struct BatchRunId(Uuid);
24
25fn reject_sentinel(u: Uuid) -> Result<Uuid, DomainError> {
26    if u.is_nil() {
27        return Err(DomainError::new("uuid", "nil UUID is a forbidden sentinel"));
28    }
29    if u.as_u128() == u128::MAX {
30        return Err(DomainError::new("uuid", "max UUID is a forbidden sentinel"));
31    }
32    match u.get_variant() {
33        Variant::RFC4122 => {}
34        _ => {
35            return Err(DomainError::new(
36                "uuid",
37                "UUID variant must be RFC 4122/9562",
38            ));
39        }
40    }
41    Ok(u)
42}
43
44impl CorrelationId {
45    /// Generates a new random v4 id.
46    #[must_use]
47    pub fn new() -> Self {
48        Self(Uuid::new_v4())
49    }
50
51    /// Parses a hyphenated UUID string (must be v4 when version is present).
52    pub fn try_new(raw: impl AsRef<str>) -> Result<Self, DomainError> {
53        let u = Uuid::parse_str(raw.as_ref().trim())
54            .map_err(|e| DomainError::new("correlation_id", e.to_string()))?;
55        let u = reject_sentinel(u)?;
56        if let Some(v) = u.get_version() {
57            if v != Version::Random {
58                return Err(DomainError::new(
59                    "correlation_id",
60                    format!("expected UUID v4, got {v:?}"),
61                ));
62            }
63        }
64        Ok(Self(u))
65    }
66
67    /// Borrows inner UUID.
68    #[must_use]
69    pub const fn as_uuid(&self) -> &Uuid {
70        &self.0
71    }
72
73    /// Canonical hyphenated string.
74    #[must_use]
75    pub fn to_string_canonical(&self) -> String {
76        self.0.to_string()
77    }
78}
79
80impl Default for CorrelationId {
81    fn default() -> Self {
82        Self::new()
83    }
84}
85
86impl fmt::Display for CorrelationId {
87    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
88        write!(f, "{}", self.0)
89    }
90}
91
92impl Serialize for CorrelationId {
93    fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
94        s.serialize_str(&self.0.to_string())
95    }
96}
97
98impl<'de> Deserialize<'de> for CorrelationId {
99    fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
100        let s = String::deserialize(d)?;
101        Self::try_new(s).map_err(serde::de::Error::custom)
102    }
103}
104
105impl BatchRunId {
106    /// Generates a new time-ordered v7 id (call **once** per multi-host command).
107    #[must_use]
108    pub fn new() -> Self {
109        Self(Uuid::now_v7())
110    }
111
112    /// Parses a hyphenated UUID string (must be v7).
113    pub fn try_new(raw: impl AsRef<str>) -> Result<Self, DomainError> {
114        let u = Uuid::parse_str(raw.as_ref().trim())
115            .map_err(|e| DomainError::new("batch_run_id", e.to_string()))?;
116        let u = reject_sentinel(u)?;
117        if let Some(v) = u.get_version() {
118            if v != Version::SortRand {
119                return Err(DomainError::new(
120                    "batch_run_id",
121                    format!("expected UUID v7, got {v:?}"),
122                ));
123            }
124        }
125        Ok(Self(u))
126    }
127
128    /// Borrows inner UUID.
129    #[must_use]
130    pub const fn as_uuid(&self) -> &Uuid {
131        &self.0
132    }
133
134    /// Canonical hyphenated string.
135    #[must_use]
136    pub fn to_string_canonical(&self) -> String {
137        self.0.to_string()
138    }
139}
140
141impl Default for BatchRunId {
142    fn default() -> Self {
143        Self::new()
144    }
145}
146
147impl fmt::Display for BatchRunId {
148    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
149        write!(f, "{}", self.0)
150    }
151}
152
153impl Serialize for BatchRunId {
154    fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
155        s.serialize_str(&self.0.to_string())
156    }
157}
158
159impl<'de> Deserialize<'de> for BatchRunId {
160    fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
161        let s = String::deserialize(d)?;
162        Self::try_new(s).map_err(serde::de::Error::custom)
163    }
164}
165
166#[cfg(test)]
167mod tests {
168    use super::*;
169
170    #[test]
171    fn correlation_v4_roundtrip() {
172        let id = CorrelationId::new();
173        let s = id.to_string_canonical();
174        let back = CorrelationId::try_new(&s).unwrap();
175        assert_eq!(id, back);
176        assert_eq!(id.as_uuid().get_version(), Some(Version::Random));
177    }
178
179    #[test]
180    fn batch_v7_roundtrip() {
181        let a = BatchRunId::new();
182        let b = BatchRunId::new();
183        // Same millisecond may collide on ordering equality; just check parse.
184        let s = a.to_string_canonical();
185        let back = BatchRunId::try_new(&s).unwrap();
186        assert_eq!(a, back);
187        assert_eq!(a.as_uuid().get_version(), Some(Version::SortRand));
188        // Distinct generations almost always differ.
189        let _ = b;
190    }
191
192    #[test]
193    fn rejects_nil() {
194        assert!(CorrelationId::try_new("00000000-0000-0000-0000-000000000000").is_err());
195        assert!(BatchRunId::try_new("00000000-0000-0000-0000-000000000000").is_err());
196    }
197
198    #[test]
199    fn distinct_types_not_interchangeable() {
200        let c = CorrelationId::new();
201        // BatchRunId rejects v4.
202        assert!(BatchRunId::try_new(c.to_string_canonical()).is_err());
203    }
204}