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)]
127 pub content: Vec<DefContentBlob>,
128 #[serde(default)]
130 pub skills: Vec<DefSkillBlob>,
131}
132
133#[derive(Debug, Error)]
134pub enum DefValidationError {
135 #[error("schema_version {version} outside supported [{min}, {max}]")]
136 UnsupportedSchema { version: u32, min: u32, max: u32 },
137 #[error("filename {filename:?} does not match data.id {id:?}")]
138 IdMismatch { filename: String, id: String },
139 #[error("required field missing: {0}")]
140 MissingField(&'static str),
141}
142
143pub fn validate(filename_stem: &str, rec: &DefinitionRecord) -> Result<(), DefValidationError> {
148 if rec.schema_version < DEF_MIN_SUPPORTED_SCHEMA || rec.schema_version > DEF_MAX_SUPPORTED_SCHEMA
149 {
150 return Err(DefValidationError::UnsupportedSchema {
151 version: rec.schema_version,
152 min: DEF_MIN_SUPPORTED_SCHEMA,
153 max: DEF_MAX_SUPPORTED_SCHEMA,
154 });
155 }
156 let d = &rec.data;
157 if d.id.is_empty() {
158 return Err(DefValidationError::MissingField("id"));
159 }
160 if d.id != filename_stem {
161 return Err(DefValidationError::IdMismatch {
162 filename: filename_stem.to_string(),
163 id: d.id.clone(),
164 });
165 }
166 if d.name.is_empty() {
167 return Err(DefValidationError::MissingField("name"));
168 }
169 if d.provider.is_empty() {
170 return Err(DefValidationError::MissingField("provider"));
171 }
172 Ok(())
173}
174
175#[cfg(test)]
176mod tests {
177 use super::*;
178
179 fn rec(id: &str) -> DefinitionRecord {
180 DefinitionRecord {
181 schema_version: 1,
182 data: DefinitionRecordV1 {
183 id: id.to_string(),
184 slug: id.to_string(),
185 name: "Demo".to_string(),
186 icon: "✦".to_string(),
187 provider: "claude".to_string(),
188 description: String::new(),
189 working_directory: String::new(),
190 shell: String::new(),
191 provider_flags: String::new(),
192 auto_start: 0,
193 restart_on_crash: 0,
194 idle_timeout_minutes: 0,
195 created_at: 1,
196 agent_type: "host".to_string(),
197 environment: String::new(),
198 agent_bus_id: String::new(),
199 is_seeded: 0,
200 accounts: String::new(),
201 parent_id: String::new(),
202 branch_label: String::new(),
203 updated_at: 1,
204 user_hidden: 0,
205 container_image: String::new(),
206 container_volumes: "[]".to_string(),
207 container_name: String::new(),
208 content: Vec::new(),
209 skills: Vec::new(),
210 },
211 }
212 }
213
214 #[test]
215 fn happy_path() {
216 validate("abc", &rec("abc")).unwrap();
217 }
218
219 #[test]
220 fn unsupported_schema_is_rejected() {
221 let mut r = rec("abc");
222 r.schema_version = 999;
223 assert!(matches!(
224 validate("abc", &r).unwrap_err(),
225 DefValidationError::UnsupportedSchema { .. }
226 ));
227 }
228
229 #[test]
230 fn filename_mismatch_is_rejected() {
231 assert!(matches!(
232 validate("xyz", &rec("abc")).unwrap_err(),
233 DefValidationError::IdMismatch { .. }
234 ));
235 }
236
237 #[test]
238 fn missing_name_is_rejected() {
239 let mut r = rec("abc");
240 r.data.name = String::new();
241 assert!(matches!(
242 validate("abc", &r).unwrap_err(),
243 DefValidationError::MissingField("name")
244 ));
245 }
246
247 #[test]
248 fn missing_provider_is_rejected() {
249 let mut r = rec("abc");
250 r.data.provider = String::new();
251 assert!(matches!(
252 validate("abc", &r).unwrap_err(),
253 DefValidationError::MissingField("provider")
254 ));
255 }
256
257 #[test]
258 fn unknown_fields_survive_round_trip() {
259 let raw = serde_json::json!({
262 "schema_version": 1,
263 "data": {
264 "id": "abc", "name": "Demo", "provider": "claude",
265 "future_field": "ignored"
266 }
267 });
268 let parsed: DefinitionRecord = serde_json::from_value(raw).unwrap();
269 assert_eq!(parsed.data.id, "abc");
270 assert_eq!(parsed.data.provider, "claude");
271 assert_eq!(parsed.data.container_volumes, "[]");
273 }
274}