Skip to main content

ucp_schema/
compose.rs

1//! Schema composition from UCP capability metadata.
2//!
3//! UCP payloads are self-describing: they embed capability metadata that declares
4//! which schemas apply. This module extracts that metadata and composes the
5//! appropriate schema for validation.
6//!
7//! # Response Pattern
8//!
9//! Responses have `ucp.capabilities` inline:
10//! ```json
11//! {
12//!   "ucp": {
13//!     "capabilities": {
14//!       "dev.ucp.shopping.checkout": [{ "version": "...", "schema": "..." }]
15//!     }
16//!   }
17//! }
18//! ```
19//!
20//! # Request Pattern (JSONRPC)
21//!
22//! JSONRPC requests have `meta.profile` at root, with payload nested under
23//! the capability short name (last segment of the dotted capability name):
24//! ```json
25//! {
26//!   "meta": {
27//!     "profile": "https://agent.example.com/.well-known/ucp"
28//!   },
29//!   "checkout": {
30//!     "line_items": [...]
31//!   }
32//! }
33//! ```
34//!
35//! # Request Pattern (REST)
36//!
37//! REST requests pass the profile URL via HTTP header (`UCP-Agent`), with
38//! the payload being the raw checkout object. Use `--profile` CLI flag to
39//! simulate this pattern.
40
41use 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/// Configuration for mapping schema URLs to local paths.
54///
55/// When both `local_base` and `remote_base` are set, URLs starting with
56/// `remote_base` have that prefix stripped before joining with `local_base`.
57///
58/// Example:
59/// - `remote_base`: `https://ucp.dev/draft`
60/// - `local_base`: `source`
61/// - URL: `https://ucp.dev/draft/schemas/checkout.json`
62/// - Result: `source/schemas/checkout.json`
63#[derive(Debug, Clone, Default)]
64pub struct SchemaBaseConfig<'a> {
65    /// Local directory containing schema files.
66    pub local_base: Option<&'a Path>,
67    /// URL prefix to strip when mapping to local paths.
68    pub remote_base: Option<&'a str>,
69}
70
71/// Capability declaration extracted from UCP metadata.
72#[derive(Debug, Clone)]
73pub struct Capability {
74    /// Reverse-domain capability name (e.g., "dev.ucp.shopping.checkout").
75    pub name: String,
76    /// Version string (e.g., "2026-01-11").
77    pub version: String,
78    /// URL to the JSON Schema for this capability.
79    pub schema_url: String,
80    /// Parent capability names this extends. None for root capabilities.
81    pub extends: Option<Vec<String>>,
82}
83
84/// Detected payload direction based on UCP metadata structure.
85#[derive(Debug, Clone, Copy, PartialEq, Eq)]
86pub enum DetectedDirection {
87    /// Payload has `ucp.capabilities` inline (response pattern).
88    Response,
89    /// Payload has `meta.profile` at root (JSONRPC request pattern).
90    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
102/// Detect direction from payload structure.
103///
104/// Returns `Some(Response)` if `ucp.capabilities` exists,
105/// `Some(Request)` if `meta.profile` exists at root (JSONRPC pattern),
106/// `None` if neither is present.
107pub fn detect_direction(payload: &Value) -> Option<DetectedDirection> {
108    // Response pattern: ucp.capabilities
109    if let Some(ucp) = payload.get("ucp") {
110        if ucp.get("capabilities").is_some() {
111            return Some(DetectedDirection::Response);
112        }
113    }
114
115    // JSONRPC request pattern: meta.profile at root (NOT ucp.meta.profile)
116    if payload.get("meta").and_then(|m| m.get("profile")).is_some() {
117        return Some(DetectedDirection::Request);
118    }
119
120    None
121}
122
123/// Extract capabilities from a self-describing payload.
124///
125/// - Response: extracts from `ucp.capabilities` directly
126/// - JSONRPC Request: fetches `meta.profile` URL, extracts from profile
127///
128/// # Arguments
129/// * `payload` - The UCP payload to extract capabilities from
130/// * `schema_base` - Configuration for mapping schema URLs to local paths
131pub fn extract_capabilities(
132    payload: &Value,
133    schema_base: &SchemaBaseConfig,
134) -> Result<Vec<Capability>, ComposeError> {
135    // Try response pattern first: ucp.capabilities
136    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    // Try JSONRPC request pattern: meta.profile at root
143    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
154/// Extract capabilities from a profile URL.
155///
156/// Used for both JSONRPC requests (meta.profile) and REST requests (--profile flag).
157pub 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
172/// Extract the actual payload from a JSONRPC request envelope.
173///
174/// JSONRPC requests have the structure: `{meta: {...}, <capability_key>: <payload>}`
175/// The capability key is the short name (last segment) of the root capability.
176///
177/// # Arguments
178/// * `envelope` - The full JSONRPC request envelope
179/// * `capabilities` - Capabilities extracted from the profile
180///
181/// # Returns
182/// The payload value and the capability key name used
183pub fn extract_jsonrpc_payload<'a>(
184    envelope: &'a Value,
185    capabilities: &[Capability],
186) -> Result<(&'a Value, String), ComposeError> {
187    // Find root capability (no extends)
188    let root = capabilities
189        .iter()
190        .find(|c| c.extends.is_none())
191        .ok_or(ComposeError::NoRootCapability)?;
192
193    // Derive short name from capability name (last segment of dotted name)
194    let short_name = capability_short_name(&root.name);
195
196    // Extract payload from envelope using short name as key
197    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
209/// Derive short name from a capability name.
210///
211/// Takes the last segment of a dotted capability name.
212/// E.g., "dev.ucp.shopping.checkout" -> "checkout"
213pub fn capability_short_name(name: &str) -> String {
214    name.rsplit('.').next().unwrap_or(name).to_string()
215}
216
217/// Parse a capabilities object into a list of Capability structs.
218fn 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        // Each capability is an array of version entries
229        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        // Take the first entry (version negotiation already happened)
237        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        // extends can be string or array of strings
263        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
299/// Fetch a profile from a URL or local path.
300fn 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/// A version constraint violation found during composition.
308#[derive(Debug, Clone)]
309pub struct VersionViolation {
310    /// The extension that declared the constraint.
311    pub extension: String,
312    /// What was constrained ("protocol" or capability name).
313    pub target: String,
314    /// The declared constraint.
315    pub constraint: VersionConstraint,
316    /// The actual version found.
317    pub actual: String,
318}
319
320impl VersionViolation {
321    /// Format the constraint as a human-readable range string.
322    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
343/// Check an extension schema's `requires` constraints against the available versions.
344///
345/// Returns a list of violations (empty if all constraints are satisfied).
346pub 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![], // Malformed requires is a lint error, not a compose error
359    };
360
361    let mut violations = Vec::new();
362
363    // Check protocol constraint
364    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    // Check capability constraints
376    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        // If capability isn't in the list, it won't be composed — not our problem
393    }
394
395    violations
396}
397
398/// Compose schema from capability declarations.
399///
400/// 1. Finds root capability (no extends)
401/// 2. Validates graph connectivity
402/// 3. Fetches schemas and extracts $defs[root] entries
403/// 4. Composes using allOf
404pub 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    // Build name -> capability map for lookups
413    let cap_map: HashMap<&str, &Capability> =
414        capabilities.iter().map(|c| (c.name.as_str(), c)).collect();
415
416    // Find root capability (no extends)
417    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    // Validate graph: all extends references must exist in capabilities
433    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    // Validate graph connectivity: all extensions must reach root
447    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    // Get extensions (all non-root capabilities)
457    let extensions: Vec<&Capability> = capabilities
458        .iter()
459        .filter(|c| c.extends.is_some())
460        .collect();
461
462    // No extensions: the capability schema stands alone. For a single-object
463    // capability this root is the message body; for a container it is the
464    // namespace of `{op}_{direction}` shapes. The operation shape, if any, is
465    // chosen downstream by `select_operation_schema`.
466    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    // Load the root schema to classify the capability (single-object vs
476    // container) and, for a container, to seed the per-operation merge with the
477    // base's `$defs`.
478    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    // Compose: for each extension, extract its self-contained `$defs[root.name]`.
487    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        // Check version constraints: if requires is declared and violated, fail.
498        // No requires = backwards compat (composer asserts compatibility).
499        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        // Extract $defs[root.name] and inline any internal refs
511        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        // Inline internal #/$defs/... refs so the extracted def is self-contained
526        let mut inlined = ext_def.clone();
527        inline_internal_refs(&mut inlined, defs);
528
529        ext_defs.push(inlined);
530    }
531
532    // Composition follows the same single-object vs container split: a
533    // single-object body is extended once at the root; a container is extended
534    // per operation shape. Both use `allOf`, and in both the base is included
535    // because each extension re-`$ref`s it.
536    if container {
537        compose_container(&root_schema, &extensions, &ext_defs, &root.name)
538    } else {
539        Ok(json!({ "allOf": ext_defs }))
540    }
541}
542
543/// Returns true if a capability schema is "container-shaped".
544///
545/// A UCP capability schema takes one of two structural forms, and the whole
546/// compose/select pipeline branches on this distinction:
547///
548/// - **Single-object** (e.g. checkout, cart): the schema root *is* the message
549///   body. A single object serves every operation and direction; the per-op /
550///   per-direction differences are expressed with visibility annotations on
551///   that one object. The validation target is the root itself.
552/// - **Container** (e.g. catalog.search, catalog.lookup): the schema is a
553///   namespace of several distinct message bodies, each held under `$defs` and
554///   keyed `{op}_{direction}` (e.g. `search_request`, `get_product_response`).
555///   The root carries no body of its own; the body for a given operation and
556///   direction is the corresponding `$defs` entry, chosen at compose/validate
557///   time (see [`compose_container`] and `select_operation_schema`).
558///
559/// Detected structurally: a container has `$defs` but no object body at the
560/// root (no `properties`, `allOf`, or `$ref`).
561pub 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
573/// Compose a container-shaped capability with its extensions, merging per
574/// operation shape.
575///
576/// The result is a container with the same `$defs/{op}_{direction}` keys as the
577/// base; for any operation an extension touches, the shape becomes an `allOf` of
578/// the extension contributions (each of which re-`$ref`s the base shape, so base
579/// constraints are preserved). Base helper defs (e.g. `lookup_variant`) and
580/// operation shapes no extension touches are carried through unchanged.
581fn compose_container(
582    root_schema: &Value,
583    extensions: &[&Capability],
584    ext_defs: &[Value],
585    capability: &str,
586) -> Result<Value, ComposeError> {
587    // Seed with the base container's $defs (operation shapes + helper defs).
588    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    // Collect per-operation contributions in first-seen order (deterministic).
595    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        // A container extension's $defs[<capability>] must itself be a container:
600        // { "$defs": { "<op>_<direction>": <shape>, ... } } mirroring the base.
601        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    // Fold contributions into the base $defs (overwriting the base op shape; the
621    // extension re-`$ref`s the base, so its constraints come through the allOf).
622    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
640/// Inline internal `#/$defs/...` refs from the parent schema.
641///
642/// When extracting a single definition from a schema, that definition may have
643/// internal refs to other definitions in the same schema. This function
644/// recursively inlines those refs so the extracted definition is self-contained.
645///
646/// # Arguments
647/// * `value` - The value to process (modified in place)
648/// * `defs` - The `$defs` object to resolve refs against
649fn 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            // Check if this object has an internal $ref
657            if let Some(ref_val) = obj.get("$ref").and_then(|v| v.as_str()) {
658                // Only handle internal refs to $defs (not self-root "#" refs)
659                if let Some(def_name) = ref_val.strip_prefix("#/$defs/") {
660                    // Guard against circular refs
661                    if visited.contains(def_name) {
662                        return;
663                    }
664
665                    // Look up the definition
666                    if let Some(def) = defs.get(def_name) {
667                        visited.insert(def_name.to_string());
668
669                        // Clone and recursively inline
670                        let mut inlined = def.clone();
671                        inline_internal_refs_inner(&mut inlined, defs, visited);
672
673                        visited.remove(def_name);
674
675                        // Replace the $ref object with the inlined definition
676                        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            // Recurse into all values
688            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
701/// Check if a capability transitively reaches the root via extends chain.
702fn 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(&current.name.as_str()) {
708            continue;
709        }
710        visited.insert(current.name.as_str());
711
712        if let Some(parents) = &current.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
727/// Convenience: extract capabilities and compose schema in one call.
728pub 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
736/// Resolve a schema URL to a Value, bundling any $ref pointers.
737///
738/// If `schema_base.local_base` is provided, maps URL paths to local files.
739/// If `schema_base.remote_base` is also provided, strips that prefix from URLs
740/// before mapping (enables versioned URL to unversioned local path mapping).
741/// Otherwise, fetches via HTTP.
742///
743/// After loading, bundles external $ref pointers so the schema is self-contained.
744/// This is necessary because extension schemas often have relative refs like
745/// `$ref: "checkout.json"` that need resolution before composition.
746fn resolve_schema_url(url: &str, schema_base: &SchemaBaseConfig) -> Result<Value, ComposeError> {
747    if let Some(base) = schema_base.local_base {
748        // Map URL to local path
749        let path = if let Some(remote_base) = schema_base.remote_base {
750            // Strip remote_base prefix if URL starts with it
751            if let Some(remainder) = url.strip_prefix(remote_base) {
752                // remainder is like "/schemas/checkout.json"
753                remainder.to_string()
754            } else {
755                // URL doesn't match remote_base, fall back to extracting path
756                extract_url_path(url)?
757            }
758        } else {
759            // No remote_base, extract path portion of URL
760            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        // Bundle refs - use URL-aware version if remote mapping is configured
770        let schema_dir = local_path.parent().unwrap_or(base);
771        if let Some(remote_base) = schema_base.remote_base {
772            // URL mapping configured - internal refs may also be absolute URLs
773            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            // No URL mapping - use simple relative path bundling
781            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        // HTTP fetch with remote bundling
790        #[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 using the URL as base for resolving relative refs
798            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        // Treat as local file path
814        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        // Bundle refs using the schema's directory as base
821        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
832/// Extract the path portion from a URL.
833///
834/// E.g., "https://ucp.dev/schemas/shopping/checkout.json" -> "/schemas/shopping/checkout.json"
835fn extract_url_path(url: &str) -> Result<String, ComposeError> {
836    // Try stripping http:// or https:// prefix
837    let rest = url
838        .strip_prefix("https://")
839        .or_else(|| url.strip_prefix("http://"));
840
841    match rest {
842        Some(after_scheme) => {
843            // URL with scheme - extract path after host
844            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            // Not a URL, treat the whole thing as a path
854            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        // JSONRPC request: meta.profile at root (NOT ucp.meta.profile)
882        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        // Old invalid format should NOT be detected as request
896        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        // Tests diamond pattern: combo extends both discount and fulfillment
969        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        // Setup: single root capability
1034        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        // This will fail because checkout.json doesn't exist, but tests the logic path
1042        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        // Error case: fulfillment is missing its "extends" field, creating two roots
1067        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, // Bug: should extend checkout
1078        };
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        // Tests diamond extension pattern: combo extends both discount and fulfillment,
1140        // both of which extend checkout. This is a realistic UCP scenario.
1141        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        // Combo capability that extends both discount and fulfillment
1160        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        // combo -> discount -> checkout (transitive through discount)
1180        // combo -> fulfillment -> checkout (transitive through fulfillment)
1181        assert!(reaches_root(&combo, &cap_map, "dev.ucp.shopping.checkout"));
1182        // Also verify the direct extensions
1183        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        // Tests orphan detection: an extension that doesn't connect to root
1198        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 something that's not in the map and not root
1209            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        // discount extends nonexistent, which doesn't connect to checkout
1220        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    // -- compose_schema version constraint integration tests --
1278
1279    #[test]
1280    fn compose_rejects_violated_protocol_constraint() {
1281        let dir = tempfile::tempdir().unwrap();
1282
1283        // Root schema
1284        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        // Extension schema with requires.protocol that won't be satisfied
1292        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        // Extension requires checkout >= 2026-09-01 but profile has 2026-06-01
1343        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        // Extension requires checkout >= 2026-01-23, profile has 2026-06-01 — satisfied
1402        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        // No requires — backwards compat
1454        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    // -- Version constraint checking (standalone function) tests --
1489
1490    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        // Protocol constraint present but no protocol version provided — skip check
1592        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        // Constraint on a capability not in the list — no violation (not our problem)
1626        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}