1use serde::{Deserialize, Serialize};
4use serde_json::Value;
5
6#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
9pub struct SchemaTransitionInfo {
10 pub from: String,
11 pub to: String,
12 pub description: String,
13}
14
15pub const VALID_OPERATIONS: &[&str] = &["create", "update", "complete", "read"];
17
18pub const UCP_ANNOTATIONS: &[&str] = &["ucp_request", "ucp_response"];
20
21pub fn json_type_name(value: &Value) -> &'static str {
23 match value {
24 Value::Null => "null",
25 Value::Bool(_) => "boolean",
26 Value::Number(_) => "number",
27 Value::String(_) => "string",
28 Value::Array(_) => "array",
29 Value::Object(_) => "object",
30 }
31}
32
33#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
37#[serde(rename_all = "lowercase")]
38pub enum Direction {
39 Request,
40 Response,
41}
42
43impl Direction {
44 pub fn annotation_key(&self) -> &'static str {
46 match self {
47 Direction::Request => "ucp_request",
48 Direction::Response => "ucp_response",
49 }
50 }
51
52 pub fn dir_str(&self) -> &'static str {
58 match self {
59 Direction::Request => "request",
60 Direction::Response => "response",
61 }
62 }
63
64 pub fn from_request_flag(is_request: bool) -> Self {
66 if is_request {
67 Direction::Request
68 } else {
69 Direction::Response
70 }
71 }
72}
73
74#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
78pub enum Visibility {
79 #[default]
81 Include,
82 Omit,
84 Required,
86 Optional,
88}
89
90impl Visibility {
91 pub fn parse(s: &str) -> Option<Self> {
95 match s {
96 "omit" => Some(Visibility::Omit),
97 "required" => Some(Visibility::Required),
98 "optional" => Some(Visibility::Optional),
99 _ => None,
100 }
101 }
102}
103
104pub fn is_valid_schema_transition(from: &str, to: &str) -> bool {
107 from != to && Visibility::parse(from).is_some() && Visibility::parse(to).is_some()
108}
109
110pub fn is_valid_version(s: &str) -> bool {
116 if s.len() != 10 || s.as_bytes()[4] != b'-' || s.as_bytes()[7] != b'-' {
117 return false;
118 }
119 if !s.bytes().enumerate().all(|(i, b)| {
120 if i == 4 || i == 7 {
121 b == b'-'
122 } else {
123 b.is_ascii_digit()
124 }
125 }) {
126 return false;
127 }
128 let month: u8 = s[5..7].parse().unwrap_or(0);
129 let day: u8 = s[8..10].parse().unwrap_or(0);
130 (1..=12).contains(&month) && (1..=31).contains(&day)
131}
132
133#[derive(Debug, Clone, PartialEq, Eq)]
138pub struct VersionConstraint {
139 pub min: String,
140 pub max: Option<String>,
141}
142
143impl VersionConstraint {
144 pub fn satisfied_by(&self, version: &str) -> bool {
146 if version < self.min.as_str() {
147 return false;
148 }
149 if let Some(ref max) = self.max {
150 if version > max.as_str() {
151 return false;
152 }
153 }
154 true
155 }
156
157 pub fn parse(value: &Value) -> Result<Self, String> {
159 let obj = value.as_object().ok_or("expected object")?;
160
161 let min = obj
162 .get("min")
163 .and_then(|v| v.as_str())
164 .ok_or("missing required field \"min\"")?;
165
166 if !is_valid_version(min) {
167 return Err(format!(
168 "invalid version format for \"min\": \"{}\" (expected YYYY-MM-DD)",
169 min
170 ));
171 }
172
173 let max = match obj.get("max") {
174 Some(v) => {
175 let s = v.as_str().ok_or("\"max\" must be a string")?;
176 if !is_valid_version(s) {
177 return Err(format!(
178 "invalid version format for \"max\": \"{}\" (expected YYYY-MM-DD)",
179 s
180 ));
181 }
182 Some(s.to_string())
183 }
184 None => None,
185 };
186
187 Ok(Self {
188 min: min.to_string(),
189 max,
190 })
191 }
192}
193
194#[derive(Debug, Clone, PartialEq, Eq, Default)]
199pub struct Requires {
200 pub protocol: Option<VersionConstraint>,
201 pub capabilities: Vec<(String, VersionConstraint)>,
202}
203
204impl Requires {
205 pub fn parse(value: &Value) -> Result<Self, Vec<String>> {
207 let obj = value
208 .as_object()
209 .ok_or_else(|| vec!["\"requires\" must be an object".to_string()])?;
210 let mut errors = Vec::new();
211
212 let protocol = match obj.get("protocol") {
213 Some(v) => match VersionConstraint::parse(v) {
214 Ok(vc) => Some(vc),
215 Err(e) => {
216 errors.push(format!("requires.protocol: {}", e));
217 None
218 }
219 },
220 None => None,
221 };
222
223 let mut capabilities = Vec::new();
224 if let Some(caps_val) = obj.get("capabilities") {
225 match caps_val.as_object() {
226 Some(caps) => {
227 for (key, val) in caps {
228 match VersionConstraint::parse(val) {
229 Ok(vc) => capabilities.push((key.clone(), vc)),
230 Err(e) => errors.push(format!("requires.capabilities.{}: {}", key, e)),
231 }
232 }
233 }
234 None => errors.push("requires.capabilities must be an object".to_string()),
235 }
236 }
237
238 if errors.is_empty() {
239 Ok(Self {
240 protocol,
241 capabilities,
242 })
243 } else {
244 Err(errors)
245 }
246 }
247}
248
249#[derive(Debug, Clone)]
251pub struct ResolveOptions {
252 pub direction: Direction,
254 pub operation: String,
257 pub strict: bool,
260 pub include_future: bool,
266 pub def_name: Option<String>,
274}
275
276impl ResolveOptions {
277 pub fn new(direction: Direction, operation: impl Into<String>) -> Self {
283 Self {
284 direction,
285 operation: operation.into().to_lowercase(),
286 strict: false,
287 include_future: false,
288 def_name: None,
289 }
290 }
291
292 pub fn strict(mut self, strict: bool) -> Self {
294 self.strict = strict;
295 self
296 }
297
298 pub fn include_future(mut self, include_future: bool) -> Self {
300 self.include_future = include_future;
301 self
302 }
303
304 pub fn def_name(mut self, def_name: Option<String>) -> Self {
307 self.def_name = def_name;
308 self
309 }
310}
311
312#[cfg(test)]
313mod tests {
314 use super::*;
315
316 #[test]
317 fn direction_annotation_key() {
318 assert_eq!(Direction::Request.annotation_key(), "ucp_request");
319 assert_eq!(Direction::Response.annotation_key(), "ucp_response");
320 }
321
322 #[test]
323 fn visibility_parse_valid() {
324 assert_eq!(Visibility::parse("omit"), Some(Visibility::Omit));
325 assert_eq!(Visibility::parse("required"), Some(Visibility::Required));
326 assert_eq!(Visibility::parse("optional"), Some(Visibility::Optional));
327 }
328
329 #[test]
330 fn visibility_parse_invalid() {
331 assert_eq!(Visibility::parse("include"), None);
332 assert_eq!(Visibility::parse("readonly"), None);
333 assert_eq!(Visibility::parse(""), None);
334 }
335
336 #[test]
337 fn valid_schema_transitions() {
338 for (from, to) in [
340 ("required", "optional"),
341 ("required", "omit"),
342 ("optional", "omit"),
343 ("optional", "required"),
344 ("omit", "required"),
345 ("omit", "optional"),
346 ] {
347 assert!(super::is_valid_schema_transition(from, to));
348 }
349 assert!(!super::is_valid_schema_transition("required", "required"));
351 assert!(!super::is_valid_schema_transition("omit", "omit"));
352 assert!(!super::is_valid_schema_transition("optional", "optional"));
353 assert!(!super::is_valid_schema_transition("readonly", "omit"));
355 assert!(!super::is_valid_schema_transition("required", "invalid"));
356 }
357
358 #[test]
359 fn is_valid_version_format() {
360 assert!(is_valid_version("2026-01-23"));
361 assert!(is_valid_version("2025-12-31"));
362 assert!(!is_valid_version("2026-1-23"));
363 assert!(!is_valid_version("not-a-date"));
364 assert!(!is_valid_version("20260123"));
365 assert!(!is_valid_version(""));
366 assert!(!is_valid_version("2026-13-32"));
368 assert!(!is_valid_version("2026-00-15"));
369 assert!(!is_valid_version("2026-06-00"));
370 assert!(!is_valid_version("9999-99-99"));
371 }
372
373 #[test]
374 fn version_constraint_satisfied_by() {
375 let min_only = VersionConstraint {
376 min: "2026-01-23".into(),
377 max: None,
378 };
379 assert!(!min_only.satisfied_by("2026-01-22"));
380 assert!(min_only.satisfied_by("2026-01-23")); assert!(min_only.satisfied_by("2026-06-01"));
382 assert!(min_only.satisfied_by("2099-12-31"));
383
384 let range = VersionConstraint {
385 min: "2026-01-23".into(),
386 max: Some("2026-09-01".into()),
387 };
388 assert!(!range.satisfied_by("2026-01-22"));
389 assert!(range.satisfied_by("2026-01-23")); assert!(range.satisfied_by("2026-06-01"));
391 assert!(range.satisfied_by("2026-09-01")); assert!(!range.satisfied_by("2026-09-02"));
393
394 let exact = VersionConstraint {
396 min: "2026-06-01".into(),
397 max: Some("2026-06-01".into()),
398 };
399 assert!(!exact.satisfied_by("2026-05-31"));
400 assert!(exact.satisfied_by("2026-06-01"));
401 assert!(!exact.satisfied_by("2026-06-02"));
402 }
403
404 #[test]
405 fn version_constraint_parse_valid() {
406 use serde_json::json;
407 let vc = VersionConstraint::parse(&json!({"min": "2026-01-23"})).unwrap();
408 assert_eq!(vc.min, "2026-01-23");
409 assert_eq!(vc.max, None);
410
411 let vc =
412 VersionConstraint::parse(&json!({"min": "2026-01-23", "max": "2026-09-01"})).unwrap();
413 assert_eq!(vc.min, "2026-01-23");
414 assert_eq!(vc.max, Some("2026-09-01".into()));
415 }
416
417 #[test]
418 fn version_constraint_parse_invalid() {
419 use serde_json::json;
420 assert!(VersionConstraint::parse(&json!({"max": "2026-01-23"})).is_err()); assert!(VersionConstraint::parse(&json!({"min": "bad"})).is_err()); assert!(VersionConstraint::parse(&json!("string")).is_err()); }
424
425 #[test]
426 fn requires_parse_valid() {
427 use serde_json::json;
428 let req = Requires::parse(&json!({
429 "protocol": { "min": "2026-01-23" },
430 "capabilities": {
431 "dev.ucp.shopping.checkout": { "min": "2026-06-01" }
432 }
433 }))
434 .unwrap();
435 assert!(req.protocol.is_some());
436 assert_eq!(req.capabilities.len(), 1);
437 assert_eq!(req.capabilities[0].0, "dev.ucp.shopping.checkout");
438 }
439
440 #[test]
441 fn requires_parse_protocol_only() {
442 use serde_json::json;
443 let req = Requires::parse(&json!({
444 "protocol": { "min": "2026-01-23" }
445 }))
446 .unwrap();
447 assert!(req.protocol.is_some());
448 assert!(req.capabilities.is_empty());
449 }
450
451 #[test]
452 fn requires_parse_empty_object() {
453 use serde_json::json;
454 let req = Requires::parse(&json!({})).unwrap();
455 assert!(req.protocol.is_none());
456 assert!(req.capabilities.is_empty());
457 }
458
459 #[test]
460 fn requires_parse_invalid() {
461 use serde_json::json;
462 assert!(Requires::parse(&json!("string")).is_err());
464 assert!(Requires::parse(&json!({"protocol": {"min": "bad"}})).is_err());
466 assert!(Requires::parse(&json!({
468 "capabilities": { "x.y.z": "not-object" }
469 }))
470 .is_err());
471 }
472
473 #[test]
474 fn resolve_options_normalizes_operation() {
475 let opts = ResolveOptions::new(Direction::Request, "Create");
476 assert_eq!(opts.operation, "create");
477
478 let opts = ResolveOptions::new(Direction::Request, "UPDATE");
479 assert_eq!(opts.operation, "update");
480 }
481}