agentmux_srv\backend/
oref.rs

1// Copyright 2025-2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! ORef: typed object reference in "otype:oid" string format.
5//! Custom serde: serializes as a JSON string, not an object.
6
7
8use serde::{de, Deserialize, Deserializer, Serialize, Serializer};
9use std::fmt;
10use uuid::Uuid;
11
12use super::obj::VALID_OTYPES;
13
14/// Object reference combining a type name and UUID.
15/// Wire format: `"block:550e8400-e29b-41d4-a716-446655440000"`
16#[derive(Debug, Clone, PartialEq, Eq, Hash, Default)]
17pub struct ORef {
18    pub otype: String,
19    pub oid: String,
20}
21
22impl ORef {
23    #[allow(dead_code)]
24    pub fn new(otype: impl Into<String>, oid: impl Into<String>) -> Self {
25        Self {
26            otype: otype.into(),
27            oid: oid.into(),
28        }
29    }
30
31    pub fn is_empty(&self) -> bool {
32        self.otype.is_empty() || self.oid.is_empty()
33    }
34
35    /// Parse an ORef from the "otype:oid" string format.
36    pub fn parse(s: &str) -> Result<Self, ORefParseError> {
37        if s.is_empty() {
38            return Ok(Self {
39                otype: String::new(),
40                oid: String::new(),
41            });
42        }
43
44        let parts: Vec<&str> = s.splitn(2, ':').collect();
45        if parts.len() != 2 {
46            return Err(ORefParseError::InvalidFormat(s.to_string()));
47        }
48
49        let otype = parts[0];
50        let oid = parts[1];
51
52        // Validate otype: must be lowercase ascii letters only
53        if otype.is_empty() || !otype.chars().all(|c| c.is_ascii_lowercase()) {
54            return Err(ORefParseError::InvalidOType(otype.to_string()));
55        }
56
57        if !VALID_OTYPES.contains(&otype) {
58            return Err(ORefParseError::UnknownOType(otype.to_string()));
59        }
60
61        // Validate OID is a valid UUID
62        Uuid::parse_str(oid).map_err(|_| ORefParseError::InvalidOID(oid.to_string()))?;
63
64        Ok(Self {
65            otype: otype.to_string(),
66            oid: oid.to_string(),
67        })
68    }
69}
70
71impl fmt::Display for ORef {
72    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
73        if self.is_empty() {
74            return write!(f, "");
75        }
76        write!(f, "{}:{}", self.otype, self.oid)
77    }
78}
79
80/// Errors from parsing an ORef string.
81#[derive(Debug, Clone, thiserror::Error)]
82pub enum ORefParseError {
83    #[error("invalid object reference: {0:?}")]
84    InvalidFormat(String),
85    #[error("invalid object type: {0:?}")]
86    InvalidOType(String),
87    #[error("unknown object type: {0:?}")]
88    UnknownOType(String),
89    #[error("invalid object id: {0:?}")]
90    InvalidOID(String),
91}
92
93// Custom serde: serialize as "otype:oid" string, not as an object.
94
95impl Serialize for ORef {
96    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
97        serializer.serialize_str(&self.to_string())
98    }
99}
100
101impl<'de> Deserialize<'de> for ORef {
102    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
103        let s = String::deserialize(deserializer)?;
104        ORef::parse(&s).map_err(de::Error::custom)
105    }
106}
107
108#[cfg(test)]
109mod tests {
110    use super::*;
111
112    #[test]
113    fn test_oref_roundtrip() {
114        let oref = ORef::new("block", "550e8400-e29b-41d4-a716-446655440000");
115        let json = serde_json::to_string(&oref).unwrap();
116        assert_eq!(json, r#""block:550e8400-e29b-41d4-a716-446655440000""#);
117
118        let parsed: ORef = serde_json::from_str(&json).unwrap();
119        assert_eq!(parsed, oref);
120    }
121
122    #[test]
123    fn test_oref_empty() {
124        let oref = ORef::parse("").unwrap();
125        assert!(oref.is_empty());
126        let json = serde_json::to_string(&oref).unwrap();
127        assert_eq!(json, r#""""#);
128    }
129
130    #[test]
131    fn test_oref_all_valid_types() {
132        let uuid = "550e8400-e29b-41d4-a716-446655440000";
133        for otype in VALID_OTYPES {
134            let s = format!("{otype}:{uuid}");
135            let oref = ORef::parse(&s).unwrap();
136            assert_eq!(oref.otype, *otype);
137            assert_eq!(oref.oid, uuid);
138        }
139    }
140
141    #[test]
142    fn test_oref_invalid_otype() {
143        let result = ORef::parse("BLOCK:550e8400-e29b-41d4-a716-446655440000");
144        assert!(result.is_err());
145        assert!(matches!(result, Err(ORefParseError::InvalidOType(_))));
146    }
147
148    #[test]
149    fn test_oref_unknown_otype() {
150        let result = ORef::parse("foobar:550e8400-e29b-41d4-a716-446655440000");
151        assert!(result.is_err());
152        assert!(matches!(result, Err(ORefParseError::UnknownOType(_))));
153    }
154
155    #[test]
156    fn test_oref_invalid_uuid() {
157        let result = ORef::parse("block:not-a-uuid");
158        assert!(result.is_err());
159        assert!(matches!(result, Err(ORefParseError::InvalidOID(_))));
160    }
161
162    #[test]
163    fn test_oref_no_colon() {
164        let result = ORef::parse("blockuuid");
165        assert!(result.is_err());
166        assert!(matches!(result, Err(ORefParseError::InvalidFormat(_))));
167    }
168
169    #[test]
170    fn test_oref_display() {
171        let oref = ORef::new("tab", "550e8400-e29b-41d4-a716-446655440000");
172        assert_eq!(oref.to_string(), "tab:550e8400-e29b-41d4-a716-446655440000");
173    }
174}