1use serde::{Deserialize, Serialize};
21use thiserror::Error;
22
23pub const DEF_MIN_SUPPORTED_SCHEMA: u32 = 1;
25pub const DEF_MAX_SUPPORTED_SCHEMA: u32 = 1;
28
29fn default_container_volumes() -> String {
33 "[]".to_string()
34}
35
36#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
39pub struct DefinitionRecord {
40 pub schema_version: u32,
41 pub data: DefinitionRecordV1,
42}
43
44#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
49pub struct DefContentBlob {
50 pub content_type: String,
51 pub content: String,
52}
53
54#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
56pub struct DefSkillBlob {
57 pub id: String,
58 pub name: String,
59 #[serde(default)]
60 pub trigger: String,
61 #[serde(default)]
62 pub skill_type: String,
63 #[serde(default)]
64 pub description: String,
65 #[serde(default)]
66 pub content: String,
67}
68
69#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
76pub struct DefinitionRecordV1 {
77 pub id: String,
78 #[serde(default)]
79 pub slug: String,
80 pub name: String,
81 #[serde(default)]
82 pub icon: String,
83 pub provider: String,
84 #[serde(default)]
85 pub description: String,
86 #[serde(default)]
87 pub working_directory: String,
88 #[serde(default)]
89 pub shell: String,
90 #[serde(default)]
91 pub provider_flags: String,
92 #[serde(default)]
93 pub auto_start: i64,
94 #[serde(default)]
95 pub restart_on_crash: i64,
96 #[serde(default)]
97 pub idle_timeout_minutes: i64,
98 #[serde(default)]
99 pub created_at: i64,
100 #[serde(default)]
101 pub agent_type: String,
102 #[serde(default)]
103 pub environment: String,
104 #[serde(default)]
105 pub agent_bus_id: String,
106 #[serde(default)]
107 pub is_seeded: i64,
108 #[serde(default)]
109 pub accounts: String,
110 #[serde(default)]
111 pub parent_id: String,
112 #[serde(default)]
113 pub branch_label: String,
114 #[serde(default)]
115 pub updated_at: i64,
116 #[serde(default)]
117 pub user_hidden: i64,
118 #[serde(default)]
119 pub container_image: String,
120 #[serde(default = "default_container_volumes")]
121 pub container_volumes: String,
122 #[serde(default)]
123 pub container_name: String,
124 #[serde(default)]
131 pub use_ambient_login: i64,
132 #[serde(default)]
139 pub auto_continue_enabled: i64,
140 #[serde(default)]
143 pub content: Vec<DefContentBlob>,
144 #[serde(default)]
146 pub skills: Vec<DefSkillBlob>,
147}
148
149#[derive(Debug, Error)]
150pub enum DefValidationError {
151 #[error("schema_version {version} outside supported [{min}, {max}]")]
152 UnsupportedSchema { version: u32, min: u32, max: u32 },
153 #[error("filename {filename:?} does not match data.id {id:?}")]
154 IdMismatch { filename: String, id: String },
155 #[error("required field missing: {0}")]
156 MissingField(&'static str),
157}
158
159pub fn validate(filename_stem: &str, rec: &DefinitionRecord) -> Result<(), DefValidationError> {
164 if rec.schema_version < DEF_MIN_SUPPORTED_SCHEMA || rec.schema_version > DEF_MAX_SUPPORTED_SCHEMA
165 {
166 return Err(DefValidationError::UnsupportedSchema {
167 version: rec.schema_version,
168 min: DEF_MIN_SUPPORTED_SCHEMA,
169 max: DEF_MAX_SUPPORTED_SCHEMA,
170 });
171 }
172 let d = &rec.data;
173 if d.id.is_empty() {
174 return Err(DefValidationError::MissingField("id"));
175 }
176 if d.id != filename_stem {
177 return Err(DefValidationError::IdMismatch {
178 filename: filename_stem.to_string(),
179 id: d.id.clone(),
180 });
181 }
182 if d.name.is_empty() {
183 return Err(DefValidationError::MissingField("name"));
184 }
185 if d.provider.is_empty() {
186 return Err(DefValidationError::MissingField("provider"));
187 }
188 Ok(())
189}
190
191#[cfg(test)]
192mod tests {
193 use super::*;
194
195 fn rec(id: &str) -> DefinitionRecord {
196 DefinitionRecord {
197 schema_version: 1,
198 data: DefinitionRecordV1 {
199 id: id.to_string(),
200 slug: id.to_string(),
201 name: "Demo".to_string(),
202 icon: "✦".to_string(),
203 provider: "claude".to_string(),
204 description: String::new(),
205 working_directory: String::new(),
206 shell: String::new(),
207 provider_flags: String::new(),
208 auto_start: 0,
209 restart_on_crash: 0,
210 idle_timeout_minutes: 0,
211 created_at: 1,
212 agent_type: "host".to_string(),
213 environment: String::new(),
214 agent_bus_id: String::new(),
215 is_seeded: 0,
216 accounts: String::new(),
217 parent_id: String::new(),
218 branch_label: String::new(),
219 updated_at: 1,
220 user_hidden: 0,
221 container_image: String::new(),
222 container_volumes: "[]".to_string(),
223 container_name: String::new(),
224 use_ambient_login: 0,
225 auto_continue_enabled: 0,
226 content: Vec::new(),
227 skills: Vec::new(),
228 },
229 }
230 }
231
232 #[test]
233 fn happy_path() {
234 validate("abc", &rec("abc")).unwrap();
235 }
236
237 #[test]
238 fn unsupported_schema_is_rejected() {
239 let mut r = rec("abc");
240 r.schema_version = 999;
241 assert!(matches!(
242 validate("abc", &r).unwrap_err(),
243 DefValidationError::UnsupportedSchema { .. }
244 ));
245 }
246
247 #[test]
248 fn filename_mismatch_is_rejected() {
249 assert!(matches!(
250 validate("xyz", &rec("abc")).unwrap_err(),
251 DefValidationError::IdMismatch { .. }
252 ));
253 }
254
255 #[test]
256 fn missing_name_is_rejected() {
257 let mut r = rec("abc");
258 r.data.name = String::new();
259 assert!(matches!(
260 validate("abc", &r).unwrap_err(),
261 DefValidationError::MissingField("name")
262 ));
263 }
264
265 #[test]
266 fn missing_provider_is_rejected() {
267 let mut r = rec("abc");
268 r.data.provider = String::new();
269 assert!(matches!(
270 validate("abc", &r).unwrap_err(),
271 DefValidationError::MissingField("provider")
272 ));
273 }
274
275 #[test]
276 fn unknown_fields_survive_round_trip() {
277 let raw = serde_json::json!({
280 "schema_version": 1,
281 "data": {
282 "id": "abc", "name": "Demo", "provider": "claude",
283 "future_field": "ignored"
284 }
285 });
286 let parsed: DefinitionRecord = serde_json::from_value(raw).unwrap();
287 assert_eq!(parsed.data.id, "abc");
288 assert_eq!(parsed.data.provider, "claude");
289 assert_eq!(parsed.data.container_volumes, "[]");
291 }
292}