Skip to main content

gate4agent_types/
adapter.rs

1use serde::{Deserialize, Deserializer, Serialize, Serializer};
2use std::borrow::Borrow;
3use std::fmt;
4use std::str::FromStr;
5use thiserror::Error;
6
7const MAX_ADAPTER_ID_LEN: usize = 96;
8pub const MAX_ADAPTER_REVISION_LEN: usize = 256;
9
10/// Stable identifier for one provider adapter implementation.
11///
12/// Adapter identity is deliberately independent from `AgentId`: several agents
13/// may share a reviewed wire shape without being re-identified as one another.
14#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
15pub struct AdapterId(String);
16
17impl AdapterId {
18    pub fn new(value: impl Into<String>) -> Result<Self, AdapterIdError> {
19        let value = value.into();
20        validate_id(&value)?;
21        Ok(Self(value))
22    }
23
24    pub fn as_str(&self) -> &str {
25        &self.0
26    }
27}
28
29fn validate_id(value: &str) -> Result<(), AdapterIdError> {
30    if value.is_empty() {
31        return Err(AdapterIdError::Empty);
32    }
33    if value.len() > MAX_ADAPTER_ID_LEN {
34        return Err(AdapterIdError::TooLong {
35            len: value.len(),
36            max: MAX_ADAPTER_ID_LEN,
37        });
38    }
39    if !value.bytes().all(|byte| {
40        byte.is_ascii_lowercase() || byte.is_ascii_digit() || matches!(byte, b'-' | b'_')
41    }) {
42        return Err(AdapterIdError::InvalidCharacters(value.to_owned()));
43    }
44    if matches!(value.as_bytes().first(), Some(b'-' | b'_'))
45        || matches!(value.as_bytes().last(), Some(b'-' | b'_'))
46    {
47        return Err(AdapterIdError::InvalidBoundary(value.to_owned()));
48    }
49    Ok(())
50}
51
52impl AsRef<str> for AdapterId {
53    fn as_ref(&self) -> &str {
54        self.as_str()
55    }
56}
57
58impl Borrow<str> for AdapterId {
59    fn borrow(&self) -> &str {
60        self.as_str()
61    }
62}
63
64impl fmt::Display for AdapterId {
65    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
66        formatter.write_str(self.as_str())
67    }
68}
69
70impl FromStr for AdapterId {
71    type Err = AdapterIdError;
72
73    fn from_str(value: &str) -> Result<Self, Self::Err> {
74        Self::new(value)
75    }
76}
77
78impl Serialize for AdapterId {
79    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
80    where
81        S: Serializer,
82    {
83        serializer.serialize_str(self.as_str())
84    }
85}
86
87impl<'de> Deserialize<'de> for AdapterId {
88    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
89    where
90        D: Deserializer<'de>,
91    {
92        let value = String::deserialize(deserializer)?;
93        Self::new(value).map_err(serde::de::Error::custom)
94    }
95}
96
97#[derive(Debug, Error, Clone, Eq, PartialEq)]
98pub enum AdapterIdError {
99    #[error("adapter ID cannot be empty")]
100    Empty,
101    #[error("adapter ID length {len} exceeds the {max}-byte limit")]
102    TooLong { len: usize, max: usize },
103    #[error("adapter ID must contain only lowercase ASCII letters, digits, '-' or '_': {0}")]
104    InvalidCharacters(String),
105    #[error("adapter ID cannot start or end with '-' or '_': {0}")]
106    InvalidBoundary(String),
107}
108
109#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize, Deserialize)]
110#[serde(rename_all = "kebab-case")]
111pub enum AdapterFamily {
112    PtySemantic,
113    Pipe,
114    OneShot,
115    Acp,
116    Hook,
117    ManagedHook,
118    History,
119    Resume,
120    SessionOptions,
121    CapabilityProbe,
122}
123
124#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize, Deserialize)]
125#[serde(rename_all = "kebab-case")]
126pub enum AdapterVerification {
127    Reference,
128    SyntheticFixture,
129    CapturedFixture,
130    VendorCanary,
131}
132
133#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize, Deserialize)]
134pub struct AdapterBinding {
135    pub id: AdapterId,
136    pub revision: String,
137    pub verification: AdapterVerification,
138}
139
140impl AdapterBinding {
141    pub fn new(
142        id: AdapterId,
143        revision: impl Into<String>,
144        verification: AdapterVerification,
145    ) -> Result<Self, AdapterBindingError> {
146        let revision = revision.into();
147        validate_revision(&revision)?;
148        Ok(Self {
149            id,
150            revision,
151            verification,
152        })
153    }
154
155    pub fn validate(&self) -> Result<(), AdapterBindingError> {
156        validate_revision(&self.revision)
157    }
158}
159
160fn validate_revision(revision: &str) -> Result<(), AdapterBindingError> {
161    if revision.trim().is_empty() {
162        return Err(AdapterBindingError::EmptyRevision);
163    }
164    if revision.len() > MAX_ADAPTER_REVISION_LEN {
165        return Err(AdapterBindingError::RevisionTooLong {
166            len: revision.len(),
167            max: MAX_ADAPTER_REVISION_LEN,
168        });
169    }
170    if revision.chars().any(char::is_control) {
171        return Err(AdapterBindingError::InvalidRevision(revision.to_owned()));
172    }
173    Ok(())
174}
175
176#[derive(Clone, Debug, Error, Eq, PartialEq)]
177pub enum AdapterBindingError {
178    #[error("adapter revision cannot be empty")]
179    EmptyRevision,
180    #[error("adapter revision length {len} exceeds the {max}-byte limit")]
181    RevisionTooLong { len: usize, max: usize },
182    #[error("adapter revision contains control characters: {0:?}")]
183    InvalidRevision(String),
184}
185
186#[cfg(test)]
187mod tests {
188    use super::*;
189
190    #[test]
191    fn adapter_ids_are_extensible_and_wire_safe() {
192        let id = AdapterId::new("grok-hook-v1").unwrap();
193        assert_eq!(id.as_str(), "grok-hook-v1");
194        let json = serde_json::to_string(&id).unwrap();
195        assert_eq!(serde_json::from_str::<AdapterId>(&json).unwrap(), id);
196    }
197
198    #[test]
199    fn invalid_ids_and_revisions_are_rejected() {
200        assert!(matches!(
201            AdapterId::new("Grok"),
202            Err(AdapterIdError::InvalidCharacters(_))
203        ));
204        let id = AdapterId::new("grok").unwrap();
205        assert!(matches!(
206            AdapterBinding::new(id, "", AdapterVerification::Reference),
207            Err(AdapterBindingError::EmptyRevision)
208        ));
209    }
210}