1use std::collections::{HashMap, HashSet};
42use std::path::Path;
43
44use serde_json::{json, Value};
45
46use crate::error::ComposeError;
47use crate::loader::{bundle_refs, bundle_refs_with_url_mapping, is_url, load_schema};
48use crate::types::{Direction, Requires, VersionConstraint};
49
50#[cfg(feature = "remote")]
51use crate::loader::{bundle_refs_remote, load_schema_url};
52
53#[derive(Debug, Clone, Default)]
64pub struct SchemaBaseConfig<'a> {
65 pub local_base: Option<&'a Path>,
67 pub remote_base: Option<&'a str>,
69}
70
71#[derive(Debug, Clone)]
73pub struct Capability {
74 pub name: String,
76 pub version: String,
78 pub schema_url: String,
80 pub extends: Option<Vec<String>>,
82}
83
84#[derive(Debug, Clone, Copy, PartialEq, Eq)]
86pub enum DetectedDirection {
87 Response,
89 Request,
91}
92
93impl From<DetectedDirection> for Direction {
94 fn from(d: DetectedDirection) -> Self {
95 match d {
96 DetectedDirection::Response => Direction::Response,
97 DetectedDirection::Request => Direction::Request,
98 }
99 }
100}
101
102pub fn detect_direction(payload: &Value) -> Option<DetectedDirection> {
108 if let Some(ucp) = payload.get("ucp") {
110 if ucp.get("capabilities").is_some() {
111 return Some(DetectedDirection::Response);
112 }
113 }
114
115 if payload.get("meta").and_then(|m| m.get("profile")).is_some() {
117 return Some(DetectedDirection::Request);
118 }
119
120 None
121}
122
123pub fn extract_capabilities(
132 payload: &Value,
133 schema_base: &SchemaBaseConfig,
134) -> Result<Vec<Capability>, ComposeError> {
135 if let Some(ucp) = payload.get("ucp") {
137 if let Some(caps) = ucp.get("capabilities") {
138 return parse_capabilities_object(caps);
139 }
140 }
141
142 if let Some(profile_url) = payload
144 .get("meta")
145 .and_then(|m| m.get("profile"))
146 .and_then(|p| p.as_str())
147 {
148 return extract_capabilities_from_profile(profile_url, schema_base);
149 }
150
151 Err(ComposeError::NotSelfDescribing)
152}
153
154pub fn extract_capabilities_from_profile(
158 profile_url: &str,
159 schema_base: &SchemaBaseConfig,
160) -> Result<Vec<Capability>, ComposeError> {
161 let profile = fetch_profile(profile_url, schema_base)?;
162 let caps = profile
163 .get("ucp")
164 .and_then(|u| u.get("capabilities"))
165 .ok_or_else(|| ComposeError::ProfileFetch {
166 url: profile_url.to_string(),
167 message: "profile missing ucp.capabilities".to_string(),
168 })?;
169 parse_capabilities_object(caps)
170}
171
172pub fn extract_jsonrpc_payload<'a>(
184 envelope: &'a Value,
185 capabilities: &[Capability],
186) -> Result<(&'a Value, String), ComposeError> {
187 let root = capabilities
189 .iter()
190 .find(|c| c.extends.is_none())
191 .ok_or(ComposeError::NoRootCapability)?;
192
193 let short_name = capability_short_name(&root.name);
195
196 let payload = envelope
198 .get(&short_name)
199 .ok_or_else(|| ComposeError::InvalidEnvelope {
200 message: format!(
201 "JSONRPC envelope missing '{}' key (derived from capability '{}')",
202 short_name, root.name
203 ),
204 })?;
205
206 Ok((payload, short_name))
207}
208
209pub fn capability_short_name(name: &str) -> String {
214 name.rsplit('.').next().unwrap_or(name).to_string()
215}
216
217fn parse_capabilities_object(caps: &Value) -> Result<Vec<Capability>, ComposeError> {
219 let obj = caps.as_object().ok_or(ComposeError::EmptyCapabilities)?;
220
221 if obj.is_empty() {
222 return Err(ComposeError::EmptyCapabilities);
223 }
224
225 let mut capabilities = Vec::new();
226
227 for (name, versions) in obj {
228 let entries = versions
230 .as_array()
231 .ok_or_else(|| ComposeError::InvalidCapability {
232 name: name.clone(),
233 message: "expected array of capability entries".to_string(),
234 })?;
235
236 let entry = entries
238 .first()
239 .ok_or_else(|| ComposeError::InvalidCapability {
240 name: name.clone(),
241 message: "empty capability array".to_string(),
242 })?;
243
244 let version = entry
245 .get("version")
246 .and_then(|v| v.as_str())
247 .ok_or_else(|| ComposeError::InvalidCapability {
248 name: name.clone(),
249 message: "missing version field".to_string(),
250 })?
251 .to_string();
252
253 let schema_url = entry
254 .get("schema")
255 .and_then(|v| v.as_str())
256 .ok_or_else(|| ComposeError::InvalidCapability {
257 name: name.clone(),
258 message: "missing schema field".to_string(),
259 })?
260 .to_string();
261
262 let extends = match entry.get("extends") {
264 None => None,
265 Some(Value::String(s)) => Some(vec![s.clone()]),
266 Some(Value::Array(arr)) => {
267 let parents: Result<Vec<String>, _> = arr
268 .iter()
269 .map(|v| {
270 v.as_str().map(|s| s.to_string()).ok_or_else(|| {
271 ComposeError::InvalidCapability {
272 name: name.clone(),
273 message: "extends array must contain strings".to_string(),
274 }
275 })
276 })
277 .collect();
278 Some(parents?)
279 }
280 Some(_) => {
281 return Err(ComposeError::InvalidCapability {
282 name: name.clone(),
283 message: "extends must be string or array of strings".to_string(),
284 });
285 }
286 };
287
288 capabilities.push(Capability {
289 name: name.clone(),
290 version,
291 schema_url,
292 extends,
293 });
294 }
295
296 Ok(capabilities)
297}
298
299fn fetch_profile(url: &str, schema_base: &SchemaBaseConfig) -> Result<Value, ComposeError> {
301 resolve_schema_url(url, schema_base).map_err(|e| ComposeError::ProfileFetch {
302 url: url.to_string(),
303 message: e.to_string(),
304 })
305}
306
307#[derive(Debug, Clone)]
309pub struct VersionViolation {
310 pub extension: String,
312 pub target: String,
314 pub constraint: VersionConstraint,
316 pub actual: String,
318}
319
320impl VersionViolation {
321 pub fn range_display(&self) -> String {
323 match &self.constraint.max {
324 Some(max) => format!("[{}, {}]", self.constraint.min, max),
325 None => format!(">= {}", self.constraint.min),
326 }
327 }
328}
329
330impl std::fmt::Display for VersionViolation {
331 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
332 write!(
333 f,
334 "extension '{}' requires {} {} but found {}",
335 self.extension,
336 self.target,
337 self.range_display(),
338 self.actual
339 )
340 }
341}
342
343pub fn check_version_constraints(
347 extension_name: &str,
348 extension_schema: &Value,
349 protocol_version: Option<&str>,
350 capabilities: &[Capability],
351) -> Vec<VersionViolation> {
352 let Some(requires_val) = extension_schema.get("requires") else {
353 return vec![];
354 };
355
356 let requires = match Requires::parse(requires_val) {
357 Ok(r) => r,
358 Err(_) => return vec![], };
360
361 let mut violations = Vec::new();
362
363 if let (Some(ref constraint), Some(version)) = (&requires.protocol, protocol_version) {
365 if !constraint.satisfied_by(version) {
366 violations.push(VersionViolation {
367 extension: extension_name.to_string(),
368 target: "protocol".to_string(),
369 constraint: constraint.clone(),
370 actual: version.to_string(),
371 });
372 }
373 }
374
375 let cap_versions: HashMap<&str, &str> = capabilities
377 .iter()
378 .map(|c| (c.name.as_str(), c.version.as_str()))
379 .collect();
380
381 for (cap_name, constraint) in &requires.capabilities {
382 if let Some(&version) = cap_versions.get(cap_name.as_str()) {
383 if !constraint.satisfied_by(version) {
384 violations.push(VersionViolation {
385 extension: extension_name.to_string(),
386 target: cap_name.clone(),
387 constraint: constraint.clone(),
388 actual: version.to_string(),
389 });
390 }
391 }
392 }
394
395 violations
396}
397
398pub fn compose_schema(
405 capabilities: &[Capability],
406 schema_base: &SchemaBaseConfig,
407) -> Result<Value, ComposeError> {
408 if capabilities.is_empty() {
409 return Err(ComposeError::EmptyCapabilities);
410 }
411
412 let cap_map: HashMap<&str, &Capability> =
414 capabilities.iter().map(|c| (c.name.as_str(), c)).collect();
415
416 let roots: Vec<&Capability> = capabilities
418 .iter()
419 .filter(|c| c.extends.is_none())
420 .collect();
421
422 let root = match roots.len() {
423 0 => return Err(ComposeError::NoRootCapability),
424 1 => roots[0],
425 _ => {
426 return Err(ComposeError::MultipleRootCapabilities {
427 names: roots.iter().map(|c| c.name.clone()).collect(),
428 })
429 }
430 };
431
432 for cap in capabilities {
434 if let Some(parents) = &cap.extends {
435 for parent in parents {
436 if !cap_map.contains_key(parent.as_str()) {
437 return Err(ComposeError::UnknownParent {
438 extension: cap.name.clone(),
439 parent: parent.clone(),
440 });
441 }
442 }
443 }
444 }
445
446 for cap in capabilities {
448 if cap.extends.is_some() && !reaches_root(cap, &cap_map, &root.name) {
449 return Err(ComposeError::OrphanExtension {
450 extension: cap.name.clone(),
451 root: root.name.clone(),
452 });
453 }
454 }
455
456 let extensions: Vec<&Capability> = capabilities
458 .iter()
459 .filter(|c| c.extends.is_some())
460 .collect();
461
462 if extensions.is_empty() {
467 return resolve_schema_url(&root.schema_url, schema_base).map_err(|e| {
468 ComposeError::SchemaFetch {
469 url: root.schema_url.clone(),
470 message: e.to_string(),
471 }
472 });
473 }
474
475 let root_schema = resolve_schema_url(&root.schema_url, schema_base).map_err(|e| {
479 ComposeError::SchemaFetch {
480 url: root.schema_url.clone(),
481 message: e.to_string(),
482 }
483 })?;
484 let container = is_container_schema(&root_schema);
485
486 let mut ext_defs = Vec::new();
488
489 for ext in &extensions {
490 let ext_schema = resolve_schema_url(&ext.schema_url, schema_base).map_err(|e| {
491 ComposeError::SchemaFetch {
492 url: ext.schema_url.clone(),
493 message: e.to_string(),
494 }
495 })?;
496
497 let violations =
500 check_version_constraints(&ext.name, &ext_schema, Some(&root.version), capabilities);
501 if let Some(v) = violations.first() {
502 return Err(ComposeError::VersionConstraintViolation {
503 extension: v.extension.clone(),
504 target: v.target.clone(),
505 range: v.range_display(),
506 actual: v.actual.clone(),
507 });
508 }
509
510 let defs = ext_schema
512 .get("$defs")
513 .ok_or_else(|| ComposeError::MissingDefEntry {
514 extension: ext.name.clone(),
515 expected_key: root.name.clone(),
516 })?;
517
518 let ext_def = defs
519 .get(&root.name)
520 .ok_or_else(|| ComposeError::MissingDefEntry {
521 extension: ext.name.clone(),
522 expected_key: root.name.clone(),
523 })?;
524
525 let mut inlined = ext_def.clone();
527 inline_internal_refs(&mut inlined, defs);
528
529 ext_defs.push(inlined);
530 }
531
532 if container {
537 compose_container(&root_schema, &extensions, &ext_defs, &root.name)
538 } else {
539 Ok(json!({ "allOf": ext_defs }))
540 }
541}
542
543pub fn is_container_schema(schema: &Value) -> bool {
562 match schema.as_object() {
563 Some(obj) => {
564 obj.contains_key("$defs")
565 && !obj.contains_key("properties")
566 && !obj.contains_key("allOf")
567 && !obj.contains_key("$ref")
568 }
569 None => false,
570 }
571}
572
573fn compose_container(
582 root_schema: &Value,
583 extensions: &[&Capability],
584 ext_defs: &[Value],
585 capability: &str,
586) -> Result<Value, ComposeError> {
587 let mut merged_defs: serde_json::Map<String, Value> = root_schema
589 .get("$defs")
590 .and_then(|d| d.as_object())
591 .cloned()
592 .unwrap_or_default();
593
594 let mut order: Vec<String> = Vec::new();
596 let mut per_op: HashMap<String, Vec<Value>> = HashMap::new();
597
598 for (ext, inlined) in extensions.iter().zip(ext_defs.iter()) {
599 let nested = inlined
602 .get("$defs")
603 .and_then(|d| d.as_object())
604 .ok_or_else(|| ComposeError::ContainerExtensionShape {
605 extension: ext.name.clone(),
606 capability: capability.to_string(),
607 })?;
608
609 for (op_key, shape) in nested {
610 if !per_op.contains_key(op_key) {
611 order.push(op_key.clone());
612 }
613 per_op
614 .entry(op_key.clone())
615 .or_default()
616 .push(shape.clone());
617 }
618 }
619
620 for op_key in order {
623 let contribs = per_op.remove(&op_key).unwrap();
624 let merged = if contribs.len() == 1 {
625 contribs.into_iter().next().unwrap()
626 } else {
627 json!({ "allOf": contribs })
628 };
629 merged_defs.insert(op_key, merged);
630 }
631
632 let mut result = root_schema.clone();
633 result
634 .as_object_mut()
635 .expect("container root is an object")
636 .insert("$defs".to_string(), Value::Object(merged_defs));
637 Ok(result)
638}
639
640fn inline_internal_refs(value: &mut Value, defs: &Value) {
650 inline_internal_refs_inner(value, defs, &mut HashSet::new());
651}
652
653fn inline_internal_refs_inner(value: &mut Value, defs: &Value, visited: &mut HashSet<String>) {
654 match value {
655 Value::Object(obj) => {
656 if let Some(ref_val) = obj.get("$ref").and_then(|v| v.as_str()) {
658 if let Some(def_name) = ref_val.strip_prefix("#/$defs/") {
660 if visited.contains(def_name) {
662 return;
663 }
664
665 if let Some(def) = defs.get(def_name) {
667 visited.insert(def_name.to_string());
668
669 let mut inlined = def.clone();
671 inline_internal_refs_inner(&mut inlined, defs, visited);
672
673 visited.remove(def_name);
674
675 obj.remove("$ref");
677 if let Value::Object(def_obj) = inlined {
678 for (k, v) in def_obj {
679 obj.entry(k).or_insert(v);
680 }
681 }
682 return;
683 }
684 }
685 }
686
687 for v in obj.values_mut() {
689 inline_internal_refs_inner(v, defs, visited);
690 }
691 }
692 Value::Array(arr) => {
693 for item in arr {
694 inline_internal_refs_inner(item, defs, visited);
695 }
696 }
697 _ => {}
698 }
699}
700
701fn reaches_root(cap: &Capability, cap_map: &HashMap<&str, &Capability>, root_name: &str) -> bool {
703 let mut visited = HashSet::new();
704 let mut queue = vec![cap];
705
706 while let Some(current) = queue.pop() {
707 if visited.contains(¤t.name.as_str()) {
708 continue;
709 }
710 visited.insert(current.name.as_str());
711
712 if let Some(parents) = ¤t.extends {
713 for parent_name in parents {
714 if parent_name == root_name {
715 return true;
716 }
717 if let Some(parent) = cap_map.get(parent_name.as_str()) {
718 queue.push(parent);
719 }
720 }
721 }
722 }
723
724 false
725}
726
727pub fn compose_from_payload(
729 payload: &Value,
730 schema_base: &SchemaBaseConfig,
731) -> Result<Value, ComposeError> {
732 let capabilities = extract_capabilities(payload, schema_base)?;
733 compose_schema(&capabilities, schema_base)
734}
735
736fn resolve_schema_url(url: &str, schema_base: &SchemaBaseConfig) -> Result<Value, ComposeError> {
747 if let Some(base) = schema_base.local_base {
748 let path = if let Some(remote_base) = schema_base.remote_base {
750 if let Some(remainder) = url.strip_prefix(remote_base) {
752 remainder.to_string()
754 } else {
755 extract_url_path(url)?
757 }
758 } else {
759 extract_url_path(url)?
761 };
762
763 let local_path = base.join(path.trim_start_matches('/'));
764 let mut schema = load_schema(&local_path).map_err(|_| ComposeError::SchemaFetch {
765 url: url.to_string(),
766 message: format!("file not found: {}", local_path.display()),
767 })?;
768
769 let schema_dir = local_path.parent().unwrap_or(base);
771 if let Some(remote_base) = schema_base.remote_base {
772 bundle_refs_with_url_mapping(&mut schema, schema_dir, base, remote_base).map_err(
774 |e| ComposeError::SchemaFetch {
775 url: url.to_string(),
776 message: format!("bundling refs: {}", e),
777 },
778 )?;
779 } else {
780 bundle_refs(&mut schema, schema_dir).map_err(|e| ComposeError::SchemaFetch {
782 url: url.to_string(),
783 message: format!("bundling refs: {}", e),
784 })?;
785 }
786
787 Ok(schema)
788 } else if is_url(url) {
789 #[cfg(feature = "remote")]
791 {
792 let mut schema = load_schema_url(url).map_err(|e| ComposeError::SchemaFetch {
793 url: url.to_string(),
794 message: e.to_string(),
795 })?;
796
797 bundle_refs_remote(&mut schema, url).map_err(|e| ComposeError::SchemaFetch {
799 url: url.to_string(),
800 message: format!("bundling refs: {}", e),
801 })?;
802
803 Ok(schema)
804 }
805 #[cfg(not(feature = "remote"))]
806 {
807 Err(ComposeError::SchemaFetch {
808 url: url.to_string(),
809 message: "HTTP fetching requires 'remote' feature".to_string(),
810 })
811 }
812 } else {
813 let local_path = Path::new(url);
815 let mut schema = load_schema(local_path).map_err(|e| ComposeError::SchemaFetch {
816 url: url.to_string(),
817 message: e.to_string(),
818 })?;
819
820 if let Some(schema_dir) = local_path.parent() {
822 bundle_refs(&mut schema, schema_dir).map_err(|e| ComposeError::SchemaFetch {
823 url: url.to_string(),
824 message: format!("bundling refs: {}", e),
825 })?;
826 }
827
828 Ok(schema)
829 }
830}
831
832fn extract_url_path(url: &str) -> Result<String, ComposeError> {
836 let rest = url
838 .strip_prefix("https://")
839 .or_else(|| url.strip_prefix("http://"));
840
841 match rest {
842 Some(after_scheme) => {
843 after_scheme
845 .find('/')
846 .map(|idx| after_scheme[idx..].to_string())
847 .ok_or_else(|| ComposeError::InvalidUrl {
848 url: url.to_string(),
849 message: "could not extract path from URL".to_string(),
850 })
851 }
852 None => {
853 Ok(url.to_string())
855 }
856 }
857}
858
859#[cfg(test)]
860mod tests {
861 use super::*;
862 use serde_json::json;
863
864 #[test]
865 fn detect_direction_response() {
866 let payload = json!({
867 "ucp": {
868 "capabilities": {
869 "dev.ucp.shopping.checkout": [{"version": "2026-01-11", "schema": "..."}]
870 }
871 }
872 });
873 assert_eq!(
874 detect_direction(&payload),
875 Some(DetectedDirection::Response)
876 );
877 }
878
879 #[test]
880 fn detect_direction_request() {
881 let payload = json!({
883 "meta": {
884 "profile": "https://example.com/.well-known/ucp"
885 },
886 "checkout": {
887 "line_items": []
888 }
889 });
890 assert_eq!(detect_direction(&payload), Some(DetectedDirection::Request));
891 }
892
893 #[test]
894 fn detect_direction_old_request_format_not_detected() {
895 let payload = json!({
897 "ucp": {
898 "meta": {
899 "profile": "https://example.com/.well-known/ucp"
900 }
901 }
902 });
903 assert_eq!(detect_direction(&payload), None);
904 }
905
906 #[test]
907 fn detect_direction_neither() {
908 let payload = json!({
909 "ucp": {
910 "version": "2026-01-11"
911 }
912 });
913 assert_eq!(detect_direction(&payload), None);
914 }
915
916 #[test]
917 fn detect_direction_no_ucp() {
918 let payload = json!({
919 "id": "123",
920 "status": "incomplete"
921 });
922 assert_eq!(detect_direction(&payload), None);
923 }
924
925 #[test]
926 fn parse_capabilities_single_root() {
927 let caps = json!({
928 "dev.ucp.shopping.checkout": [{
929 "version": "2026-01-11",
930 "schema": "https://ucp.dev/schemas/shopping/checkout.json"
931 }]
932 });
933 let result = parse_capabilities_object(&caps).unwrap();
934 assert_eq!(result.len(), 1);
935 assert_eq!(result[0].name, "dev.ucp.shopping.checkout");
936 assert_eq!(result[0].version, "2026-01-11");
937 assert!(result[0].extends.is_none());
938 }
939
940 #[test]
941 fn parse_capabilities_with_extension() {
942 let caps = json!({
943 "dev.ucp.shopping.checkout": [{
944 "version": "2026-01-11",
945 "schema": "https://ucp.dev/schemas/shopping/checkout.json"
946 }],
947 "dev.ucp.shopping.discount": [{
948 "version": "2026-01-11",
949 "schema": "https://ucp.dev/schemas/shopping/discount.json",
950 "extends": "dev.ucp.shopping.checkout"
951 }]
952 });
953 let result = parse_capabilities_object(&caps).unwrap();
954 assert_eq!(result.len(), 2);
955
956 let discount = result
957 .iter()
958 .find(|c| c.name == "dev.ucp.shopping.discount")
959 .unwrap();
960 assert_eq!(
961 discount.extends,
962 Some(vec!["dev.ucp.shopping.checkout".to_string()])
963 );
964 }
965
966 #[test]
967 fn parse_capabilities_multi_parent() {
968 let caps = json!({
970 "dev.ucp.shopping.checkout": [{
971 "version": "2026-01-11",
972 "schema": "https://ucp.dev/schemas/shopping/checkout.json"
973 }],
974 "dev.ucp.shopping.discount": [{
975 "version": "2026-01-11",
976 "schema": "https://ucp.dev/schemas/shopping/discount.json",
977 "extends": "dev.ucp.shopping.checkout"
978 }],
979 "dev.ucp.shopping.fulfillment": [{
980 "version": "2026-01-11",
981 "schema": "https://ucp.dev/schemas/shopping/fulfillment.json",
982 "extends": "dev.ucp.shopping.checkout"
983 }],
984 "dev.ucp.shopping.combo": [{
985 "version": "2026-01-11",
986 "schema": "https://ucp.dev/schemas/shopping/combo.json",
987 "extends": ["dev.ucp.shopping.discount", "dev.ucp.shopping.fulfillment"]
988 }]
989 });
990 let result = parse_capabilities_object(&caps).unwrap();
991 assert_eq!(result.len(), 4);
992
993 let combo = result
994 .iter()
995 .find(|c| c.name == "dev.ucp.shopping.combo")
996 .unwrap();
997 assert_eq!(
998 combo.extends,
999 Some(vec![
1000 "dev.ucp.shopping.discount".to_string(),
1001 "dev.ucp.shopping.fulfillment".to_string()
1002 ])
1003 );
1004 }
1005
1006 #[test]
1007 fn parse_capabilities_empty() {
1008 let caps = json!({});
1009 let result = parse_capabilities_object(&caps);
1010 assert!(matches!(result, Err(ComposeError::EmptyCapabilities)));
1011 }
1012
1013 #[test]
1014 fn extract_url_path_https() {
1015 let path = extract_url_path("https://ucp.dev/schemas/shopping/checkout.json").unwrap();
1016 assert_eq!(path, "/schemas/shopping/checkout.json");
1017 }
1018
1019 #[test]
1020 fn extract_url_path_http() {
1021 let path = extract_url_path("http://localhost:8080/schemas/test.json").unwrap();
1022 assert_eq!(path, "/schemas/test.json");
1023 }
1024
1025 #[test]
1026 fn extract_url_path_local() {
1027 let path = extract_url_path("./schemas/checkout.json").unwrap();
1028 assert_eq!(path, "./schemas/checkout.json");
1029 }
1030
1031 #[test]
1032 fn compose_no_extensions() {
1033 let checkout = Capability {
1035 name: "dev.ucp.shopping.checkout".to_string(),
1036 version: "2026-01-11".to_string(),
1037 schema_url: "checkout.json".to_string(),
1038 extends: None,
1039 };
1040
1041 let config = SchemaBaseConfig {
1043 local_base: Some(Path::new("/nonexistent")),
1044 remote_base: None,
1045 };
1046 let result = compose_schema(&[checkout], &config);
1047 assert!(matches!(result, Err(ComposeError::SchemaFetch { .. })));
1048 }
1049
1050 #[test]
1051 fn compose_no_root_error() {
1052 let discount = Capability {
1053 name: "dev.ucp.shopping.discount".to_string(),
1054 version: "2026-01-11".to_string(),
1055 schema_url: "discount.json".to_string(),
1056 extends: Some(vec!["dev.ucp.shopping.checkout".to_string()]),
1057 };
1058
1059 let config = SchemaBaseConfig::default();
1060 let result = compose_schema(&[discount], &config);
1061 assert!(matches!(result, Err(ComposeError::NoRootCapability)));
1062 }
1063
1064 #[test]
1065 fn compose_multiple_roots_error() {
1066 let checkout = Capability {
1068 name: "dev.ucp.shopping.checkout".to_string(),
1069 version: "2026-01-11".to_string(),
1070 schema_url: "checkout.json".to_string(),
1071 extends: None,
1072 };
1073 let fulfillment = Capability {
1074 name: "dev.ucp.shopping.fulfillment".to_string(),
1075 version: "2026-01-11".to_string(),
1076 schema_url: "fulfillment.json".to_string(),
1077 extends: None, };
1079
1080 let config = SchemaBaseConfig::default();
1081 let result = compose_schema(&[checkout, fulfillment], &config);
1082 assert!(matches!(
1083 result,
1084 Err(ComposeError::MultipleRootCapabilities { .. })
1085 ));
1086 }
1087
1088 #[test]
1089 fn compose_unknown_parent_error() {
1090 let checkout = Capability {
1091 name: "dev.ucp.shopping.checkout".to_string(),
1092 version: "2026-01-11".to_string(),
1093 schema_url: "checkout.json".to_string(),
1094 extends: None,
1095 };
1096 let discount = Capability {
1097 name: "dev.ucp.shopping.discount".to_string(),
1098 version: "2026-01-11".to_string(),
1099 schema_url: "discount.json".to_string(),
1100 extends: Some(vec!["dev.ucp.shopping.nonexistent".to_string()]),
1101 };
1102
1103 let config = SchemaBaseConfig::default();
1104 let result = compose_schema(&[checkout, discount], &config);
1105 assert!(matches!(result, Err(ComposeError::UnknownParent { .. })));
1106 }
1107
1108 #[test]
1109 fn reaches_root_direct() {
1110 let checkout = Capability {
1111 name: "dev.ucp.shopping.checkout".to_string(),
1112 version: "2026-01-11".to_string(),
1113 schema_url: "checkout.json".to_string(),
1114 extends: None,
1115 };
1116 let discount = Capability {
1117 name: "dev.ucp.shopping.discount".to_string(),
1118 version: "2026-01-11".to_string(),
1119 schema_url: "discount.json".to_string(),
1120 extends: Some(vec!["dev.ucp.shopping.checkout".to_string()]),
1121 };
1122
1123 let cap_map: HashMap<&str, &Capability> = vec![
1124 ("dev.ucp.shopping.checkout", &checkout),
1125 ("dev.ucp.shopping.discount", &discount),
1126 ]
1127 .into_iter()
1128 .collect();
1129
1130 assert!(reaches_root(
1131 &discount,
1132 &cap_map,
1133 "dev.ucp.shopping.checkout"
1134 ));
1135 }
1136
1137 #[test]
1138 fn reaches_root_transitive_diamond() {
1139 let checkout = Capability {
1142 name: "dev.ucp.shopping.checkout".to_string(),
1143 version: "2026-01-11".to_string(),
1144 schema_url: "checkout.json".to_string(),
1145 extends: None,
1146 };
1147 let discount = Capability {
1148 name: "dev.ucp.shopping.discount".to_string(),
1149 version: "2026-01-11".to_string(),
1150 schema_url: "discount.json".to_string(),
1151 extends: Some(vec!["dev.ucp.shopping.checkout".to_string()]),
1152 };
1153 let fulfillment = Capability {
1154 name: "dev.ucp.shopping.fulfillment".to_string(),
1155 version: "2026-01-11".to_string(),
1156 schema_url: "fulfillment.json".to_string(),
1157 extends: Some(vec!["dev.ucp.shopping.checkout".to_string()]),
1158 };
1159 let combo = Capability {
1161 name: "dev.ucp.shopping.combo".to_string(),
1162 version: "2026-01-11".to_string(),
1163 schema_url: "combo.json".to_string(),
1164 extends: Some(vec![
1165 "dev.ucp.shopping.discount".to_string(),
1166 "dev.ucp.shopping.fulfillment".to_string(),
1167 ]),
1168 };
1169
1170 let cap_map: HashMap<&str, &Capability> = vec![
1171 ("dev.ucp.shopping.checkout", &checkout),
1172 ("dev.ucp.shopping.discount", &discount),
1173 ("dev.ucp.shopping.fulfillment", &fulfillment),
1174 ("dev.ucp.shopping.combo", &combo),
1175 ]
1176 .into_iter()
1177 .collect();
1178
1179 assert!(reaches_root(&combo, &cap_map, "dev.ucp.shopping.checkout"));
1182 assert!(reaches_root(
1184 &discount,
1185 &cap_map,
1186 "dev.ucp.shopping.checkout"
1187 ));
1188 assert!(reaches_root(
1189 &fulfillment,
1190 &cap_map,
1191 "dev.ucp.shopping.checkout"
1192 ));
1193 }
1194
1195 #[test]
1196 fn reaches_root_orphan() {
1197 let checkout = Capability {
1199 name: "dev.ucp.shopping.checkout".to_string(),
1200 version: "2026-01-11".to_string(),
1201 schema_url: "checkout.json".to_string(),
1202 extends: None,
1203 };
1204 let discount = Capability {
1205 name: "dev.ucp.shopping.discount".to_string(),
1206 version: "2026-01-11".to_string(),
1207 schema_url: "discount.json".to_string(),
1208 extends: Some(vec!["dev.ucp.shopping.nonexistent".to_string()]),
1210 };
1211
1212 let cap_map: HashMap<&str, &Capability> = vec![
1213 ("dev.ucp.shopping.checkout", &checkout),
1214 ("dev.ucp.shopping.discount", &discount),
1215 ]
1216 .into_iter()
1217 .collect();
1218
1219 assert!(!reaches_root(
1221 &discount,
1222 &cap_map,
1223 "dev.ucp.shopping.checkout"
1224 ));
1225 }
1226
1227 #[test]
1228 fn capability_short_name_extracts_last_segment() {
1229 assert_eq!(
1230 capability_short_name("dev.ucp.shopping.checkout"),
1231 "checkout"
1232 );
1233 assert_eq!(
1234 capability_short_name("dev.ucp.shopping.discount"),
1235 "discount"
1236 );
1237 assert_eq!(capability_short_name("checkout"), "checkout");
1238 }
1239
1240 #[test]
1241 fn extract_jsonrpc_payload_finds_checkout_key() {
1242 let envelope = json!({
1243 "meta": {"profile": "https://example.com/profile"},
1244 "checkout": {"line_items": [{"item": {"id": "sku"}, "quantity": 2}]}
1245 });
1246
1247 let capabilities = vec![Capability {
1248 name: "dev.ucp.shopping.checkout".to_string(),
1249 version: "2026-01-26".to_string(),
1250 schema_url: "https://example.com/checkout.json".to_string(),
1251 extends: None,
1252 }];
1253
1254 let (payload, key) = extract_jsonrpc_payload(&envelope, &capabilities).unwrap();
1255 assert_eq!(key, "checkout");
1256 assert_eq!(payload["line_items"][0]["quantity"], 2);
1257 }
1258
1259 #[test]
1260 fn extract_jsonrpc_payload_missing_key_errors() {
1261 let envelope = json!({
1262 "meta": {"profile": "https://example.com/profile"},
1263 "wrong_key": {"line_items": []}
1264 });
1265
1266 let capabilities = vec![Capability {
1267 name: "dev.ucp.shopping.checkout".to_string(),
1268 version: "2026-01-26".to_string(),
1269 schema_url: "https://example.com/checkout.json".to_string(),
1270 extends: None,
1271 }];
1272
1273 let result = extract_jsonrpc_payload(&envelope, &capabilities);
1274 assert!(matches!(result, Err(ComposeError::InvalidEnvelope { .. })));
1275 }
1276
1277 #[test]
1280 fn compose_rejects_violated_protocol_constraint() {
1281 let dir = tempfile::tempdir().unwrap();
1282
1283 let checkout_path = dir.path().join("checkout.json");
1285 std::fs::write(
1286 &checkout_path,
1287 r#"{"type": "object", "properties": {"id": {"type": "string"}}}"#,
1288 )
1289 .unwrap();
1290
1291 let ext_path = dir.path().join("loyalty.json");
1293 std::fs::write(
1294 &ext_path,
1295 r#"{
1296 "requires": { "protocol": { "min": "2026-09-01" } },
1297 "$defs": {
1298 "dev.ucp.shopping.checkout": {
1299 "type": "object",
1300 "properties": { "loyalty": { "type": "integer" } }
1301 }
1302 }
1303 }"#,
1304 )
1305 .unwrap();
1306
1307 let capabilities = vec![
1308 Capability {
1309 name: "dev.ucp.shopping.checkout".to_string(),
1310 version: "2026-06-01".to_string(),
1311 schema_url: checkout_path.to_str().unwrap().to_string(),
1312 extends: None,
1313 },
1314 Capability {
1315 name: "com.acme.loyalty".to_string(),
1316 version: "2026-01-01".to_string(),
1317 schema_url: ext_path.to_str().unwrap().to_string(),
1318 extends: Some(vec!["dev.ucp.shopping.checkout".to_string()]),
1319 },
1320 ];
1321
1322 let config = SchemaBaseConfig::default();
1323 let result = compose_schema(&capabilities, &config);
1324 assert!(
1325 matches!(result, Err(ComposeError::VersionConstraintViolation { .. })),
1326 "expected VersionConstraintViolation, got {:?}",
1327 result
1328 );
1329 }
1330
1331 #[test]
1332 fn compose_rejects_violated_capability_constraint() {
1333 let dir = tempfile::tempdir().unwrap();
1334
1335 let checkout_path = dir.path().join("checkout.json");
1336 std::fs::write(
1337 &checkout_path,
1338 r#"{"type": "object", "properties": {"id": {"type": "string"}}}"#,
1339 )
1340 .unwrap();
1341
1342 let ext_path = dir.path().join("loyalty.json");
1344 std::fs::write(
1345 &ext_path,
1346 r#"{
1347 "requires": {
1348 "capabilities": {
1349 "dev.ucp.shopping.checkout": { "min": "2026-09-01" }
1350 }
1351 },
1352 "$defs": {
1353 "dev.ucp.shopping.checkout": {
1354 "type": "object",
1355 "properties": { "loyalty": { "type": "integer" } }
1356 }
1357 }
1358 }"#,
1359 )
1360 .unwrap();
1361
1362 let capabilities = vec![
1363 Capability {
1364 name: "dev.ucp.shopping.checkout".to_string(),
1365 version: "2026-06-01".to_string(),
1366 schema_url: checkout_path.to_str().unwrap().to_string(),
1367 extends: None,
1368 },
1369 Capability {
1370 name: "com.acme.loyalty".to_string(),
1371 version: "2026-01-01".to_string(),
1372 schema_url: ext_path.to_str().unwrap().to_string(),
1373 extends: Some(vec!["dev.ucp.shopping.checkout".to_string()]),
1374 },
1375 ];
1376
1377 let config = SchemaBaseConfig::default();
1378 let result = compose_schema(&capabilities, &config);
1379 match &result {
1380 Err(ComposeError::VersionConstraintViolation {
1381 extension, target, ..
1382 }) => {
1383 assert_eq!(extension, "com.acme.loyalty");
1384 assert_eq!(target, "dev.ucp.shopping.checkout");
1385 }
1386 other => panic!("expected VersionConstraintViolation, got {:?}", other),
1387 }
1388 }
1389
1390 #[test]
1391 fn compose_succeeds_when_constraints_satisfied() {
1392 let dir = tempfile::tempdir().unwrap();
1393
1394 let checkout_path = dir.path().join("checkout.json");
1395 std::fs::write(
1396 &checkout_path,
1397 r#"{"type": "object", "properties": {"id": {"type": "string"}}}"#,
1398 )
1399 .unwrap();
1400
1401 let ext_path = dir.path().join("loyalty.json");
1403 std::fs::write(
1404 &ext_path,
1405 r#"{
1406 "requires": {
1407 "protocol": { "min": "2026-01-23" },
1408 "capabilities": {
1409 "dev.ucp.shopping.checkout": { "min": "2026-01-23" }
1410 }
1411 },
1412 "$defs": {
1413 "dev.ucp.shopping.checkout": {
1414 "type": "object",
1415 "properties": { "loyalty": { "type": "integer" } }
1416 }
1417 }
1418 }"#,
1419 )
1420 .unwrap();
1421
1422 let capabilities = vec![
1423 Capability {
1424 name: "dev.ucp.shopping.checkout".to_string(),
1425 version: "2026-06-01".to_string(),
1426 schema_url: checkout_path.to_str().unwrap().to_string(),
1427 extends: None,
1428 },
1429 Capability {
1430 name: "com.acme.loyalty".to_string(),
1431 version: "2026-01-01".to_string(),
1432 schema_url: ext_path.to_str().unwrap().to_string(),
1433 extends: Some(vec!["dev.ucp.shopping.checkout".to_string()]),
1434 },
1435 ];
1436
1437 let config = SchemaBaseConfig::default();
1438 let result = compose_schema(&capabilities, &config);
1439 assert!(result.is_ok(), "expected Ok, got {:?}", result);
1440 }
1441
1442 #[test]
1443 fn compose_succeeds_without_requires() {
1444 let dir = tempfile::tempdir().unwrap();
1445
1446 let checkout_path = dir.path().join("checkout.json");
1447 std::fs::write(
1448 &checkout_path,
1449 r#"{"type": "object", "properties": {"id": {"type": "string"}}}"#,
1450 )
1451 .unwrap();
1452
1453 let ext_path = dir.path().join("discount.json");
1455 std::fs::write(
1456 &ext_path,
1457 r#"{
1458 "$defs": {
1459 "dev.ucp.shopping.checkout": {
1460 "type": "object",
1461 "properties": { "discounts": { "type": "array" } }
1462 }
1463 }
1464 }"#,
1465 )
1466 .unwrap();
1467
1468 let capabilities = vec![
1469 Capability {
1470 name: "dev.ucp.shopping.checkout".to_string(),
1471 version: "2026-06-01".to_string(),
1472 schema_url: checkout_path.to_str().unwrap().to_string(),
1473 extends: None,
1474 },
1475 Capability {
1476 name: "dev.ucp.shopping.discount".to_string(),
1477 version: "2026-06-01".to_string(),
1478 schema_url: ext_path.to_str().unwrap().to_string(),
1479 extends: Some(vec!["dev.ucp.shopping.checkout".to_string()]),
1480 },
1481 ];
1482
1483 let config = SchemaBaseConfig::default();
1484 let result = compose_schema(&capabilities, &config);
1485 assert!(result.is_ok(), "expected Ok, got {:?}", result);
1486 }
1487
1488 fn make_capabilities() -> Vec<Capability> {
1491 vec![
1492 Capability {
1493 name: "dev.ucp.shopping.checkout".to_string(),
1494 version: "2026-06-01".to_string(),
1495 schema_url: "https://example.com/checkout.json".to_string(),
1496 extends: None,
1497 },
1498 Capability {
1499 name: "dev.ucp.shopping.fulfillment".to_string(),
1500 version: "2026-03-01".to_string(),
1501 schema_url: "https://example.com/fulfillment.json".to_string(),
1502 extends: Some(vec!["dev.ucp.shopping.checkout".to_string()]),
1503 },
1504 ]
1505 }
1506
1507 #[test]
1508 fn version_constraints_satisfied() {
1509 let caps = make_capabilities();
1510 let schema = json!({
1511 "requires": {
1512 "protocol": { "min": "2026-01-23" },
1513 "capabilities": {
1514 "dev.ucp.shopping.checkout": { "min": "2026-01-23" }
1515 }
1516 }
1517 });
1518
1519 let violations =
1520 check_version_constraints("com.acme.loyalty", &schema, Some("2026-06-01"), &caps);
1521 assert!(violations.is_empty());
1522 }
1523
1524 #[test]
1525 fn version_constraints_protocol_violation() {
1526 let caps = make_capabilities();
1527 let schema = json!({
1528 "requires": {
1529 "protocol": { "min": "2026-09-01" }
1530 }
1531 });
1532
1533 let violations =
1534 check_version_constraints("com.acme.loyalty", &schema, Some("2026-06-01"), &caps);
1535 assert_eq!(violations.len(), 1);
1536 assert_eq!(violations[0].target, "protocol");
1537 }
1538
1539 #[test]
1540 fn version_constraints_capability_violation() {
1541 let caps = make_capabilities();
1542 let schema = json!({
1543 "requires": {
1544 "capabilities": {
1545 "dev.ucp.shopping.checkout": { "min": "2026-09-01" }
1546 }
1547 }
1548 });
1549
1550 let violations =
1551 check_version_constraints("com.acme.loyalty", &schema, Some("2026-06-01"), &caps);
1552 assert_eq!(violations.len(), 1);
1553 assert_eq!(violations[0].target, "dev.ucp.shopping.checkout");
1554 assert_eq!(violations[0].actual, "2026-06-01");
1555 }
1556
1557 #[test]
1558 fn version_constraints_max_exceeded() {
1559 let caps = make_capabilities();
1560 let schema = json!({
1561 "requires": {
1562 "capabilities": {
1563 "dev.ucp.shopping.checkout": {
1564 "min": "2026-01-23",
1565 "max": "2026-03-01"
1566 }
1567 }
1568 }
1569 });
1570
1571 let violations =
1572 check_version_constraints("com.acme.loyalty", &schema, Some("2026-06-01"), &caps);
1573 assert_eq!(violations.len(), 1);
1574 assert!(violations[0]
1575 .to_string()
1576 .contains("[2026-01-23, 2026-03-01]"));
1577 }
1578
1579 #[test]
1580 fn version_constraints_no_requires() {
1581 let caps = make_capabilities();
1582 let schema = json!({ "type": "object" });
1583
1584 let violations =
1585 check_version_constraints("com.acme.loyalty", &schema, Some("2026-06-01"), &caps);
1586 assert!(violations.is_empty());
1587 }
1588
1589 #[test]
1590 fn version_constraints_no_protocol_version() {
1591 let caps = make_capabilities();
1593 let schema = json!({
1594 "requires": {
1595 "protocol": { "min": "2026-09-01" }
1596 }
1597 });
1598
1599 let violations = check_version_constraints("com.acme.loyalty", &schema, None, &caps);
1600 assert!(violations.is_empty());
1601 }
1602
1603 #[test]
1604 fn version_constraints_multiple_violations() {
1605 let caps = make_capabilities();
1606 let schema = json!({
1607 "requires": {
1608 "protocol": { "min": "2026-09-01" },
1609 "capabilities": {
1610 "dev.ucp.shopping.checkout": { "min": "2026-09-01" }
1611 }
1612 }
1613 });
1614
1615 let violations =
1616 check_version_constraints("com.acme.loyalty", &schema, Some("2026-06-01"), &caps);
1617 assert_eq!(violations.len(), 2);
1618 let targets: Vec<&str> = violations.iter().map(|v| v.target.as_str()).collect();
1619 assert!(targets.contains(&"protocol"));
1620 assert!(targets.contains(&"dev.ucp.shopping.checkout"));
1621 }
1622
1623 #[test]
1624 fn version_constraints_unknown_capability() {
1625 let caps = make_capabilities();
1627 let schema = json!({
1628 "requires": {
1629 "capabilities": {
1630 "dev.ucp.shopping.order": { "min": "2026-01-23" }
1631 }
1632 }
1633 });
1634
1635 let violations =
1636 check_version_constraints("com.acme.loyalty", &schema, Some("2026-06-01"), &caps);
1637 assert!(violations.is_empty());
1638 }
1639}