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 let cap = CapabilityRef::new("current_time");
182 assert_eq!(cap.capability_id(), "current_time");
183 assert_eq!(cap.config_value(), &json!({}));
184 }
185
186 #[test]
187 fn serializes_as_persisted_attachment_shape() {
188 let cap = CapabilityRef::with_config("current_time", json!({"timezone": "UTC"}));
189 let value = serde_json::to_value(&cap).unwrap();
190 assert_eq!(
191 value,
192 json!({"ref": "current_time", "config": {"timezone": "UTC"}})
193 );
194
195 let parsed: CapabilityRef = serde_json::from_value(value).unwrap();
196 assert_eq!(parsed, cap);
197 }
198
199 #[test]
200 fn missing_and_null_config_deserialize_to_empty_object() {
201 let missing: CapabilityRef = serde_json::from_str(r#"{"ref":"noop"}"#).unwrap();
202 assert_eq!(missing.config_value(), &json!({}));
203
204 let null: CapabilityRef = serde_json::from_str(r#"{"ref":"noop","config":null}"#).unwrap();
205 assert_eq!(null.config_value(), &json!({}));
206 }
207
208 #[test]
209 fn debug_redacts_config_values() {
210 let cap = CapabilityRef::with_config(
211 "vendor.search",
212 json!({"api_key": "sk-super-secret", "index": "prod"}),
213 );
214 let debug = format!("{cap:?}");
215 assert!(debug.contains("vendor.search"));
216 assert!(!debug.contains("sk-super-secret"));
217 assert!(!debug.contains("api_key"));
218 assert!(debug.contains("<redacted>"));
219 }
220
221 #[test]
222 fn validate_enforces_id_and_object_boundary() {
223 CapabilityRef::new("current_time").validate().unwrap();
224 assert!(CapabilityRef::new("2fast").validate().is_err());
225 let err = CapabilityRef::with_config("noop", json!("string"))
226 .validate()
227 .unwrap_err();
228 assert!(err.reason().contains("JSON object"));
229 }
230
231 #[test]
232 fn builder_and_mutation() {
233 let mut cap = CapabilityRef::new("noop").config(json!({"a": 1}));
234 assert_eq!(cap.config_value(), &json!({"a": 1}));
235 cap.set_config(json!({"b": 2}));
236 assert_eq!(cap.config_value(), &json!({"b": 2}));
237 let (id, config) = cap.into_parts();
238 assert_eq!(id.as_str(), "noop");
239 assert_eq!(config, json!({"b": 2}));
240 }
241}