everruns_capability/
reference.rs1use serde::de::Deserializer;
4use serde::{Deserialize, Serialize};
5use serde_json::{Map, Value};
6use std::fmt;
7
8use crate::error::CapabilityError;
9use crate::id::CapabilityId;
10
11#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
33pub struct CapabilityRef {
34 #[serde(rename = "ref")]
35 id: CapabilityId,
36 #[serde(default = "empty_object", deserialize_with = "object_or_default")]
37 config: Value,
38}
39
40fn empty_object() -> Value {
41 Value::Object(Map::new())
42}
43
44fn object_or_default<'de, D>(deserializer: D) -> Result<Value, D::Error>
47where
48 D: Deserializer<'de>,
49{
50 let value = Value::deserialize(deserializer)?;
51 Ok(match value {
52 Value::Null => empty_object(),
53 other => other,
54 })
55}
56
57impl fmt::Debug for CapabilityRef {
58 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
59 formatter
60 .debug_struct("CapabilityRef")
61 .field("id", &self.id)
62 .field("config", &"<redacted>")
63 .finish()
64 }
65}
66
67impl CapabilityRef {
68 pub fn new(id: impl Into<CapabilityId>) -> Self {
70 Self {
71 id: id.into(),
72 config: empty_object(),
73 }
74 }
75
76 pub fn with_config(id: impl Into<CapabilityId>, config: Value) -> Self {
78 Self {
79 id: id.into(),
80 config,
81 }
82 }
83
84 pub fn config(mut self, config: impl Into<Value>) -> Self {
90 self.config = config.into();
91 self
92 }
93
94 pub fn id(&self) -> &str {
96 self.id.as_str()
97 }
98
99 pub fn capability_id(&self) -> &str {
101 self.id.as_str()
102 }
103
104 pub fn typed_id(&self) -> &CapabilityId {
106 &self.id
107 }
108
109 pub fn config_value(&self) -> &Value {
111 &self.config
112 }
113
114 pub fn config_mut(&mut self) -> &mut Value {
116 &mut self.config
117 }
118
119 pub fn set_config(&mut self, config: impl Into<Value>) {
121 self.config = config.into();
122 }
123
124 pub fn set_id(&mut self, id: impl Into<CapabilityId>) {
126 self.id = id.into();
127 }
128
129 pub fn into_parts(self) -> (CapabilityId, Value) {
131 (self.id, self.config)
132 }
133
134 pub fn validate(&self) -> Result<(), CapabilityError> {
136 self.id.validate()?;
137 validate_capability_config(self.id.as_str(), &self.config)
138 }
139}
140
141impl From<CapabilityId> for CapabilityRef {
142 fn from(id: CapabilityId) -> Self {
143 Self::new(id)
144 }
145}
146
147impl From<&str> for CapabilityRef {
148 fn from(id: &str) -> Self {
149 Self::new(id)
150 }
151}
152
153impl From<String> for CapabilityRef {
154 fn from(id: String) -> Self {
155 Self::new(id)
156 }
157}
158
159pub fn validate_capability_config(id: &str, config: &Value) -> Result<(), CapabilityError> {
164 if config.is_object() {
165 Ok(())
166 } else {
167 Err(CapabilityError::InvalidConfig {
168 id: id.to_string(),
169 reason: "capability config must be a JSON object".to_string(),
170 })
171 }
172}
173
174#[cfg(test)]
175mod tests {
176 use super::*;
177 use serde_json::json;
178
179 #[test]
180 fn new_defaults_to_empty_object() {
181 for cap in [
182 CapabilityRef::new("current_time"),
183 CapabilityId::new("current_time").into(),
184 "current_time".into(),
185 String::from("current_time").into(),
186 ] {
187 assert_eq!(cap.capability_id(), "current_time");
188 assert_eq!(cap.config_value(), &json!({}));
189 assert_eq!(
190 serde_json::to_value(&cap).unwrap(),
191 json!({"ref": "current_time", "config": {}})
192 );
193 }
194 }
195
196 #[test]
197 fn serializes_as_persisted_attachment_shape() {
198 let cap = CapabilityRef::with_config("current_time", json!({"timezone": "UTC"}));
199 let value = serde_json::to_value(&cap).unwrap();
200 assert_eq!(
201 value,
202 json!({"ref": "current_time", "config": {"timezone": "UTC"}})
203 );
204
205 let parsed: CapabilityRef = serde_json::from_value(value).unwrap();
206 assert_eq!(parsed, cap);
207 }
208
209 #[test]
210 fn missing_and_null_config_deserialize_to_empty_object() {
211 let missing: CapabilityRef = serde_json::from_str(r#"{"ref":"noop"}"#).unwrap();
212 assert_eq!(missing.config_value(), &json!({}));
213
214 let null: CapabilityRef = serde_json::from_str(r#"{"ref":"noop","config":null}"#).unwrap();
215 assert_eq!(null.config_value(), &json!({}));
216 }
217
218 #[test]
219 fn debug_redacts_config_values() {
220 let cap = CapabilityRef::with_config(
221 "vendor.search",
222 json!({"api_key": "sk-super-secret", "index": "prod"}),
223 );
224 let debug = format!("{cap:?}");
225 assert!(debug.contains("vendor.search"));
226 assert!(!debug.contains("sk-super-secret"));
227 assert!(!debug.contains("api_key"));
228 assert!(debug.contains("<redacted>"));
229 }
230
231 #[test]
232 fn validate_enforces_id_and_object_boundary() {
233 CapabilityRef::new("current_time").validate().unwrap();
234 assert!(CapabilityRef::new("2fast").validate().is_err());
235 CapabilityRef::with_config("noop", json!({"enabled": true}))
236 .validate()
237 .unwrap();
238 for config in [
239 json!("string"),
240 json!(null),
241 json!([]),
242 json!(42),
243 json!(true),
244 ] {
245 let err = CapabilityRef::with_config("noop", config)
246 .validate()
247 .unwrap_err();
248 assert_eq!(err.id(), "noop");
249 assert!(err.reason().contains("JSON object"));
250 }
251 }
252
253 #[test]
254 fn builder_and_mutation() {
255 let mut cap = CapabilityRef::new("noop").config(json!({"a": 1}));
256 assert_eq!(cap.config_value(), &json!({"a": 1}));
257 cap.set_config(json!({"b": 2}));
258 assert_eq!(cap.config_value(), &json!({"b": 2}));
259 let (id, config) = cap.into_parts();
260 assert_eq!(id.as_str(), "noop");
261 assert_eq!(config, json!({"b": 2}));
262 }
263}