1use serde::{Deserialize, Serialize};
9
10use crate::agent_spec_patch::{AgentSpecPatch, merge_agent_spec};
11use crate::registry_spec::{AgentSpec, McpServerSpec, ModelBindingSpec, ProviderSpec};
12use crate::tool_spec::ToolSpec;
13use crate::tool_spec_patch::{ToolSpecPatch, merge_tool_spec};
14
15#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
17pub struct ConfigRecord<T> {
18 pub spec: T,
19 pub meta: RecordMeta,
20}
21
22#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
24pub struct RecordMeta {
25 pub source: RecordSource,
26 #[serde(default)]
27 pub hidden: bool,
28 #[serde(default, skip_serializing_if = "Option::is_none")]
32 pub user_overrides: Option<serde_json::Value>,
33 #[serde(default)]
36 pub created_at: u64,
37 #[serde(default)]
38 pub updated_at: u64,
39 #[serde(default)]
44 pub revision: u64,
45}
46
47#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
49#[serde(tag = "kind", rename_all = "snake_case")]
50pub enum RecordSource {
51 Builtin { binary_version: String },
54 User,
56}
57
58#[derive(Debug, Clone, Copy, Default, Deserialize, Serialize, PartialEq, Eq)]
64#[serde(deny_unknown_fields)]
65pub struct NoConfigPatch {}
66
67pub trait ConfigRecordMerge: Sized {
69 type Patch: serde::de::DeserializeOwned;
70
71 fn merge_patch(self, patch: Self::Patch) -> Self;
72}
73
74impl ConfigRecordMerge for AgentSpec {
75 type Patch = AgentSpecPatch;
76
77 fn merge_patch(self, patch: AgentSpecPatch) -> Self {
78 merge_agent_spec(self, patch)
79 }
80}
81
82impl ConfigRecordMerge for ToolSpec {
83 type Patch = ToolSpecPatch;
84
85 fn merge_patch(self, patch: ToolSpecPatch) -> Self {
86 merge_tool_spec(self, patch)
87 }
88}
89
90impl ConfigRecordMerge for ProviderSpec {
91 type Patch = NoConfigPatch;
92
93 fn merge_patch(self, _patch: NoConfigPatch) -> Self {
94 self
95 }
96}
97
98impl ConfigRecordMerge for ModelBindingSpec {
99 type Patch = NoConfigPatch;
100
101 fn merge_patch(self, _patch: NoConfigPatch) -> Self {
102 self
103 }
104}
105
106impl ConfigRecordMerge for McpServerSpec {
107 type Patch = NoConfigPatch;
108
109 fn merge_patch(self, _patch: NoConfigPatch) -> Self {
110 self
111 }
112}
113
114#[derive(Debug, thiserror::Error)]
116pub enum ConfigRecordError {
117 #[error("invalid config record: {0}")]
118 Decode(#[source] serde_json::Error),
119 #[error("invalid config record overrides: {0}")]
120 Overrides(#[source] serde_json::Error),
121}
122
123impl<T: serde::de::DeserializeOwned> ConfigRecord<T> {
124 pub fn from_value(value: serde_json::Value) -> Result<Self, serde_json::Error> {
131 if is_envelope(&value) {
132 serde_json::from_value(value)
133 } else {
134 let spec: T = serde_json::from_value(value)?;
135 Ok(Self {
136 spec,
137 meta: RecordMeta::legacy_user(),
138 })
139 }
140 }
141}
142
143pub fn decode_config_record<T>(
146 value: serde_json::Value,
147) -> Result<ConfigRecord<T>, ConfigRecordError>
148where
149 T: serde::de::DeserializeOwned,
150{
151 ConfigRecord::from_value(value).map_err(ConfigRecordError::Decode)
152}
153
154pub fn validate_config_record<T>(
157 value: serde_json::Value,
158) -> Result<ConfigRecord<T>, ConfigRecordError>
159where
160 T: serde::de::DeserializeOwned + ConfigRecordMerge,
161{
162 let record = decode_config_record::<T>(value)?;
163 validate_config_record_overrides::<T>(&record)?;
164 Ok(record)
165}
166
167pub fn validate_config_record_overrides<T>(
169 record: &ConfigRecord<T>,
170) -> Result<(), ConfigRecordError>
171where
172 T: ConfigRecordMerge,
173{
174 if let Some(overrides) = &record.meta.user_overrides {
175 serde_json::from_value::<T::Patch>(overrides.clone())
176 .map_err(ConfigRecordError::Overrides)?;
177 }
178 Ok(())
179}
180
181pub fn effective_config_record<T>(record: ConfigRecord<T>) -> Result<T, ConfigRecordError>
183where
184 T: ConfigRecordMerge,
185{
186 let Some(overrides) = record.meta.user_overrides else {
187 return Ok(record.spec);
188 };
189 let patch: T::Patch =
190 serde_json::from_value(overrides).map_err(ConfigRecordError::Overrides)?;
191 Ok(record.spec.merge_patch(patch))
192}
193
194pub fn effective_visible_config_records<T, I>(records: I) -> Result<Vec<T>, ConfigRecordError>
199where
200 T: serde::de::DeserializeOwned + ConfigRecordMerge,
201 I: IntoIterator<Item = serde_json::Value>,
202{
203 let mut out = Vec::new();
204 for value in records {
205 let record = validate_config_record::<T>(value)?;
206 if record.meta.hidden {
207 continue;
208 }
209 out.push(effective_config_record(record)?);
210 }
211 Ok(out)
212}
213
214impl<T: Serialize> ConfigRecord<T> {
215 pub fn to_value(&self) -> Result<serde_json::Value, serde_json::Error> {
217 serde_json::to_value(self)
218 }
219}
220
221impl RecordMeta {
222 pub fn legacy_user() -> Self {
225 Self {
226 source: RecordSource::User,
227 hidden: false,
228 user_overrides: None,
229 created_at: 0,
230 updated_at: 0,
231 revision: 0,
232 }
233 }
234
235 pub fn new_user() -> Self {
237 let now = crate::time::now_ms();
238 Self {
239 source: RecordSource::User,
240 hidden: false,
241 user_overrides: None,
242 created_at: now,
243 updated_at: now,
244 revision: 0,
245 }
246 }
247
248 pub fn new_builtin(binary_version: impl Into<String>) -> Self {
250 let now = crate::time::now_ms();
251 Self {
252 source: RecordSource::Builtin {
253 binary_version: binary_version.into(),
254 },
255 hidden: false,
256 user_overrides: None,
257 created_at: now,
258 updated_at: now,
259 revision: 0,
260 }
261 }
262}
263
264fn is_envelope(value: &serde_json::Value) -> bool {
265 matches!(value, serde_json::Value::Object(map) if map.contains_key("spec") && map.contains_key("meta"))
266}
267
268#[cfg(test)]
269mod tests {
270 use super::*;
271
272 #[test]
273 fn legacy_json_without_revision_deserialises_to_zero() {
274 let json = serde_json::json!({
276 "source": {"kind": "user"},
277 "hidden": false,
278 "created_at": 1000,
279 "updated_at": 2000
280 });
281 let meta: RecordMeta = serde_json::from_value(json).unwrap();
282 assert_eq!(meta.revision, 0);
283 assert_eq!(meta.created_at, 1000);
284 assert_eq!(meta.updated_at, 2000);
285 }
286
287 #[test]
288 fn round_trip_preserves_revision() {
289 let meta = RecordMeta {
290 source: RecordSource::User,
291 hidden: false,
292 user_overrides: None,
293 created_at: 100,
294 updated_at: 200,
295 revision: 7,
296 };
297 let serialized = serde_json::to_value(&meta).unwrap();
298 let deserialized: RecordMeta = serde_json::from_value(serialized).unwrap();
299 assert_eq!(deserialized.revision, 7);
300 }
301
302 #[test]
303 fn constructors_default_revision_to_zero() {
304 assert_eq!(RecordMeta::legacy_user().revision, 0);
305 assert_eq!(RecordMeta::new_user().revision, 0);
306 assert_eq!(RecordMeta::new_builtin("1.0.0").revision, 0);
307 }
308}