Skip to main content

agentmux_srv/backend/
oref.rs

1// Copyright 2025, Command Line Inc.
2// Copyright 2025-2026, AgentMux Corp.
3// SPDX-License-Identifier: Apache-2.0
4
5//! ORef: typed object reference in "otype:oid" string format.
6//! Custom serde: serializes as a JSON string, not an object.
7
8
9use serde::{de, Deserialize, Deserializer, Serialize, Serializer};
10use std::fmt;
11use uuid::Uuid;
12
13use super::obj::VALID_OTYPES;
14
15/// Object reference combining a type name and UUID.
16/// Wire format: `"block:550e8400-e29b-41d4-a716-446655440000"`
17///
18/// `#[ts(type = "string")]` is load-bearing, not decoration. This struct has a
19/// hand-written `Serialize`/`Deserialize` pair (below) that emits and parses a
20/// single `"otype:oid"` STRING — the Rust shape and the wire shape deliberately
21/// disagree. Without this attribute ts-rs would faithfully generate
22/// `{ otype: string, oid: string }` from the struct, which is not what any
23/// client ever receives: it would be a confidently wrong binding, worse than
24/// none, and the compiler could not catch it because TypeScript would simply
25/// believe it. ts-rs documents this attribute for exactly this case — "when you
26/// have a custom serializer and deserializer".
27#[derive(Debug, Clone, PartialEq, Eq, Hash, Default)]
28pub struct ORef {
29    pub otype: String,
30    pub oid: String,
31}
32
33impl ORef {
34    #[allow(dead_code)]
35    pub fn new(otype: impl Into<String>, oid: impl Into<String>) -> Self {
36        Self {
37            otype: otype.into(),
38            oid: oid.into(),
39        }
40    }
41
42    pub fn is_empty(&self) -> bool {
43        self.otype.is_empty() || self.oid.is_empty()
44    }
45
46    /// Parse an ORef from the "otype:oid" string format.
47    pub fn parse(s: &str) -> Result<Self, ORefParseError> {
48        if s.is_empty() {
49            return Ok(Self {
50                otype: String::new(),
51                oid: String::new(),
52            });
53        }
54
55        let parts: Vec<&str> = s.splitn(2, ':').collect();
56        if parts.len() != 2 {
57            return Err(ORefParseError::InvalidFormat(s.to_string()));
58        }
59
60        let otype = parts[0];
61        let oid = parts[1];
62
63        // Validate otype: must be lowercase ascii letters only
64        if otype.is_empty() || !otype.chars().all(|c| c.is_ascii_lowercase()) {
65            return Err(ORefParseError::InvalidOType(otype.to_string()));
66        }
67
68        if !VALID_OTYPES.contains(&otype) {
69            return Err(ORefParseError::UnknownOType(otype.to_string()));
70        }
71
72        // Validate OID is a valid UUID
73        Uuid::parse_str(oid).map_err(|_| ORefParseError::InvalidOID(oid.to_string()))?;
74
75        Ok(Self {
76            otype: otype.to_string(),
77            oid: oid.to_string(),
78        })
79    }
80}
81
82impl fmt::Display for ORef {
83    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
84        if self.is_empty() {
85            return write!(f, "");
86        }
87        write!(f, "{}:{}", self.otype, self.oid)
88    }
89}
90
91/// Errors from parsing an ORef string.
92#[derive(Debug, Clone, thiserror::Error)]
93pub enum ORefParseError {
94    #[error("invalid object reference: {0:?}")]
95    InvalidFormat(String),
96    #[error("invalid object type: {0:?}")]
97    InvalidOType(String),
98    #[error("unknown object type: {0:?}")]
99    UnknownOType(String),
100    #[error("invalid object id: {0:?}")]
101    InvalidOID(String),
102}
103
104// ts-rs binding, kept beside the serde impls it has to agree with.
105//
106// Written by hand rather than derived, because a derive cannot express this.
107// ts-rs emits a *reference* to a type's name() at every use site, so for a
108// field holding an ORef to come out as `string`, name() itself has to be
109// "string". Neither `#[ts(as = "String")]` nor `#[ts(type = "string")]` on the
110// container does that: both leave name() as "ORef" and a consumer still
111// generates `{ oref: ORef }`. That is not a guess — the test below was written
112// first and failed against both attributes before this impl existed.
113//
114// This mirrors what ts-rs does internally for foreign string-like types
115// (`impl_primitives! { uuid::Uuid => "string" }`) and for the same reason: the
116// Rust shape and the wire shape differ, and the wire is what a client sees.
117//
118// decl()/inline_flattened() panic exactly as they do for every ts-rs primitive:
119// ORef is never declared as its own TypeScript type, and cannot be flattened.
120impl ts_rs::TS for ORef {
121    type WithoutGenerics = Self;
122    type OptionInnerType = Self;
123    fn name(_: &ts_rs::Config) -> String {
124        "string".to_owned()
125    }
126    fn inline(cfg: &ts_rs::Config) -> String {
127        <Self as ts_rs::TS>::name(cfg)
128    }
129    fn inline_flattened(_: &ts_rs::Config) -> String {
130        panic!("ORef cannot be flattened")
131    }
132    fn decl(_: &ts_rs::Config) -> String {
133        panic!("ORef cannot be declared")
134    }
135    fn decl_concrete(_: &ts_rs::Config) -> String {
136        panic!("ORef cannot be declared")
137    }
138}
139
140// Custom serde: serialize as "otype:oid" string, not as an object.
141
142impl Serialize for ORef {
143    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
144        serializer.serialize_str(&self.to_string())
145    }
146}
147
148impl<'de> Deserialize<'de> for ORef {
149    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
150        let s = String::deserialize(deserializer)?;
151        ORef::parse(&s).map_err(de::Error::custom)
152    }
153}
154
155#[cfg(test)]
156mod tests {
157    use super::*;
158
159    /// The binding ts-rs emits for ORef must be `string`, because that is what
160    /// the wire carries. Both halves are asserted together on purpose: if
161    /// either the TS name or the serialized form drifts, the other pins it.
162    ///
163    /// Why this needs guarding at all: ts-rs derives from the STRUCT shape, and
164    /// the `serde-compat` feature (see crates/srv/Cargo.toml) only reads
165    /// serde ATTRIBUTES — rename_all, skip_serializing_if and friends. It
166    /// cannot see a hand-written `impl Serialize`, which is exactly what ORef
167    /// has. Without `#[ts(type = "string")]` the generated type would be
168    /// `{ otype: string, oid: string }`: a shape no client ever receives, that
169    /// TypeScript would nonetheless believe, and that no compiler on either
170    /// side could catch.
171    #[test]
172    fn ts_binding_is_a_string_because_the_wire_is_a_string() {
173        // Assert what a CONSUMER generates, not ORef::name(). `#[ts(as)]`
174        // changes what is inlined at use sites; the type keeps its own name,
175        // so asserting name() would test the wrong thing (it returns "ORef").
176        // A field is what any real command actually has.
177        #[derive(ts_rs::TS)]
178        #[allow(dead_code)]
179        struct Consumer {
180            oref: ORef,
181        }
182        let decl = <Consumer as ts_rs::TS>::decl(&ts_rs::Config::from_env());
183        assert!(
184            decl.contains("oref: string"),
185            "a field holding an ORef must generate `string`; got: {decl}"
186        );
187        assert!(
188            !decl.contains("otype"),
189            "the struct shape must not leak into the binding; got: {decl}"
190        );
191
192        let oref = ORef::new("block", "550e8400-e29b-41d4-a716-446655440000");
193        assert_eq!(
194            serde_json::to_string(&oref).unwrap(),
195            "\"block:550e8400-e29b-41d4-a716-446655440000\"",
196            "the wire form the binding above claims to describe"
197        );
198    }
199
200    #[test]
201    fn test_oref_roundtrip() {
202        let oref = ORef::new("block", "550e8400-e29b-41d4-a716-446655440000");
203        let json = serde_json::to_string(&oref).unwrap();
204        assert_eq!(json, r#""block:550e8400-e29b-41d4-a716-446655440000""#);
205
206        let parsed: ORef = serde_json::from_str(&json).unwrap();
207        assert_eq!(parsed, oref);
208    }
209
210    #[test]
211    fn test_oref_empty() {
212        let oref = ORef::parse("").unwrap();
213        assert!(oref.is_empty());
214        let json = serde_json::to_string(&oref).unwrap();
215        assert_eq!(json, r#""""#);
216    }
217
218    #[test]
219    fn test_oref_all_valid_types() {
220        let uuid = "550e8400-e29b-41d4-a716-446655440000";
221        for otype in VALID_OTYPES {
222            let s = format!("{otype}:{uuid}");
223            let oref = ORef::parse(&s).unwrap();
224            assert_eq!(oref.otype, *otype);
225            assert_eq!(oref.oid, uuid);
226        }
227    }
228
229    #[test]
230    fn test_oref_invalid_otype() {
231        let result = ORef::parse("BLOCK:550e8400-e29b-41d4-a716-446655440000");
232        assert!(result.is_err());
233        assert!(matches!(result, Err(ORefParseError::InvalidOType(_))));
234    }
235
236    #[test]
237    fn test_oref_unknown_otype() {
238        let result = ORef::parse("foobar:550e8400-e29b-41d4-a716-446655440000");
239        assert!(result.is_err());
240        assert!(matches!(result, Err(ORefParseError::UnknownOType(_))));
241    }
242
243    #[test]
244    fn test_oref_invalid_uuid() {
245        let result = ORef::parse("block:not-a-uuid");
246        assert!(result.is_err());
247        assert!(matches!(result, Err(ORefParseError::InvalidOID(_))));
248    }
249
250    #[test]
251    fn test_oref_no_colon() {
252        let result = ORef::parse("blockuuid");
253        assert!(result.is_err());
254        assert!(matches!(result, Err(ORefParseError::InvalidFormat(_))));
255    }
256
257    #[test]
258    fn test_oref_display() {
259        let oref = ORef::new("tab", "550e8400-e29b-41d4-a716-446655440000");
260        assert_eq!(oref.to_string(), "tab:550e8400-e29b-41d4-a716-446655440000");
261    }
262}