diff --git a/src/bin/ucp-schema.rs b/src/bin/ucp-schema.rs index 4ff50ed..673905e 100644 --- a/src/bin/ucp-schema.rs +++ b/src/bin/ucp-schema.rs @@ -9,9 +9,10 @@ use clap::{Parser, Subcommand}; use ucp_schema::{ bundle_refs, bundle_refs_with_url_mapping, compose_from_payload, compose_schema, detect_direction, extract_capabilities, extract_capabilities_from_profile, - extract_jsonrpc_payload, is_url, lint, load_schema, load_schema_auto, resolve, - select_operation_schema, validate, ComposeError, DetectedDirection, Direction, FileStatus, - ResolveError, ResolveOptions, SchemaBaseConfig, ValidateError, + extract_jsonrpc_payload, generate_types, is_url, lint, load_schema, load_schema_auto, resolve, + select_operation_schema, validate, CodegenError, ComposeError, DetectedDirection, Direction, + FileStatus, GenerateTypesOptions, ResolveError, ResolveOptions, SchemaBaseConfig, + ValidateError, }; /// Errors with associated CLI exit codes. @@ -31,6 +32,12 @@ impl CliExitCode for ComposeError { } } +impl CliExitCode for CodegenError { + fn exit_code(&self) -> u8 { + CodegenError::exit_code(self) as u8 + } +} + /// Map an error to a CLI exit code, reporting it in the configured format. fn cli_err(json_output: bool) -> impl FnOnce(E) -> u8 { move |e| { @@ -237,6 +244,50 @@ enum Commands { #[arg(long, short)] quiet: bool, }, + + /// Generate a self-contained JSON Schema 2020-12 type bundle ($defs) from a UCP profile or capability set + #[command(name = "generate-types")] + GenerateTypes { + /// Path or URL to a UCP discovery profile (e.g. profile.json or https://allbirds.com/.well-known/ucp) + #[arg(long, short = 'p')] + profile: Option, + + /// Explicit capability FQDN to include in flag mode (repeatable or comma-separated, e.g. dev.ucp.shopping.checkout) + #[arg( + long = "capability", + alias = "capabilities", + value_delimiter = ',', + conflicts_with = "profile" + )] + capabilities: Vec, + + /// Explicit capability extension FQDN to include and compose in flag mode (repeatable or comma-separated, e.g. dev.ucp.shopping.fulfillment) + #[arg( + long = "extension", + alias = "extensions", + value_delimiter = ',', + conflicts_with = "profile" + )] + extensions: Vec, + + /// Local directory containing UCP JSON schema files (required in flag mode without --profile; optional local URL-mapping override when --profile is provided) + #[arg(long, short = 's', alias = "schema-local-base")] + schema_dir: Option, + + /// Output file path (emits JSON to stdout if omitted) + #[arg(long, short = 'o')] + output: Option, + + /// Pretty-print JSON output (defaults to true; use --pretty=false for compact) + #[arg( + long, + default_value_t = true, + num_args = 0..=1, + default_missing_value = "true", + action = clap::ArgAction::Set + )] + pretty: bool, + }, } fn main() -> ExitCode { @@ -323,6 +374,22 @@ fn main() -> ExitCode { strict, quiet, } => run_lint(&path, &format, strict, quiet), + + Commands::GenerateTypes { + profile, + capabilities, + extensions, + schema_dir, + output, + pretty, + } => run_generate_types( + profile, + capabilities, + extensions, + schema_dir, + output, + pretty, + ), }; match result { @@ -715,9 +782,28 @@ fn run_validate(args: ValidateArgs) -> Result<(), u8> { } } +fn run_generate_types( + profile: Option, + capabilities: Vec, + extensions: Vec, + schema_dir: Option, + output: Option, + pretty: bool, +) -> Result<(), u8> { + let options = GenerateTypesOptions { + profile, + capabilities: (!capabilities.is_empty()).then_some(capabilities), + extensions: (!extensions.is_empty()).then_some(extensions), + schema_dir, + ..GenerateTypesOptions::default() + }; + let bundle = generate_types(&options).map_err(cli_err(false))?; + write_json_output(&bundle, output, pretty) +} + /// Shared helper: serialize JSON and write to output or stdout. -fn write_json_output( - value: &serde_json::Value, +fn write_json_output( + value: &T, output: Option, pretty: bool, ) -> Result<(), u8> { diff --git a/src/codegen/compose.rs b/src/codegen/compose.rs new file mode 100644 index 0000000..c4c6f8c --- /dev/null +++ b/src/codegen/compose.rs @@ -0,0 +1,704 @@ +//! Stage 3: Capability, sub-type overlay, and container extension composition. + +use std::collections::{BTreeMap, BTreeSet}; +use std::path::PathBuf; + +use serde_json::{Map, Value}; + +use crate::codegen::normalizer::{ + is_reverse_domain_name, normalize_def_schema, qualify_container_op_name, qualify_def_name, + rewrite_refs_to_defs, +}; +use crate::codegen::reachability::{is_container_op_key, LoadedSchema}; +use crate::codegen::{collision_qualified_name, local_def_ref, CodegenError, SchemaMap}; +use crate::resolver::resolve; +use crate::types::{Direction, ResolveOptions}; + +pub(super) fn compose_active_extensions( + loaded: &[LoadedSchema], + active_cap_indices: &BTreeSet, + active_ext_indices: &BTreeSet, + pending_overlays: Vec<(String, Value)>, + root_raw_schemas: &mut SchemaMap, + defs: &mut SchemaMap, + inlined_mixin_defs: &mut BTreeSet, +) -> Result<(SchemaMap, Vec<(PathBuf, Value)>), CodegenError> { + for (target_name, overlay) in pending_overlays { + modify_target_def( + &target_name, + root_raw_schemas, + defs, + |target, roots, defs| { + merge_extension_into_schema(target, &overlay, &target_name, roots, defs); + }, + ); + } + + for &cap_idx in active_cap_indices { + let cap_item = &loaded[cap_idx]; + if cap_item.is_container { + continue; + } + let (Some(cap_name), Some(mut target_schema)) = ( + cap_item.name.as_deref(), + root_raw_schemas.remove(&cap_item.stem_pascal), + ) else { + continue; + }; + + for &ext_idx in active_ext_indices { + let ext_item = &loaded[ext_idx]; + let Some(ext_block) = ext_item + .schema + .get("$defs") + .and_then(Value::as_object) + .and_then(|d| d.get(cap_name)) + else { + continue; + }; + let mut prepared = deref_local_mixin_refs( + ext_block, + &ext_item.schema, + &ext_item.stem_pascal, + inlined_mixin_defs, + ); + rewrite_refs_to_defs( + &mut prepared, + &cap_item.stem_pascal, + Some(&ext_item.stem_pascal), + ); + merge_extension_into_schema( + &mut target_schema, + &prepared, + &cap_item.stem_pascal, + root_raw_schemas, + defs, + ); + } + + root_raw_schemas.insert(cap_item.stem_pascal.clone(), target_schema); + } + + let capability_resources = active_cap_indices + .iter() + .filter_map(|&idx| { + let name = &loaded[idx].stem_pascal; + root_raw_schemas + .get(name) + .map(|val| (name.clone(), val.clone())) + }) + .collect(); + + let mut container_schemas = Vec::new(); + for &cap_idx in active_cap_indices { + let cap_item = &loaded[cap_idx]; + if !cap_item.is_container { + continue; + } + let composed = compose_container_capability( + cap_item, + active_ext_indices, + loaded, + root_raw_schemas, + defs, + inlined_mixin_defs, + )?; + hoist_container_operations(cap_item, &composed, defs)?; + container_schemas.push((cap_item.path.clone(), composed)); + } + + Ok((capability_resources, container_schemas)) +} + +fn modify_target_def( + name: &str, + root_raw_schemas: &mut BTreeMap, + defs: &mut BTreeMap, + f: impl FnOnce(&mut Value, &mut BTreeMap, &mut BTreeMap), +) -> bool { + if let Some(mut schema) = root_raw_schemas.remove(name) { + f(&mut schema, root_raw_schemas, defs); + root_raw_schemas.insert(name.to_string(), schema); + return true; + } + if let Some(mut schema) = defs.remove(name) { + f(&mut schema, root_raw_schemas, defs); + defs.insert(name.to_string(), schema); + return true; + } + false +} + +fn deref_local_mixin_refs( + block: &Value, + source_schema: &Value, + parent_pascal: &str, + inlined_mixin_defs: &mut BTreeSet, +) -> Value { + let mut current = block.clone(); + let Some(defs_obj) = source_schema.get("$defs").and_then(Value::as_object) else { + return current; + }; + let mut inline_one = |node: &mut Value| { + let Some(ref_key) = local_def_ref(node) else { + return; + }; + if let Some(target) = defs_obj.get(ref_key) { + inlined_mixin_defs.insert(qualify_def_name(parent_pascal, ref_key)); + *node = target.clone(); + } else if let Some(target) = defs_obj.iter().find_map(|(raw_key, val)| { + (!is_reverse_domain_name(raw_key) + && collision_qualified_name(parent_pascal, raw_key) == ref_key) + .then_some(val) + }) { + inlined_mixin_defs.insert(ref_key.to_string()); + *node = target.clone(); + } + }; + inline_one(&mut current); + if let Some(arr) = current.get_mut("allOf").and_then(Value::as_array_mut) { + arr.iter_mut().for_each(&mut inline_one); + } + current +} + +fn merge_extension_into_schema( + target: &mut Value, + ext_val: &Value, + target_name: &str, + root_raw_schemas: &mut BTreeMap, + defs: &mut BTreeMap, +) { + if let Some(all_of) = ext_val.get("allOf").and_then(Value::as_array) { + for branch in all_of { + if local_def_ref(branch) != Some(target_name) { + merge_single_branch_into_target(target, branch, root_raw_schemas, defs); + } + } + } + merge_single_branch_into_target(target, ext_val, root_raw_schemas, defs); +} + +fn merge_single_branch_into_target( + target: &mut Value, + branch: &Value, + root_raw_schemas: &mut BTreeMap, + defs: &mut BTreeMap, +) { + let (Some(target_obj), Some(branch_obj)) = (target.as_object_mut(), branch.as_object()) else { + return; + }; + + if let Some(ext_props) = branch_obj.get("properties").and_then(Value::as_object) { + if let Some(target_props_map) = target_obj + .entry("properties".to_string()) + .or_insert_with(|| Value::Object(Map::new())) + .as_object_mut() + { + for (prop_name, ext_prop) in ext_props { + if let Some(existing_prop) = target_props_map.get_mut(prop_name) { + merge_extension_property(existing_prop, ext_prop, root_raw_schemas, defs); + } else { + target_props_map.insert(prop_name.clone(), ext_prop.clone()); + } + } + } + } + + let Some(ext_reqs) = branch_obj.get("required").and_then(Value::as_array) else { + return; + }; + if let Some(req_arr) = target_obj + .entry("required".to_string()) + .or_insert_with(|| Value::Array(Vec::new())) + .as_array_mut() + { + for req in ext_reqs { + if !req_arr.contains(req) { + req_arr.push(req.clone()); + } + } + } +} + +fn merge_extension_property( + base_prop: &mut Value, + ext_prop: &Value, + root_raw_schemas: &mut BTreeMap, + defs: &mut BTreeMap, +) { + let (Some(base_obj), Some(ext_obj)) = (base_prop.as_object_mut(), ext_prop.as_object()) else { + return; + }; + for key in ["$ref", "ucp_request", "ucp_response", "description"] { + if let Some(val) = ext_obj.get(key) { + base_obj.insert(key.to_string(), val.clone()); + } + } + if let Some(ext_items) = ext_obj.get("items") { + merge_extension_items(base_obj, ext_items, root_raw_schemas, defs); + } + + let has_ext_props = ext_obj + .get("properties") + .and_then(Value::as_object) + .is_some_and(|m| !m.is_empty()); + let has_ext_reqs = ext_obj + .get("required") + .and_then(Value::as_array) + .is_some_and(|a| !a.is_empty()); + if !(has_ext_props || has_ext_reqs) { + return; + } + + if !base_obj.contains_key("properties") && !ext_obj.contains_key("$ref") { + if let Some(target_def) = local_def_ref(base_prop).map(str::to_string) { + if modify_target_def( + &target_def, + root_raw_schemas, + defs, + |target, roots, defs| { + merge_single_branch_into_target(target, ext_prop, roots, defs); + }, + ) { + return; + } + } + } + + merge_single_branch_into_target(base_prop, ext_prop, root_raw_schemas, defs); +} + +fn merge_extension_items( + base_obj: &mut Map, + ext_items: &Value, + root_raw_schemas: &mut BTreeMap, + defs: &mut BTreeMap, +) { + if ext_items.get("$ref").is_some() { + let keep_base_ref = local_def_ref(ext_items).is_some_and(|ext_def| { + base_obj + .get("items") + .and_then(local_def_ref) + .is_some_and(|base_def| { + def_extends_target(base_def, ext_def, root_raw_schemas, defs) + }) + }); + if !keep_base_ref { + base_obj.insert("items".to_string(), ext_items.clone()); + } + return; + } + + let Some(ext_items_obj) = ext_items.as_object() else { + return; + }; + let cond_branches: Vec = if let Some(Value::Array(arr)) = ext_items_obj.get("allOf") { + arr.clone() + } else if ext_items_obj.contains_key("if") || ext_items_obj.contains_key("then") { + vec![ext_items.clone()] + } else { + return; + }; + + if let Some(item_def) = base_obj + .get("items") + .and_then(local_def_ref) + .map(str::to_string) + { + if let Some(target_schema) = root_raw_schemas + .get_mut(&item_def) + .or_else(|| defs.get_mut(&item_def)) + { + append_unique_allof_branches(target_schema, cond_branches); + return; + } + } + + if let Some(base_items) = base_obj.get_mut("items") { + append_unique_allof_branches(base_items, cond_branches); + } +} + +fn def_extends_target( + base_def: &str, + ext_def: &str, + root_raw_schemas: &BTreeMap, + defs: &BTreeMap, +) -> bool { + if base_def == ext_def { + return true; + } + let Some(all_of) = defs + .get(base_def) + .or_else(|| root_raw_schemas.get(base_def)) + .and_then(|s| s.get("allOf")) + .and_then(Value::as_array) + else { + return false; + }; + all_of.iter().any(|b| local_def_ref(b) == Some(ext_def)) +} + +fn append_unique_allof_branches(target: &mut Value, branches: Vec) { + let Some(all_of) = target.as_object_mut().and_then(|o| { + o.entry("allOf".to_string()) + .or_insert_with(|| Value::Array(Vec::new())) + .as_array_mut() + }) else { + return; + }; + for branch in branches { + if !all_of.contains(&branch) { + all_of.push(branch); + } + } +} + +fn compose_container_capability( + cap_item: &LoadedSchema, + active_ext_indices: &BTreeSet, + loaded: &[LoadedSchema], + root_raw_schemas: &mut BTreeMap, + defs: &mut BTreeMap, + inlined_mixin_defs: &mut BTreeSet, +) -> Result { + let mut composed = cap_item.schema.clone(); + let (Some(cap_name), Some(container_defs)) = ( + cap_item.name.as_deref(), + composed.get_mut("$defs").and_then(Value::as_object_mut), + ) else { + return Ok(composed); + }; + + for &ext_idx in active_ext_indices { + let ext_item = &loaded[ext_idx]; + let Some(ext_cap_block) = ext_item + .schema + .get("$defs") + .and_then(Value::as_object) + .and_then(|d| d.get(cap_name)) + else { + continue; + }; + + let op_patches: Vec<(String, &Value)> = + if let Some(nested_defs) = ext_cap_block.get("$defs").and_then(Value::as_object) { + nested_defs.iter().map(|(k, v)| (k.clone(), v)).collect() + } else { + let blocks: Vec<&Value> = + if let Some(one_of) = ext_cap_block.get("oneOf").and_then(Value::as_array) { + one_of.iter().collect() + } else { + vec![ext_cap_block] + }; + blocks + .into_iter() + .filter_map(|b| find_referenced_container_op(b, container_defs).map(|k| (k, b))) + .collect() + }; + + for (op_key, block) in op_patches { + let Some(target_op_schema) = container_defs.get_mut(&op_key) else { + continue; + }; + let target_op_name = qualify_container_op_name(&cap_item.stem_pascal, &op_key); + let mut derefed = deref_local_mixin_refs( + block, + &ext_item.schema, + &ext_item.stem_pascal, + inlined_mixin_defs, + ); + rewrite_refs_to_defs(&mut derefed, &target_op_name, Some(&ext_item.stem_pascal)); + merge_extension_into_schema( + target_op_schema, + &derefed, + &target_op_name, + root_raw_schemas, + defs, + ); + } + } + + Ok(composed) +} + +fn find_referenced_container_op( + block: &Value, + container_defs: &Map, +) -> Option { + block.get("allOf")?.as_array()?.iter().find_map(|branch| { + let (_, def_key) = branch.get("$ref")?.as_str()?.split_once("#/$defs/")?; + container_defs + .contains_key(def_key) + .then(|| def_key.to_string()) + }) +} + +fn hoist_container_operations( + cap_item: &LoadedSchema, + composed_container: &Value, + defs: &mut BTreeMap, +) -> Result<(), CodegenError> { + let Some(container_defs) = composed_container.get("$defs").and_then(Value::as_object) else { + return Ok(()); + }; + + for (op_key, op_schema) in container_defs { + if !is_container_op_key(op_key) { + continue; + } + let hoisted_name = qualify_container_op_name(&cap_item.stem_pascal, op_key); + let opts = match op_key.strip_suffix("_request") { + Some(op_prefix) => ResolveOptions::new(Direction::Request, op_prefix), + None => ResolveOptions::new(Direction::Response, "read"), + }; + let resolved = resolve(op_schema, &opts)?; + defs.insert( + hoisted_name.clone(), + normalize_def_schema(&resolved, &hoisted_name, Some(&cap_item.stem_pascal)), + ); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn merge_extension_into_schema_merges_properties_items_and_required_without_duplicates() { + let mut target = json!({ + "type": "object", + "required": ["id"], + "properties": { + "id": { "type": "string" }, + "products": { "type": "array", "items": { "$ref": "#/$defs/Product" } } + } + }); + + let ext_source = json!({ + "$defs": { + "mixin_props": { + "type": "object", + "required": ["id", "extra_req"], + "properties": { + "products": { "type": "array", "items": { "$ref": "#/$defs/FulfillmentProduct" } }, + "extra_req": { "type": "string" } + } + }, + "colliding_mixin": { + "type": "object", + "required": ["colliding_field"], + "properties": { + "colliding_field": { "type": "boolean" } + } + }, + "dev.ucp.shopping.checkout": { + "allOf": [ + { "$ref": "#/$defs/Checkout" }, + { "$ref": "#/$defs/mixin_props" }, + { "$ref": "#/$defs/FulfillmentCollidingMixin" } + ] + } + } + }); + + let mut inlined_mixin_defs = BTreeSet::new(); + let derefed = deref_local_mixin_refs( + &ext_source["$defs"]["dev.ucp.shopping.checkout"], + &ext_source, + "Fulfillment", + &mut inlined_mixin_defs, + ); + assert!(inlined_mixin_defs.contains("MixinProps")); + assert!(inlined_mixin_defs.contains("FulfillmentCollidingMixin")); + let mut root_raw_schemas = BTreeMap::new(); + let mut defs = BTreeMap::new(); + merge_extension_into_schema( + &mut target, + &derefed, + "Checkout", + &mut root_raw_schemas, + &mut defs, + ); + + assert_eq!( + target["required"], + json!(["id", "extra_req", "colliding_field"]) + ); + assert_eq!( + target["properties"]["products"]["items"]["$ref"], + "#/$defs/FulfillmentProduct" + ); + assert_eq!(target["properties"]["extra_req"]["type"], "string"); + assert_eq!(target["properties"]["colliding_field"]["type"], "boolean"); + } + + #[test] + fn merge_extension_redirects_ref_property_constraints_and_conditional_items_to_target_defs() { + let mut booking = json!({ + "type": "object", + "properties": { + "actions": { "$ref": "#/$defs/Actions" }, + "payment": { "$ref": "#/$defs/Payment" }, + "policies": { "type": "array", "items": { "$ref": "#/$defs/Policy" } } + } + }); + let mut root_raw_schemas = BTreeMap::from([ + ( + "Actions".to_string(), + json!({ "type": "object", "propertyNames": { "$ref": "#/$defs/ReverseDomainName" } }), + ), + ( + "Payment".to_string(), + json!({ + "type": "object", + "properties": { + "instruments": { "type": "array", "items": { "$ref": "#/$defs/SelectedPaymentInstrument" } } + } + }), + ), + ("Policy".to_string(), json!({ "type": "object" })), + ]); + let mut defs = BTreeMap::from([( + "SelectedPaymentInstrument".to_string(), + json!({ "allOf": [{ "$ref": "#/$defs/PaymentInstrument" }] }), + )]); + + let ext = json!({ + "type": "object", + "properties": { + "actions": { + "ucp_request": "omit", + "properties": { + "dev.ucp.common.payment.three_ds_challenge": { "type": "array" } + } + }, + "payment": { + "type": "object", + "properties": { + "instruments": { + "type": "array", + "items": { "$ref": "#/$defs/PaymentInstrument" }, + "ucp_request": { "complete": "required" } + } + } + }, + "policies": { + "type": "array", + "items": { + "if": { "properties": { "type": { "const": "dev.ucp.lodging.policy.cancellation" } } }, + "then": { "$ref": "#/$defs/CancellationItem" } + } + } + } + }); + + merge_extension_into_schema( + &mut booking, + &ext, + "Booking", + &mut root_raw_schemas, + &mut defs, + ); + + assert!(booking["properties"]["actions"].get("properties").is_none()); + assert_eq!(booking["properties"]["actions"]["ucp_request"], "omit"); + assert!(root_raw_schemas["Actions"]["properties"] + .get("dev.ucp.common.payment.three_ds_challenge") + .is_some()); + + assert!(booking["properties"]["payment"].get("properties").is_none()); + assert_eq!( + root_raw_schemas["Payment"]["properties"]["instruments"]["items"]["$ref"], + "#/$defs/SelectedPaymentInstrument" + ); + assert_eq!( + root_raw_schemas["Payment"]["properties"]["instruments"]["ucp_request"]["complete"], + "required" + ); + + assert_eq!( + booking["properties"]["policies"]["items"], + json!({ "$ref": "#/$defs/Policy" }) + ); + assert_eq!( + root_raw_schemas["Policy"]["allOf"][0]["then"]["$ref"], + "#/$defs/CancellationItem" + ); + } + + #[test] + fn compose_container_capability_supports_nested_defs_and_oneof_allof_extensions() { + let loaded = vec![ + LoadedSchema::from_file( + "/schemas/shopping/catalog_lookup.json", + json!({ + "name": "dev.ucp.shopping.catalog.lookup", + "$defs": { + "lookup_request": { "type": "object", "properties": { "ids": { "type": "array" } } }, + "lookup_response": { "type": "object", "properties": { "products": { "type": "array" } } } + } + }), + ), + LoadedSchema::from_file( + "/schemas/shopping/fulfillment.json", + json!({ + "name": "dev.ucp.shopping.fulfillment", + "$defs": { + "req_mixin": { + "type": "object", + "properties": { "context": { "$ref": "types/context.json" } } + }, + "dev.ucp.shopping.catalog.lookup": { + "$defs": { "lookup_request": { "$ref": "#/$defs/req_mixin" } } + } + } + }), + ), + LoadedSchema::from_file( + "/schemas/common/loyalty.json", + json!({ + "name": "dev.ucp.common.loyalty", + "$defs": { + "dev.ucp.shopping.catalog.lookup": { + "oneOf": [{ + "allOf": [ + { "$ref": "catalog_lookup.json#/$defs/lookup_response" }, + { "type": "object", "properties": { "loyalty": { "$ref": "types/loyalty.json" } } } + ] + }] + } + } + }), + ), + ]; + + let mut root_raw_schemas = BTreeMap::new(); + let mut defs = BTreeMap::new(); + let mut inlined_mixin_defs = BTreeSet::new(); + let composed = compose_container_capability( + &loaded[0], + &BTreeSet::from([1, 2]), + &loaded, + &mut root_raw_schemas, + &mut defs, + &mut inlined_mixin_defs, + ) + .unwrap(); + + assert!(inlined_mixin_defs.contains("ReqMixin")); + hoist_container_operations(&loaded[0], &composed, &mut defs).unwrap(); + + assert_eq!( + defs["CatalogLookupRequest"]["properties"]["context"]["$ref"], + "#/$defs/Context" + ); + assert_eq!( + defs["CatalogLookupResponse"]["properties"]["loyalty"]["$ref"], + "#/$defs/Loyalty" + ); + } +} diff --git a/src/codegen/hoist.rs b/src/codegen/hoist.rs new file mode 100644 index 0000000..84b91f5 --- /dev/null +++ b/src/codegen/hoist.rs @@ -0,0 +1,580 @@ +//! Stage 2: Upfront `$defs` hoisting, role container extraction, and collision qualification. + +use std::collections::{BTreeMap, BTreeSet}; + +use serde_json::Value; + +use crate::codegen::normalizer::{ + has_directional_annotations, is_reverse_domain_name, normalize_def_schema, qualify_def_name, + ref_to_def_name, rewrite_refs_to_defs, slice_directional_schemas, to_pascal_case, +}; +use crate::codegen::reachability::{ + compute_inactive_local_defs, is_container_op_key, normalize_lexical_path, LoadedSchema, +}; +use crate::codegen::{ + collision_qualified_name, contains_ref, has_root_schema_body, CodegenError, SchemaMap, +}; +use crate::loader::for_each_schema_object_mut; +use crate::resolver::resolve; +use crate::types::{Direction, ResolveOptions}; + +const ROLE_SCHEMA_KEYS: &[&str] = &["platform_schema", "business_schema", "response_schema"]; + +enum LocalDefAction { + Skip, + RoleContainer, + Overlay(String), + Hoist(String), +} + +pub(super) fn hoist_defs( + loaded: &mut [LoadedSchema], + reachable_indices: &BTreeSet, + active_cap_indices: &BTreeSet, + active_ext_indices: &BTreeSet, + active_capabilities: &BTreeSet, + defs: &mut SchemaMap, + sliced_base_names: &mut BTreeSet, +) -> Result<(SchemaMap, Vec<(String, Value)>), CodegenError> { + let standalone_type_names: BTreeSet = reachable_indices + .iter() + .filter_map(|&idx| { + let item = &loaded[idx]; + (!item.is_container && has_root_schema_body(&item.schema)) + .then(|| item.stem_pascal.clone()) + }) + .collect(); + + let inactive_local_defs: BTreeMap> = reachable_indices + .iter() + .filter_map(|&idx| { + let inactive = compute_inactive_local_defs(&loaded[idx], active_capabilities); + (!inactive.is_empty()).then_some((idx, inactive)) + }) + .collect(); + + let is_active_source = + |idx: usize| active_ext_indices.contains(&idx) || active_cap_indices.contains(&idx); + let local_def_renames = compute_collision_renames( + loaded, + reachable_indices, + &inactive_local_defs, + &standalone_type_names, + &is_active_source, + ); + apply_collision_renames_to_schemas(loaded, reachable_indices, &local_def_renames); + + let mut pending_overlays = Vec::new(); + for &idx in reachable_indices { + let item = &loaded[idx]; + let Some(defs_obj) = item.schema.get("$defs").and_then(Value::as_object) else { + continue; + }; + let inactive_set = inactive_local_defs.get(&idx); + + for (def_key, def_val) in defs_obj { + match classify_local_def( + item, + def_key, + def_val, + inactive_set, + &standalone_type_names, + is_active_source(idx), + ) { + LocalDefAction::Skip => continue, + LocalDefAction::RoleContainer => { + hoist_self_named_role_schemas( + &item.stem_pascal, + def_val, + defs, + sliced_base_names, + )?; + } + LocalDefAction::Overlay(target_pascal) => { + let mut overlay = def_val.clone(); + rewrite_refs_to_defs(&mut overlay, &target_pascal, Some(&item.stem_pascal)); + pending_overlays.push((target_pascal, overlay)); + } + LocalDefAction::Hoist(default_name) => { + let hoisted_name = local_def_renames + .get(&(idx, def_key.clone())) + .cloned() + .unwrap_or(default_name); + if item.stem == "ucp" { + insert_ucp_def( + def_key, + def_val, + defs_obj.get("base"), + &hoisted_name, + defs, + )?; + } else { + insert_sliced_or_normalized_def( + def_val, + &hoisted_name, + &item.stem_pascal, + defs, + sliced_base_names, + )?; + } + } + } + } + } + + let mut root_raw_schemas = BTreeMap::new(); + for &idx in reachable_indices { + let item = &loaded[idx]; + if item.is_container { + continue; + } + let mut root_val = item.schema.clone(); + if let Some(obj) = root_val.as_object_mut() { + obj.remove("$defs"); + obj.remove("definitions"); + } + if !has_root_schema_body(&root_val) { + continue; + } + rewrite_refs_to_defs(&mut root_val, &item.stem_pascal, Some(&item.stem_pascal)); + root_raw_schemas.insert(item.stem_pascal.clone(), root_val); + } + + Ok((root_raw_schemas, pending_overlays)) +} + +fn classify_local_def( + item: &LoadedSchema, + def_key: &str, + def_val: &Value, + inactive_set: Option<&BTreeSet>, + standalone_type_names: &BTreeSet, + is_active_source: bool, +) -> LocalDefAction { + if is_reverse_domain_name(def_key) { + return if item.name.as_deref() == Some(def_key) { + LocalDefAction::RoleContainer + } else { + LocalDefAction::Skip + }; + } + if inactive_set.is_some_and(|s| s.contains(def_key)) + || (item.is_container && is_container_op_key(def_key)) + || is_pure_reexport_def(&item.stem_pascal, def_key, def_val) + { + return LocalDefAction::Skip; + } + let target_pascal = to_pascal_case(def_key); + if is_active_source + && is_inplace_overlay( + item, + def_key, + &target_pascal, + def_val, + standalone_type_names, + ) + { + return LocalDefAction::Overlay(target_pascal); + } + LocalDefAction::Hoist(qualify_def_name(&item.stem_pascal, def_key)) +} + +fn is_pure_reexport_def(parent_pascal: &str, def_key: &str, def_val: &Value) -> bool { + let Some(obj) = def_val.as_object() else { + return false; + }; + let Some(Value::String(ref_str)) = obj.get("$ref") else { + return false; + }; + !ref_str.contains('#') + && !["properties", "allOf", "oneOf", "anyOf"] + .iter() + .any(|k| obj.contains_key(*k)) + && ref_to_def_name(ref_str, Some(parent_pascal)) == to_pascal_case(def_key) +} + +fn is_inplace_overlay( + item: &LoadedSchema, + def_key: &str, + target_pascal: &str, + def_val: &Value, + standalone_type_names: &BTreeSet, +) -> bool { + if !standalone_type_names.contains(target_pascal) { + return false; + } + let Some(obj) = def_val.as_object() else { + return false; + }; + let has_allof_ref_to_target = + obj.get("allOf") + .and_then(Value::as_array) + .is_some_and(|arr| { + arr.iter().any(|branch| { + branch.get("$ref").and_then(Value::as_str).is_some_and(|r| { + ref_to_def_name(r, Some(&item.stem_pascal)) == target_pascal + }) + }) + }); + if has_allof_ref_to_target { + return true; + } + if !item.is_extension + || !(obj.get("type").and_then(Value::as_str) == Some("object") + || obj.contains_key("properties")) + { + return false; + } + let expected_ref = format!("#/$defs/{def_key}"); + item.schema + .get("$defs") + .and_then(Value::as_object) + .is_some_and(|defs| { + defs.iter() + .any(|(k, v)| is_reverse_domain_name(k) && contains_ref(v, &expected_ref)) + }) +} + +fn compute_collision_renames( + loaded: &[LoadedSchema], + reachable_indices: &BTreeSet, + inactive_local_defs: &BTreeMap>, + standalone_type_names: &BTreeSet, + is_active_source: &dyn Fn(usize) -> bool, +) -> BTreeMap<(usize, String), String> { + let mut candidate_counts: BTreeMap = BTreeMap::new(); + let mut entries = Vec::new(); + + for &idx in reachable_indices { + let item = &loaded[idx]; + let Some(defs_obj) = item.schema.get("$defs").and_then(Value::as_object) else { + continue; + }; + let inactive_set = inactive_local_defs.get(&idx); + for (def_key, def_val) in defs_obj { + let LocalDefAction::Hoist(default_name) = classify_local_def( + item, + def_key, + def_val, + inactive_set, + standalone_type_names, + is_active_source(idx), + ) else { + continue; + }; + *candidate_counts.entry(default_name.clone()).or_insert(0) += 1; + entries.push((idx, def_key.clone(), default_name)); + } + } + + let mut renames = BTreeMap::new(); + for (idx, def_key, default_name) in entries { + let count = candidate_counts.get(&default_name).copied().unwrap_or(0); + if standalone_type_names.contains(&default_name) || count > 1 { + let qualified = collision_qualified_name(&loaded[idx].stem_pascal, &def_key); + renames.insert((idx, def_key), qualified); + } + } + renames +} + +fn apply_collision_renames_to_schemas( + loaded: &mut [LoadedSchema], + reachable_indices: &BTreeSet, + local_def_renames: &BTreeMap<(usize, String), String>, +) { + if local_def_renames.is_empty() { + return; + } + let path_to_idx: BTreeMap<_, usize> = loaded + .iter() + .enumerate() + .map(|(i, item)| (item.path.clone(), i)) + .collect(); + + for &idx in reachable_indices { + let current_dir = loaded[idx].path.parent().map(std::path::Path::to_path_buf); + for_each_schema_object_mut(&mut loaded[idx].schema, &mut |obj| { + let Some(ref_str) = obj.get("$ref").and_then(Value::as_str) else { + return; + }; + let lookup_key = if let Some(local_key) = ref_str.strip_prefix("#/$defs/") { + Some((idx, local_key.to_string())) + } else if let Some((file_part, def_key)) = ref_str.split_once("#/$defs/") { + current_dir + .as_ref() + .and_then(|dir| path_to_idx.get(&normalize_lexical_path(&dir.join(file_part)))) + .map(|&target_idx| (target_idx, def_key.to_string())) + } else { + None + }; + if let Some(qualified) = lookup_key.and_then(|k| local_def_renames.get(&k)) { + obj.insert( + "$ref".to_string(), + Value::String(format!("#/$defs/{qualified}")), + ); + } + }); + } +} + +fn hoist_self_named_role_schemas( + stem_pascal: &str, + role_container: &Value, + defs: &mut BTreeMap, + sliced_base_names: &mut BTreeSet, +) -> Result<(), CodegenError> { + let Some(obj) = role_container.as_object() else { + return Ok(()); + }; + for &role_key in ROLE_SCHEMA_KEYS { + let Some(role_val) = obj.get(role_key).filter(|v| v.is_object()) else { + continue; + }; + let hoisted_name = qualify_def_name(stem_pascal, role_key); + insert_sliced_or_normalized_def( + role_val, + &hoisted_name, + stem_pascal, + defs, + sliced_base_names, + )?; + } + Ok(()) +} + +pub(super) fn insert_sliced_or_normalized_def( + raw_val: &Value, + hoisted_name: &str, + parent_pascal: &str, + defs: &mut BTreeMap, + sliced_base_names: &mut BTreeSet, +) -> Result<(), CodegenError> { + if has_directional_annotations(raw_val) { + sliced_base_names.insert(hoisted_name.to_string()); + let mut prepared = raw_val.clone(); + rewrite_refs_to_defs(&mut prepared, hoisted_name, Some(parent_pascal)); + for (slice_name, slice_val) in slice_directional_schemas(&prepared, hoisted_name)? { + defs.insert(slice_name, slice_val); + } + } else { + defs.insert( + hoisted_name.to_string(), + normalize_def_schema(raw_val, hoisted_name, Some(parent_pascal)), + ); + } + Ok(()) +} + +fn insert_ucp_def( + def_key: &str, + raw_val: &Value, + raw_ucp_base: Option<&Value>, + hoisted_name: &str, + defs: &mut BTreeMap, +) -> Result<(), CodegenError> { + let mut prepared = raw_val.clone(); + let is_request_envelope = def_key.starts_with("request_"); + let is_response_envelope = def_key.starts_with("response_"); + + if (is_request_envelope || is_response_envelope) && def_key != "base" { + if let Some(base_val) = raw_ucp_base.and_then(Value::as_object) { + for_each_schema_object_mut(&mut prepared, &mut |obj| { + let is_base_ref = obj + .get("$ref") + .and_then(Value::as_str) + .is_some_and(|r| r == "#/$defs/base" || r == "#/$defs/UcpBase"); + if !is_base_ref { + return; + } + obj.remove("$ref"); + for (k, v) in base_val { + obj.entry(k.clone()).or_insert_with(|| v.clone()); + } + }); + } + } + + if is_request_envelope || is_response_envelope || has_directional_annotations(&prepared) { + let opts = if is_request_envelope { + ResolveOptions::new(Direction::Request, "create") + } else { + ResolveOptions::new(Direction::Response, "read") + }; + prepared = resolve(&prepared, &opts)?; + } + + defs.insert( + hoisted_name.to_string(), + normalize_def_schema(&prepared, hoisted_name, Some("Ucp")), + ); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn hoist_defs_qualifies_colliding_local_defs_and_skips_pure_reexports() { + let mut loaded = vec![ + LoadedSchema::from_file( + "/schemas/alpha.json", + json!({ + "name": "dev.ucp.shopping.alpha", + "type": "object", + "properties": { + "detail": { "$ref": "#/$defs/shared_detail" }, + "prefixed": { "$ref": "#/$defs/alpha_prefixed" }, + "item": { "$ref": "#/$defs/line_item" } + }, + "$defs": { + "shared_detail": { "type": "object", "properties": { "a": { "type": "string" } } }, + "alpha_prefixed": { "type": "string", "enum": ["x", "y"] }, + "line_item": { "$ref": "types/line_item.json" } + } + }), + ), + LoadedSchema::from_file( + "/schemas/beta.json", + json!({ + "name": "dev.ucp.shopping.beta", + "type": "object", + "properties": { + "detail": { "$ref": "#/$defs/shared_detail" }, + "alpha_detail": { "$ref": "alpha.json#/$defs/shared_detail" } + }, + "$defs": { + "shared_detail": { "type": "object", "properties": { "b": { "type": "number" } } }, + "dev.ucp.shopping.beta": { + "platform_schema": { + "type": "object", + "properties": { "endpoint": { "type": "string" } } + } + } + } + }), + ), + LoadedSchema::from_file( + "/schemas/types/line_item.json", + json!({ + "type": "object", + "properties": { "id": { "type": "string" } } + }), + ), + LoadedSchema::from_file( + "/schemas/types/alpha_prefixed.json", + json!({ + "type": "object", + "properties": { "code": { "type": "string" } } + }), + ), + ]; + + let reachable = BTreeSet::from([0, 1, 2, 3]); + let active_caps = BTreeSet::from([0, 1]); + let active_exts = BTreeSet::new(); + let active_names = BTreeSet::from(["alpha".to_string(), "beta".to_string()]); + let mut defs = BTreeMap::new(); + let mut sliced_base_names = BTreeSet::new(); + + let (root_raw, overlays) = hoist_defs( + &mut loaded, + &reachable, + &active_caps, + &active_exts, + &active_names, + &mut defs, + &mut sliced_base_names, + ) + .unwrap(); + + assert!(overlays.is_empty()); + assert!(defs.contains_key("AlphaSharedDetail")); + assert!(defs.contains_key("AlphaAlphaPrefixed")); + assert!(defs.contains_key("BetaSharedDetail")); + assert!(defs.contains_key("BetaPlatformSchema")); + assert!(!defs.contains_key("LineItem")); + + assert_eq!( + root_raw["Alpha"]["properties"]["detail"]["$ref"], + "#/$defs/AlphaSharedDetail" + ); + assert_eq!( + root_raw["Alpha"]["properties"]["prefixed"]["$ref"], + "#/$defs/AlphaAlphaPrefixed" + ); + assert_eq!( + root_raw["Beta"]["properties"]["detail"]["$ref"], + "#/$defs/BetaSharedDetail" + ); + assert_eq!( + root_raw["Beta"]["properties"]["alpha_detail"]["$ref"], + "#/$defs/AlphaSharedDetail" + ); + } + + #[test] + fn hoist_defs_resolves_ucp_envelopes_without_create_or_update_suffixes() { + let mut loaded = vec![LoadedSchema::from_file( + "/schemas/ucp.json", + json!({ + "$defs": { + "base": { + "type": "object", + "required": ["version"], + "properties": { + "version": { "type": "string" }, + "map_order": { "type": "object", "ucp_request": "omit" } + } + }, + "request_checkout_schema": { + "allOf": [ + { "$ref": "#/$defs/base" }, + { "type": "object", "properties": { "capabilities": { "type": "object" } } } + ] + }, + "response_checkout_schema": { + "allOf": [ + { "$ref": "#/$defs/base" }, + { + "type": "object", + "required": ["payment_handlers"], + "properties": { "payment_handlers": { "type": "object" } } + } + ] + } + } + }), + )]; + + let reachable = BTreeSet::from([0]); + let mut defs = BTreeMap::new(); + let mut sliced_base_names = BTreeSet::new(); + + hoist_defs( + &mut loaded, + &reachable, + &BTreeSet::new(), + &BTreeSet::new(), + &BTreeSet::new(), + &mut defs, + &mut sliced_base_names, + ) + .unwrap(); + + assert!(defs.contains_key("UcpBase")); + assert!(!defs.contains_key("UcpBaseCreateRequest")); + assert!(!defs.contains_key("UcpBaseUpdateRequest")); + assert!(!sliced_base_names.contains("UcpBase")); + + let req_base = &defs["RequestCheckoutSchema"]["allOf"][0]; + assert!(req_base["properties"].get("version").is_some()); + assert!(req_base["properties"].get("map_order").is_none()); + + let resp_base = &defs["ResponseCheckoutSchema"]["allOf"][0]; + assert!(resp_base["properties"].get("version").is_some()); + assert!(resp_base["properties"].get("map_order").is_some()); + } +} diff --git a/src/codegen/mod.rs b/src/codegen/mod.rs index 600c602..3ee72f6 100644 --- a/src/codegen/mod.rs +++ b/src/codegen/mod.rs @@ -1,3 +1,456 @@ //! Modular JSON Schema 2020-12 type-bundle code generation (`generate-types`). +mod compose; +mod hoist; pub mod normalizer; +mod reachability; + +use std::collections::{BTreeMap, BTreeSet}; +use std::path::PathBuf; + +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +use crate::codegen::compose::compose_active_extensions; +use crate::codegen::hoist::{hoist_defs, insert_sliced_or_normalized_def}; +use crate::codegen::normalizer::{align_directional_refs, to_pascal_case}; +use crate::codegen::reachability::{ + compute_reachable_closure, load_all_schemas, select_active_schemas, +}; +use crate::error::{ComposeError, ResolveError}; +use crate::loader::{for_each_schema_object, for_each_schema_object_mut}; + +/// Configuration options for [`compile_types`] and [`generate_types`]. +#[derive(Debug, Clone)] +pub struct GenerateTypesOptions { + pub profile: Option, + pub capabilities: Option>, + pub extensions: Option>, + pub schema_dir: Option, + pub title: String, + pub description: Option, +} + +impl Default for GenerateTypesOptions { + fn default() -> Self { + Self { + profile: None, + capabilities: None, + extensions: None, + schema_dir: None, + title: "UCP Schema Types".to_string(), + description: None, + } + } +} + +impl GenerateTypesOptions { + /// Create default [`GenerateTypesOptions`]. + pub fn new() -> Self { + Self::default() + } + + /// Set the local schema directory path. + pub fn schema_dir(mut self, dir: impl Into) -> Self { + self.schema_dir = Some(dir.into()); + self + } + + /// Set the profile path or URL. + pub fn profile(mut self, profile: impl Into) -> Self { + self.profile = Some(profile.into()); + self + } + + /// Set active capability filters (accepts string literals without `.to_string()`). + pub fn capabilities(mut self, caps: impl IntoIterator>) -> Self { + let collected: Vec = caps.into_iter().map(Into::into).collect(); + self.capabilities = (!collected.is_empty()).then_some(collected); + self + } + + /// Set active extension filters (accepts string literals without `.to_string()`). + pub fn extensions(mut self, exts: impl IntoIterator>) -> Self { + let collected: Vec = exts.into_iter().map(Into::into).collect(); + self.extensions = (!collected.is_empty()).then_some(collected); + self + } + + /// Set the bundle document title. + pub fn title(mut self, title: impl Into) -> Self { + self.title = title.into(); + self + } + + /// Set the bundle document description. + pub fn description(mut self, description: impl Into) -> Self { + self.description = Some(description.into()); + self + } +} + +/// Top-level JSON Schema 2020-12 `$defs` bundle emitted by [`generate_types`]. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct TypesBundleDoc { + #[serde(rename = "$schema")] + pub schema_dialect: String, + pub title: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, + #[serde(rename = "$defs")] + pub defs: BTreeMap, +} + +/// Intermediate compilation artifacts produced by [`compile_types`]. +#[derive(Debug, Clone)] +pub struct CompiledTypes { + pub defs: BTreeMap, + pub capability_resources: BTreeMap, + pub container_schemas: Vec<(PathBuf, Value)>, + /// Stores both full reverse-domain capability names (e.g. "dev.ucp.shopping.catalog.search") + /// and canonical schema file stems (e.g. "catalog_search", "checkout") for active capabilities. + pub active_capabilities: BTreeSet, +} + +/// Errors produced during type-bundle or OpenAPI code generation. +#[derive(Debug, thiserror::Error)] +#[non_exhaustive] +pub enum CodegenError { + #[error("schema resolution error: {0}")] + ResolveError(#[from] ResolveError), + #[error("schema composition error: {0}")] + ComposeError(#[from] ComposeError), +} + +impl CodegenError { + /// Returns the CLI exit code for this error type. + pub fn exit_code(&self) -> i32 { + match self { + Self::ResolveError(e) => e.exit_code(), + Self::ComposeError(e) => e.exit_code(), + } + } +} + +/// Compile UCP schemas into a self-contained `$defs` type table and intermediate metadata. +pub fn compile_types(options: &GenerateTypesOptions) -> Result { + if options.profile.is_some() { + return Err(CodegenError::ResolveError(ResolveError::InvalidSchema { + message: "--profile mode is not yet implemented (deferred to Task 5)".to_string(), + })); + } + let Some(schema_dir) = options.schema_dir.as_deref() else { + return Err(CodegenError::ResolveError(ResolveError::InvalidSchema { + message: "--schema-dir is required when --profile is not provided".to_string(), + })); + }; + if !schema_dir.exists() { + return Err(CodegenError::ResolveError(ResolveError::FileNotFound { + path: schema_dir.to_path_buf(), + })); + }; + + // Stage 1: Entrypoint & Transitive $ref Reachability Crawl + let mut loaded = load_all_schemas(schema_dir)?; + let cap_queries = options.capabilities.as_deref().filter(|s| !s.is_empty()); + let ext_queries = options.extensions.as_deref().filter(|s| !s.is_empty()); + let (active_cap_indices, active_ext_indices, active_capabilities) = + select_active_schemas(&loaded, cap_queries, ext_queries)?; + let include_all = cap_queries.is_none() && ext_queries.is_none(); + let reachable_indices = compute_reachable_closure( + &loaded, + &active_cap_indices, + &active_ext_indices, + &active_capabilities, + include_all, + ); + + // Stage 2: Upfront $defs Hoisting & Collision Qualification + let mut defs = BTreeMap::new(); + let mut sliced_base_names = BTreeSet::new(); + let (mut root_raw_schemas, pending_overlays) = hoist_defs( + &mut loaded, + &reachable_indices, + &active_cap_indices, + &active_ext_indices, + &active_capabilities, + &mut defs, + &mut sliced_base_names, + )?; + + // Stage 3: Capability & Sub-Type Extension Composition + let mut inlined_mixin_defs = BTreeSet::new(); + let (capability_resources, container_schemas) = compose_active_extensions( + &loaded, + &active_cap_indices, + &active_ext_indices, + pending_overlays, + &mut root_raw_schemas, + &mut defs, + &mut inlined_mixin_defs, + )?; + + // Stage 4: Inline Conditional Variant Hoisting (added in Phase 3 / Task 6) + + // Stage 5: Directional Slicing & Base Normalization + for (base_name, raw_schema) in &root_raw_schemas { + insert_sliced_or_normalized_def( + raw_schema, + base_name, + base_name, + &mut defs, + &mut sliced_base_names, + )?; + } + + // Stage 6: Directional $ref Alignment + align_all_directional_refs(&mut defs, &sliced_base_names); + + // Stage 7: Ordered anyOf Union Lowering & Subtype Registration (added in Phase 3 / Task 7) + + // Stage 8: Empty-Object, Inlined-Mixin & Unreachable Request-Slice Pruning + let active_cap_root_requests: BTreeSet = active_cap_indices + .iter() + .flat_map(|&idx| { + let stem = &loaded[idx].stem_pascal; + [ + format!("{stem}CreateRequest"), + format!("{stem}UpdateRequest"), + format!("{stem}CompleteRequest"), + ] + }) + .collect(); + prune_empty_object_defs(&mut defs, &inlined_mixin_defs, &active_cap_root_requests); + + Ok(CompiledTypes { + defs, + capability_resources, + container_schemas, + active_capabilities, + }) +} + +/// Generate a self-contained JSON Schema 2020-12 `$defs` bundle document. +pub fn generate_types(options: &GenerateTypesOptions) -> Result { + let compiled = compile_types(options)?; + let title = if options.title.is_empty() { + "UCP Schema Types".to_string() + } else { + options.title.clone() + }; + Ok(TypesBundleDoc { + schema_dialect: "https://json-schema.org/draft/2020-12/schema".to_string(), + title, + description: options.description.clone(), + defs: compiled.defs, + }) +} + +pub(super) type SchemaMap = BTreeMap; + +pub(super) fn local_def_ref(val: &Value) -> Option<&str> { + val.get("$ref")?.as_str()?.strip_prefix("#/$defs/") +} + +pub(super) fn contains_ref(val: &Value, target_ref: &str) -> bool { + let mut found = false; + for_each_schema_object(val, &mut |obj| { + if obj.get("$ref").and_then(Value::as_str) == Some(target_ref) { + found = true; + } + }); + found +} + +pub(super) fn collision_qualified_name(parent_pascal: &str, def_key: &str) -> String { + format!("{parent_pascal}{}", to_pascal_case(def_key)) +} + +pub(super) fn has_root_schema_body(val: &Value) -> bool { + val.is_object() && !is_empty_object_schema(val) +} + +fn align_all_directional_refs( + defs: &mut BTreeMap, + sliced_base_names: &BTreeSet, +) { + let known_defs: BTreeSet = defs.keys().cloned().collect(); + for (def_name, schema_val) in defs.iter_mut() { + let Some(suffix) = ["CreateRequest", "UpdateRequest", "CompleteRequest"] + .into_iter() + .find(|s| def_name.ends_with(s)) + else { + continue; + }; + align_directional_refs(schema_val, suffix, &known_defs); + for_each_schema_object_mut(schema_val, &mut |obj| { + let Some(Value::Array(all_of)) = obj.get_mut("allOf") else { + return; + }; + all_of.retain(|branch| { + local_def_ref(branch).is_none_or(|target| !sliced_base_names.contains(target)) + }); + }); + } +} + +fn prune_empty_object_defs( + defs: &mut BTreeMap, + inlined_mixin_defs: &BTreeSet, + active_cap_root_requests: &BTreeSet, +) { + loop { + let mut referenced = BTreeSet::new(); + for val in defs.values() { + for_each_schema_object(val, &mut |obj| { + if let Some(target) = obj + .get("$ref") + .and_then(Value::as_str) + .and_then(|r| r.strip_prefix("#/$defs/")) + { + referenced.insert(target.to_string()); + } + }); + } + let before = defs.len(); + defs.retain(|name, val| { + if name == "ErrorResponse" || referenced.contains(name) { + return true; + } + if is_empty_object_schema(val) || inlined_mixin_defs.contains(name) { + return false; + } + let is_request_slice = ["CreateRequest", "UpdateRequest", "CompleteRequest"] + .iter() + .any(|s| name.ends_with(s)); + !is_request_slice || active_cap_root_requests.contains(name) + }); + if defs.len() == before { + break; + } + } +} + +fn is_empty_object_schema(val: &Value) -> bool { + let Some(obj) = val.as_object() else { + return false; + }; + if obj + .get("type") + .is_some_and(|t| t.as_str() != Some("object")) + { + return false; + } + let has_props = obj + .get("properties") + .and_then(Value::as_object) + .is_some_and(|m| !m.is_empty()); + let has_composition = ["allOf", "oneOf", "anyOf"].iter().any(|k| { + obj.get(*k) + .and_then(Value::as_array) + .is_some_and(|a| !a.is_empty()) + }); + let has_constraints = obj.get("$ref").is_some_and(Value::is_string) + || obj + .get("additionalProperties") + .is_some_and(Value::is_object) + || ["propertyNames", "enum", "const", "pattern", "items"] + .iter() + .any(|k| obj.contains_key(*k)); + + !(has_props || has_composition || has_constraints) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn prune_empty_object_defs_prunes_unreferenced_and_retains_referenced_and_error_response() { + let mut defs = BTreeMap::from([ + ( + "UnreferencedEmpty".to_string(), + json!({ "type": "object", "properties": {}, "additionalProperties": true }), + ), + ( + "InlinedMixin".to_string(), + json!({ "type": "object", "properties": { "x": { "type": "string" } } }), + ), + ( + "DeadChildCreateRequest".to_string(), + json!({ "type": "object", "properties": { "leaf": { "$ref": "#/$defs/DeadLeafCreateRequest" } } }), + ), + ( + "DeadLeafCreateRequest".to_string(), + json!({ "type": "object", "properties": { "id": { "type": "string" } } }), + ), + ( + "CheckoutCreateRequest".to_string(), + json!({ "type": "object", "properties": { "id": { "type": "string" } } }), + ), + ( + "ReferencedEmpty".to_string(), + json!({ "type": "object", "additionalProperties": true }), + ), + ( + "ErrorResponse".to_string(), + json!({ "type": "object", "additionalProperties": true }), + ), + ( + "ActionsMap".to_string(), + json!({ "type": "object", "propertyNames": { "pattern": "^[a-z]+$" }, "additionalProperties": { "type": "boolean" } }), + ), + ( + "Holder".to_string(), + json!({ "type": "object", "properties": { "marker": { "$ref": "#/$defs/ReferencedEmpty" } } }), + ), + ]); + + prune_empty_object_defs( + &mut defs, + &BTreeSet::from(["InlinedMixin".to_string()]), + &BTreeSet::from(["CheckoutCreateRequest".to_string()]), + ); + + for pruned in [ + "UnreferencedEmpty", + "InlinedMixin", + "DeadChildCreateRequest", + "DeadLeafCreateRequest", + ] { + assert!(!defs.contains_key(pruned), "expected {pruned} to be pruned"); + } + for retained in [ + "CheckoutCreateRequest", + "ReferencedEmpty", + "ErrorResponse", + "ActionsMap", + "Holder", + ] { + assert!( + defs.contains_key(retained), + "expected {retained} to be retained" + ); + } + } + + #[test] + fn options_builders_and_error_exit_codes_behave_as_expected() { + let opts = GenerateTypesOptions::new() + .profile("https://example.com/profile.json") + .capabilities(Vec::<&str>::new()) + .extensions(Vec::<&str>::new()) + .title(""); + assert_eq!( + opts.profile.as_deref(), + Some("https://example.com/profile.json") + ); + assert!(opts.capabilities.is_none()); + assert!(opts.extensions.is_none()); + assert_eq!(opts.title, ""); + + let profile_err = compile_types(&opts).expect_err("profile guard must error before Task 5"); + assert_eq!(profile_err.exit_code(), 2); + } +} diff --git a/src/codegen/reachability.rs b/src/codegen/reachability.rs new file mode 100644 index 0000000..35f0ecb --- /dev/null +++ b/src/codegen/reachability.rs @@ -0,0 +1,608 @@ +//! Stage 1: Schema discovery, capability/extension selection, and transitive `$ref` reachability. + +use std::collections::{BTreeMap, BTreeSet, VecDeque}; +use std::path::{Component, Path, PathBuf}; + +use serde_json::{Map, Value}; + +use crate::codegen::normalizer::{is_reverse_domain_name, to_pascal_case}; +use crate::codegen::{contains_ref, CodegenError}; +use crate::compose::is_container_schema; +use crate::error::ComposeError; +use crate::loader::{collect_schema_files, for_each_schema_object, load_schema}; + +pub(super) const AMBIENT_ROOT_STEMS: &[&str] = &[ + "profile", + "ucp", + "capability", + "service", + "payment_handler", + "error_response", +]; + +#[derive(Debug, Clone)] +pub(super) struct LoadedSchema { + pub path: PathBuf, + pub stem: String, + pub stem_pascal: String, + pub name: Option, + pub is_extension: bool, + pub is_container: bool, + pub is_capability: bool, + pub in_types_dir: bool, + pub schema: Value, +} + +impl LoadedSchema { + pub(super) fn from_file(file_path: impl AsRef, schema: Value) -> Self { + let path = normalize_lexical_path(file_path.as_ref()); + let stem = path + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or_default() + .to_string(); + let stem_pascal = to_pascal_case(&stem); + let name = schema + .get("name") + .and_then(Value::as_str) + .filter(|s| is_reverse_domain_name(s)) + .map(str::to_string); + let parent_dir = path + .parent() + .and_then(Path::file_name) + .and_then(|s| s.to_str()); + let in_types_dir = parent_dir == Some("types"); + let in_transports_dir = parent_dir == Some("transports"); + let is_extension = is_extension_schema(&schema); + let is_container = + is_container_capability(&schema, is_extension, in_types_dir || in_transports_dir); + let is_capability = !is_extension + && !in_types_dir + && !in_transports_dir + && !AMBIENT_ROOT_STEMS.contains(&stem.as_str()) + && (name.is_some() || is_container); + Self { + path, + stem, + stem_pascal, + name, + is_extension, + is_container, + is_capability, + in_types_dir, + schema, + } + } +} + +pub(super) fn load_all_schemas(schema_dir: &Path) -> Result, CodegenError> { + collect_schema_files(schema_dir) + .into_iter() + .map(|p| Ok(LoadedSchema::from_file(&p, load_schema(&p)?))) + .collect() +} + +#[allow(clippy::type_complexity)] +pub(super) fn select_active_schemas( + loaded: &[LoadedSchema], + cap_queries: Option<&[String]>, + ext_queries: Option<&[String]>, +) -> Result<(BTreeSet, BTreeSet, BTreeSet), CodegenError> { + let cap_queries = cap_queries.filter(|q| !q.is_empty()); + let ext_queries = ext_queries.filter(|q| !q.is_empty()); + let mut active_caps = BTreeSet::new(); + let mut active_exts = BTreeSet::new(); + let resolve_query = |query: &str, kind: &str| -> Result { + let trimmed = query.trim(); + loaded + .iter() + .position(|item| item.name.as_deref() == Some(trimmed)) + .ok_or_else(|| { + CodegenError::ComposeError(ComposeError::InvalidCapability { + name: query.to_string(), + message: format!("{kind} schema not found in schema_dir"), + }) + }) + }; + + if let Some(queries) = cap_queries { + for query in queries { + let idx = resolve_query(query, "capability")?; + if loaded[idx].is_extension { + active_exts.insert(idx); + } else { + active_caps.insert(idx); + } + } + } else { + active_caps.extend( + loaded + .iter() + .enumerate() + .filter_map(|(i, s)| s.is_capability.then_some(i)), + ); + } + + if let Some(queries) = ext_queries { + for query in queries { + let idx = resolve_query(query, "extension")?; + if !loaded[idx].is_extension { + return Err(CodegenError::ComposeError( + ComposeError::InvalidCapability { + name: query.to_string(), + message: "schema is a capability, not an extension".to_string(), + }, + )); + } + active_exts.insert(idx); + } + } else if cap_queries.is_none() { + active_exts.extend( + loaded + .iter() + .enumerate() + .filter_map(|(i, s)| s.is_extension.then_some(i)), + ); + } + + let mut active_capabilities = BTreeSet::new(); + for &idx in &active_caps { + let item = &loaded[idx]; + active_capabilities.insert(item.stem.clone()); + if let Some(name) = &item.name { + active_capabilities.insert(name.clone()); + } + } + + Ok((active_caps, active_exts, active_capabilities)) +} + +pub(super) fn compute_reachable_closure( + loaded: &[LoadedSchema], + active_caps: &BTreeSet, + active_exts: &BTreeSet, + active_capabilities: &BTreeSet, + include_all: bool, +) -> BTreeSet { + if include_all { + return (0..loaded.len()).collect(); + } + + let path_to_idx: BTreeMap = loaded + .iter() + .enumerate() + .map(|(i, item)| (item.path.clone(), i)) + .collect(); + + let mut reachable = BTreeSet::new(); + let mut queue = VecDeque::new(); + + for (idx, item) in loaded.iter().enumerate() { + let is_ambient = AMBIENT_ROOT_STEMS.contains(&item.stem.as_str()) + && (!item.in_types_dir || item.stem == "error_response"); + if (is_ambient || active_caps.contains(&idx) || active_exts.contains(&idx)) + && reachable.insert(idx) + { + queue.push_back(idx); + } + } + + while let Some(idx) = queue.pop_front() { + let item = &loaded[idx]; + let Some(parent_dir) = item.path.parent() else { + continue; + }; + let inactive_locals = compute_inactive_local_defs(item, active_capabilities); + for ref_str in collect_active_external_refs(item, &inactive_locals) { + let file_part = ref_str.split('#').next().unwrap_or(""); + if file_part.is_empty() { + continue; + } + let resolved_path = normalize_lexical_path(&parent_dir.join(file_part)); + if let Some(&t_idx) = path_to_idx.get(&resolved_path) { + if reachable.insert(t_idx) { + queue.push_back(t_idx); + } + } + } + } + reachable +} + +pub(super) fn compute_inactive_local_defs( + item: &LoadedSchema, + active_capabilities: &BTreeSet, +) -> BTreeSet { + let mut inactive = BTreeSet::new(); + let Some(defs_obj) = item.schema.get("$defs").and_then(Value::as_object) else { + return inactive; + }; + + if item.is_capability { + for (def_key, def_val) in defs_obj { + if !active_capabilities.contains(def_key) + && is_unreferenced_cross_capability_overlay(item, def_key, def_val) + { + inactive.insert(def_key.clone()); + } + } + } + if !item.is_extension { + return inactive; + } + + let mut active_seeds = Vec::new(); + let mut inactive_seeds = Vec::new(); + for (k, v) in defs_obj { + if !is_reverse_domain_name(k) { + continue; + } + if item.name.as_deref() == Some(k.as_str()) || active_capabilities.contains(k) { + active_seeds.push(v); + } else { + inactive.insert(k.clone()); + inactive_seeds.push(v); + } + } + + let used_by_active = transitive_local_def_refs(&active_seeds, defs_obj); + let used_by_inactive = transitive_local_def_refs(&inactive_seeds, defs_obj); + + for (def_key, def_val) in defs_obj { + if is_reverse_domain_name(def_key) || used_by_active.contains(def_key) { + continue; + } + let is_external_ref_alias = def_val + .get("$ref") + .and_then(Value::as_str) + .is_some_and(|r| !r.starts_with('#')); + if used_by_inactive.contains(def_key) || is_external_ref_alias { + inactive.insert(def_key.clone()); + } + } + inactive +} + +fn is_unreferenced_cross_capability_overlay( + item: &LoadedSchema, + def_key: &str, + def_val: &Value, +) -> bool { + let target_file = format!("{def_key}.json"); + let target_suffix = format!("/{target_file}"); + let extends_other_cap = def_val + .get("allOf") + .and_then(Value::as_array) + .is_some_and(|all_of| { + all_of.iter().any(|b| { + b.get("$ref") + .and_then(Value::as_str) + .is_some_and(|r| r == target_file || r.ends_with(&target_suffix)) + }) + }); + if !extends_other_cap { + return false; + } + let local_ref = format!("#/$defs/{def_key}"); + let Some(root_obj) = item.schema.as_object() else { + return true; + }; + !root_obj.iter().any(|(k, v)| { + if k == "$defs" { + v.as_object().is_some_and(|defs| { + defs.iter().any(|(other_k, other_v)| { + other_k != def_key && contains_ref(other_v, &local_ref) + }) + }) + } else { + contains_ref(v, &local_ref) + } + }) +} + +pub(super) fn is_container_op_key(key: &str) -> bool { + key.ends_with("_request") || key.ends_with("_response") +} + +pub(super) fn normalize_lexical_path(path: &Path) -> PathBuf { + let mut out = PathBuf::new(); + for comp in path.components() { + match comp { + Component::CurDir => {} + Component::ParentDir => { + out.pop(); + } + other => out.push(other.as_os_str()), + } + } + out +} + +fn is_extension_schema(schema: &Value) -> bool { + let own_name = schema.get("name").and_then(Value::as_str); + schema + .get("$defs") + .and_then(Value::as_object) + .is_some_and(|defs| { + defs.keys() + .any(|k| is_reverse_domain_name(k) && Some(k.as_str()) != own_name) + }) +} + +fn is_container_capability(schema: &Value, is_extension: bool, in_types_dir: bool) -> bool { + if is_extension || in_types_dir || !is_container_schema(schema) { + return false; + } + let Some(obj) = schema.as_object() else { + return false; + }; + if obj.contains_key("oneOf") + || obj.contains_key("anyOf") + || obj.contains_key("propertyNames") + || obj + .get("additionalProperties") + .is_some_and(Value::is_object) + { + return false; + } + obj.get("$defs") + .and_then(Value::as_object) + .is_some_and(|defs| defs.keys().any(|k| is_container_op_key(k))) +} + +fn transitive_local_def_refs(seeds: &[&Value], defs_obj: &Map) -> BTreeSet { + let mut visited = BTreeSet::new(); + let mut queue = VecDeque::new(); + let push_refs = |val: &Value, q: &mut VecDeque| { + for_each_schema_object(val, &mut |obj| { + if let Some(k) = obj + .get("$ref") + .and_then(Value::as_str) + .and_then(|r| r.strip_prefix("#/$defs/")) + { + q.push_back(k.to_string()); + } + }); + }; + + for seed in seeds { + push_refs(seed, &mut queue); + } + while let Some(def_key) = queue.pop_front() { + if visited.insert(def_key.clone()) { + if let Some(def_val) = defs_obj.get(&def_key) { + push_refs(def_val, &mut queue); + } + } + } + visited +} + +fn collect_active_external_refs( + item: &LoadedSchema, + inactive_locals: &BTreeSet, +) -> Vec { + let mut out = Vec::new(); + let mut push_ext_refs = |val: &Value| { + for_each_schema_object(val, &mut |obj| { + if let Some(r) = obj.get("$ref").and_then(Value::as_str) { + if !r.starts_with('#') { + out.push(r.to_string()); + } + } + }); + }; + + let Some(root_obj) = item.schema.as_object() else { + return out; + }; + for (k, v) in root_obj { + if matches!(k.as_str(), "embedded" | "requires") { + continue; + } + if k != "$defs" { + push_ext_refs(v); + continue; + } + let Some(defs_obj) = v.as_object() else { + continue; + }; + for (def_k, def_v) in defs_obj { + if !inactive_locals.contains(def_k) { + push_ext_refs(def_v); + } + } + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn select_active_schemas_reclassifies_extensions_and_supports_default_caps_with_explicit_exts() + { + let loaded = vec![ + LoadedSchema::from_file( + "/home/user/types/transports/schemas/shopping/checkout.json", + json!({"name": "dev.ucp.shopping.checkout", "type": "object", "properties": {"id": {"type": "string"}}}), + ), + LoadedSchema::from_file( + "/home/user/types/transports/schemas/shopping/discount.json", + json!({"name": "dev.ucp.shopping.discount", "$defs": {"dev.ucp.shopping.checkout": {"properties": {}}}}), + ), + LoadedSchema::from_file( + "/home/user/types/transports/schemas/shopping/types/discount.json", + json!({"type": "object"}), + ), + ]; + assert!(loaded[0].is_capability && !loaded[0].in_types_dir); + assert!(loaded[2].in_types_dir && !loaded[2].is_capability); + + // 1. Passing an extension in `cap_queries` reclassifies it into `active_exts` + let caps = vec![ + "dev.ucp.shopping.checkout".to_string(), + "dev.ucp.shopping.discount".to_string(), + ]; + let (active_caps, active_exts, active_names) = + select_active_schemas(&loaded, Some(&caps), None).unwrap(); + assert_eq!(active_caps, BTreeSet::from([0])); + assert_eq!(active_exts, BTreeSet::from([1])); + assert!(active_names.contains("checkout")); + assert!(active_names.contains("dev.ucp.shopping.checkout")); + + // 2. `cap_queries: None` with explicit `ext_queries` activates all capabilities and selects requested extensions + let exts = vec!["dev.ucp.shopping.discount".to_string()]; + let (all_caps, selected_exts, _) = + select_active_schemas(&loaded, None, Some(&exts)).unwrap(); + assert_eq!(all_caps, BTreeSet::from([0])); + assert_eq!(selected_exts, BTreeSet::from([1])); + + // 3. Empty slices `Some(&[])` normalize to `None` (selecting all capabilities and extensions) + let (all_caps_empty, all_exts_empty, _) = + select_active_schemas(&loaded, Some(&[]), Some(&[])).unwrap(); + assert_eq!(all_caps_empty, BTreeSet::from([0])); + assert_eq!(all_exts_empty, BTreeSet::from([1])); + + // 4. Short names ("checkout") are rejected with InvalidCapability + let err = select_active_schemas(&loaded, Some(&["checkout".to_string()]), None) + .expect_err("short capability names must be rejected"); + assert!(matches!( + err, + CodegenError::ComposeError(ComposeError::InvalidCapability { .. }) + )); + + // 5. Passing a non-extension capability in `ext_queries` is rejected with InvalidCapability + let err = select_active_schemas( + &loaded, + None, + Some(&["dev.ucp.shopping.checkout".to_string()]), + ) + .expect_err("root capabilities in ext_queries must be rejected"); + assert!(matches!( + err, + CodegenError::ComposeError(ComposeError::InvalidCapability { .. }) + )); + } + + #[test] + fn compute_inactive_local_defs_prunes_inactive_extension_helpers_and_cart_checkout() { + let ext = LoadedSchema::from_file( + "/schemas/shopping/ext.json", + json!({ + "name": "dev.ucp.shopping.ext", + "$defs": { + "active_helper": { "type": "object" }, + "shared_helper": { "type": "object" }, + "inactive_helper": { + "type": "object", + "properties": { "nested": { "$ref": "#/$defs/inactive_leaf" } } + }, + "inactive_leaf": { "type": "string" }, + "dev.ucp.shopping.checkout": { + "properties": { + "a": { "$ref": "#/$defs/active_helper" }, + "s": { "$ref": "#/$defs/shared_helper" } + } + }, + "dev.ucp.shopping.order": { + "properties": { + "i": { "$ref": "#/$defs/inactive_helper" }, + "s": { "$ref": "#/$defs/shared_helper" } + } + } + } + }), + ); + + let active_caps = BTreeSet::from([ + "checkout".to_string(), + "dev.ucp.shopping.checkout".to_string(), + ]); + let inactive = compute_inactive_local_defs(&ext, &active_caps); + assert!(inactive.contains("inactive_helper")); + assert!(inactive.contains("inactive_leaf")); + assert!(!inactive.contains("active_helper")); + assert!(!inactive.contains("shared_helper")); + + let cart = LoadedSchema::from_file( + "/schemas/shopping/cart.json", + json!({ + "name": "dev.ucp.shopping.cart", + "type": "object", + "$defs": { + "checkout": { + "allOf": [{ "$ref": "checkout.json" }, { "type": "object" }] + }, + "internal_helper": { + "allOf": [{ "$ref": "internal_helper.json" }], + "type": "object" + } + }, + "properties": { + "helper": { "$ref": "#/$defs/internal_helper" } + } + }), + ); + let cart_only_caps = BTreeSet::from(["cart".to_string()]); + let cart_inactive = compute_inactive_local_defs(&cart, &cart_only_caps); + assert!(cart_inactive.contains("checkout")); + assert!(!cart_inactive.contains("internal_helper")); + } + + #[test] + fn compute_reachable_closure_follows_active_refs_and_skips_inactive_extension_refs() { + let loaded = vec![ + LoadedSchema::from_file("/schemas/ucp.json", json!({"type": "object"})), + LoadedSchema::from_file( + "/schemas/shopping/types/service.json", + json!({"type": "object"}), + ), + LoadedSchema::from_file( + "/schemas/shopping/checkout.json", + json!({ + "name": "dev.ucp.shopping.checkout", + "type": "object", + "properties": { "buyer": { "$ref": "types/buyer.json" } } + }), + ), + LoadedSchema::from_file( + "/schemas/shopping/types/buyer.json", + json!({"type": "object"}), + ), + LoadedSchema::from_file( + "/schemas/shopping/types/order_only.json", + json!({"type": "object"}), + ), + LoadedSchema::from_file( + "/schemas/shopping/ext.json", + json!({ + "name": "dev.ucp.shopping.ext", + "$defs": { + "order_helper": { "$ref": "types/order_only.json" }, + "dev.ucp.shopping.checkout": { "properties": {} }, + "dev.ucp.shopping.order": { + "properties": { "o": { "$ref": "#/$defs/order_helper" } } + } + } + }), + ), + ]; + + let reachable = compute_reachable_closure( + &loaded, + &BTreeSet::from([2]), + &BTreeSet::from([5]), + &BTreeSet::from([ + "checkout".to_string(), + "dev.ucp.shopping.checkout".to_string(), + ]), + false, + ); + // Ambient root ucp.json (0), checkout.json (2), buyer.json (3), ext.json (5) are reachable; + // types/service.json (1, in types/ dir) and types/order_only.json (4, only referenced by inactive order) are not. + assert_eq!(reachable, BTreeSet::from([0, 2, 3, 5])); + } +} diff --git a/src/lib.rs b/src/lib.rs index e44b3b9..74bf7af 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -64,6 +64,10 @@ mod resolver; mod types; mod validator; +pub use codegen::{ + compile_types, generate_types, CodegenError, CompiledTypes, GenerateTypesOptions, + TypesBundleDoc, +}; pub use compose::{ capability_short_name, check_version_constraints, compose_from_payload, compose_schema, detect_direction, extract_capabilities, extract_capabilities_from_profile, diff --git a/tests/generate_types_test.rs b/tests/generate_types_test.rs new file mode 100644 index 0000000..cd5de47 --- /dev/null +++ b/tests/generate_types_test.rs @@ -0,0 +1,670 @@ +use std::collections::BTreeSet; +use std::path::{Path, PathBuf}; + +use serde_json::Value; +use ucp_schema::{compile_types, generate_types, CodegenError, GenerateTypesOptions}; + +fn fixture_schemas_dir() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/compose/schemas") +} + +fn ucp_schemas_dir() -> Option { + let candidate = Path::new(env!("CARGO_MANIFEST_DIR")).join("../ucp/source/schemas"); + candidate.exists().then_some(candidate) +} + +fn visit_schema_objects(val: &Value, f: &mut impl FnMut(&serde_json::Map)) { + match val { + Value::Object(obj) => { + f(obj); + for (k, v) in obj { + if !["const", "enum", "default", "examples"].contains(&k.as_str()) { + visit_schema_objects(v, f); + } + } + } + Value::Array(arr) => arr.iter().for_each(|v| visit_schema_objects(v, f)), + _ => {} + } +} + +fn assert_bundle_invariants(defs: &std::collections::BTreeMap) { + let known: BTreeSet<&str> = defs.keys().map(String::as_str).collect(); + for (def_name, schema_val) in defs { + assert_eq!( + schema_val.get("title").and_then(Value::as_str), + Some(def_name.as_str()), + "def '{def_name}' must have canonical title equal to its $defs key" + ); + visit_schema_objects(schema_val, &mut |obj| { + if let Some(r) = obj.get("$ref").and_then(Value::as_str) { + assert!( + !obj.contains_key("properties"), + "def '{def_name}' contains hybrid $ref + inline properties node" + ); + let target = r + .strip_prefix("#/$defs/") + .unwrap_or_else(|| panic!("def '{def_name}' contains non-local $ref: '{r}'")); + assert!( + known.contains(target), + "def '{def_name}' contains dangling $ref '#/$defs/{target}'" + ); + } + if let Some(all_of) = obj.get("allOf").and_then(Value::as_array) { + assert!( + all_of.iter().all(|b| b.get("title").is_none()), + "def '{def_name}' contains inline allOf branch title" + ); + } + assert!( + !obj.keys().any(|k| { + k == "ucp_request" || k == "ucp_response" || k == "x-ucp-schema-transition" + }), + "def '{def_name}' still contains UCP annotations" + ); + }); + } +} + +fn assert_has_defs(defs: &std::collections::BTreeMap, names: &[&str]) { + for &n in names { + assert!(defs.contains_key(n), "expected def '{n}' in bundle.defs"); + } +} + +fn assert_lacks_defs(defs: &std::collections::BTreeMap, names: &[&str]) { + for &n in names { + assert!( + !defs.contains_key(n), + "expected def '{n}' to be excluded/pruned" + ); + } +} + +#[test] +fn missing_or_nonexistent_schema_dir_returns_expected_errors() { + let opts = GenerateTypesOptions::default(); + let err = generate_types(&opts).expect_err("expected error when schema_dir is None"); + assert_eq!(err.exit_code(), 2); + + let missing_opts = GenerateTypesOptions::new().schema_dir("/nonexistent/ucp/schemas/path"); + let err = generate_types(&missing_opts).expect_err("expected FileNotFound error"); + assert_eq!(err.exit_code(), 3); +} + +#[test] +fn unknown_capability_or_extension_returns_invalid_capability_error() { + let bad_cap = GenerateTypesOptions::new() + .schema_dir(fixture_schemas_dir()) + .capabilities(["dev.ucp.shopping.nonexistent"]); + let err = generate_types(&bad_cap).expect_err("unknown capability must error"); + assert!(matches!(err, CodegenError::ComposeError(_))); + assert_eq!(err.exit_code(), 2); + + let bad_ext = GenerateTypesOptions::new() + .schema_dir(fixture_schemas_dir()) + .capabilities(["dev.ucp.shopping.checkout"]) + .extensions(["dev.ucp.shopping.nonexistent_ext"]); + let err = generate_types(&bad_ext).expect_err("unknown extension must error"); + assert!(matches!(err, CodegenError::ComposeError(_))); + assert_eq!(err.exit_code(), 2); + + let cap_as_ext = GenerateTypesOptions::new() + .schema_dir(fixture_schemas_dir()) + .extensions(["dev.ucp.shopping.checkout"]); + let err = + generate_types(&cap_as_ext).expect_err("passing capability in extensions() must error"); + assert!(matches!(err, CodegenError::ComposeError(_))); + assert_eq!(err.exit_code(), 2); +} + +#[test] +fn fixture_composes_extensions_and_slices_directionally() { + let opts = GenerateTypesOptions::new() + .schema_dir(fixture_schemas_dir()) + .capabilities(["dev.ucp.shopping.checkout"]) + .extensions(["dev.ucp.shopping.discount"]) + .title("Custom Shopping Bundle") + .description("Test bundle"); + + let bundle = generate_types(&opts).unwrap(); + assert_eq!( + bundle.schema_dialect, + "https://json-schema.org/draft/2020-12/schema" + ); + assert_eq!(bundle.title, "Custom Shopping Bundle"); + assert_eq!(bundle.description.as_deref(), Some("Test bundle")); + assert_bundle_invariants(&bundle.defs); + assert_has_defs( + &bundle.defs, + &["Checkout", "CheckoutCreateRequest", "CheckoutUpdateRequest"], + ); + assert_lacks_defs(&bundle.defs, &["Cart"]); + + let c_props = &bundle.defs["Checkout"]["properties"]; + let cr_props = &bundle.defs["CheckoutCreateRequest"]["properties"]; + let ur_props = &bundle.defs["CheckoutUpdateRequest"]["properties"]; + assert!(c_props.get("discounts").is_some()); + assert!(cr_props.get("discounts").is_some()); + assert!(cr_props.get("id").is_none()); + assert!(ur_props.get("id").is_some()); +} + +#[test] +fn ucp_corpus_checkout_with_discount_scoping_and_slicing() { + let Some(schema_dir) = ucp_schemas_dir() else { + return; + }; + + let opts = GenerateTypesOptions::new() + .schema_dir(schema_dir) + .capabilities(["dev.ucp.shopping.checkout"]) + .extensions(["dev.ucp.shopping.discount"]); + + let compiled = compile_types(&opts).unwrap(); + assert!(compiled + .active_capabilities + .contains("dev.ucp.shopping.checkout")); + assert!(compiled.active_capabilities.contains("checkout")); + assert!(!compiled.active_capabilities.contains("cart")); + assert!(!compiled.active_capabilities.contains("order")); + + let bundle = generate_types(&opts).unwrap(); + assert_bundle_invariants(&bundle.defs); + assert_has_defs( + &bundle.defs, + &[ + "Checkout", + "CheckoutCreateRequest", + "CheckoutUpdateRequest", + "CheckoutCompleteRequest", + "DiscountsObject", + "DiscountsObjectCreateRequest", + "DiscountsObjectUpdateRequest", + "AppliedDiscount", + "Allocation", + "LineItem", + "LineItemCreateRequest", + "LineItemUpdateRequest", + "Buyer", + "Total", + "ErrorResponse", + "UcpBase", + ], + ); + assert_lacks_defs( + &bundle.defs, + &[ + "Cart", + "CartCreateRequest", + "Order", + "Booking", + "CatalogSearchRequest", + "CatalogLookupRequest", + "UcpBaseCreateRequest", + "UcpBaseUpdateRequest", + ], + ); + + assert_eq!( + bundle.defs["Checkout"]["properties"]["discounts"]["$ref"], + "#/$defs/DiscountsObject" + ); + assert_eq!( + bundle.defs["CheckoutCreateRequest"]["properties"]["discounts"]["$ref"], + "#/$defs/DiscountsObjectCreateRequest" + ); + assert_eq!( + bundle.defs["CheckoutUpdateRequest"]["properties"]["discounts"]["$ref"], + "#/$defs/DiscountsObjectUpdateRequest" + ); + assert!(bundle.defs["CheckoutCompleteRequest"]["properties"] + .get("discounts") + .is_none()); + assert!(bundle.defs["DiscountsObjectCreateRequest"]["properties"] + .get("applied") + .is_none()); + assert!(bundle.defs["DiscountsObject"]["properties"] + .get("applied") + .is_some()); +} + +#[test] +fn ucp_corpus_multi_extension_and_container_composition() { + let Some(schema_dir) = ucp_schemas_dir() else { + return; + }; + + let opts = GenerateTypesOptions::new() + .schema_dir(schema_dir) + .capabilities([ + "dev.ucp.shopping.checkout", + "dev.ucp.shopping.catalog.search", + "dev.ucp.shopping.catalog.lookup", + ]) + .extensions([ + "dev.ucp.shopping.buyer_consent", + "dev.ucp.shopping.fulfillment", + "dev.ucp.common.payment.terms", + "dev.ucp.common.payment.split_payments", + "dev.ucp.common.loyalty", + ]); + + let bundle = generate_types(&opts).unwrap(); + assert_bundle_invariants(&bundle.defs); + + assert_eq!( + bundle.defs["Buyer"]["properties"]["consent"]["$ref"], + "#/$defs/Consent" + ); + assert_has_defs( + &bundle.defs, + &[ + "ConsentPurpose", + "ConsentSegment", + "BusinessSplitPaymentsConfig", + "PaymentSplitPaymentsBusinessSchema", + ], + ); + assert_lacks_defs( + &bundle.defs, + &[ + "ConsentPurposeCreateRequest", + "ConsentPurposeUpdateRequest", + "ConsentSegmentCreateRequest", + "ConsentSegmentUpdateRequest", + "FulfillmentSearchRequest", + "FulfillmentSearchResponse", + "FulfillmentLookupRequest", + "FulfillmentLookupResponse", + "FulfillmentGetProductRequest", + "FulfillmentGetProductResponse", + ], + ); + assert!(bundle.defs["CheckoutCompleteRequest"]["properties"] + .get("buyer") + .is_some()); + + assert!(bundle.defs["Checkout"]["properties"]["payment"] + .get("properties") + .is_none()); + let p_props = &bundle.defs["Payment"]["properties"]; + let p_cr_props = &bundle.defs["PaymentCreateRequest"]["properties"]; + let p_ur_props = &bundle.defs["PaymentUpdateRequest"]["properties"]; + assert_eq!( + p_props["instruments"]["items"]["$ref"], + "#/$defs/SelectedPaymentInstrument" + ); + assert!(p_props.get("terms").is_some()); + assert!(p_props.get("selected_term_id").is_some()); + assert!(p_cr_props.get("terms").is_none()); + assert!(p_cr_props.get("selected_term_id").is_none()); + assert!(p_ur_props.get("selected_term_id").is_some()); + assert_eq!( + bundle.defs["PaymentCompleteRequest"]["required"], + serde_json::json!(["instruments"]) + ); + assert_eq!( + bundle.defs["PaymentInstrument"]["properties"]["amount"]["$ref"], + "#/$defs/Amount" + ); + + assert_eq!( + bundle.defs["CatalogSearchRequest"]["properties"]["filters"]["$ref"], + "#/$defs/FulfillmentSearchFilters" + ); + assert_eq!( + bundle.defs["CatalogSearchResponse"]["properties"]["products"]["items"]["$ref"], + "#/$defs/FulfillmentProduct" + ); + for resp in [ + "CatalogSearchResponse", + "CatalogLookupResponse", + "CatalogGetProductResponse", + ] { + assert_eq!( + bundle.defs[resp]["properties"]["loyalty"]["$ref"], + "#/$defs/Loyalty" + ); + } + assert_eq!( + bundle.defs["CatalogGetProductResponse"]["properties"]["product"]["$ref"], + "#/$defs/FulfillmentDetailProduct" + ); + assert!(bundle.defs["CatalogLookupRequest"]["properties"] + .get("loyalty") + .is_none()); +} + +#[test] +fn ucp_corpus_full_compilation_has_zero_dangling_refs_or_annotations() { + let Some(schema_dir) = ucp_schemas_dir() else { + return; + }; + + let bundle = generate_types(&GenerateTypesOptions::new().schema_dir(schema_dir)).unwrap(); + assert_bundle_invariants(&bundle.defs); + + assert_has_defs( + &bundle.defs, + &[ + "Provider", + "ScopePolicy", + "ScopeToken", + "IdentityLinkingPlatformSchema", + "IdentityLinkingBusinessSchema", + "PermalinkEndpoint", + "PermalinkConfig", + "PermalinkPlatformSchema", + "PermalinkBusinessSchema", + "PermalinkResponseSchema", + "FulfillmentPlatformSchema", + "FulfillmentBusinessSchema", + "PaymentSplitPaymentsBusinessSchema", + "ErrorCode", + "PaymentAp2MandateErrorCode", + "ErrorResponse", + "JsonrpcErrorResponse", + "Message", + "A2aMessageMessage", + "Actions", + "Instance", + "Policy", + "CancellationItem", + "CatalogSearchRequest", + "CatalogSearchResponse", + "CatalogLookupRequest", + "CatalogLookupResponse", + "CatalogGetProductRequest", + "CatalogGetProductResponse", + "LocationSearchRequest", + "LocationSearchResponse", + "LocationLookupRequest", + "LocationLookupResponse", + ], + ); + assert_lacks_defs( + &bundle.defs, + &[ + "PaymentActions", + "FulfillmentSearchRequest", + "FulfillmentSearchResponse", + "FulfillmentLookupRequest", + "FulfillmentLookupResponse", + "FulfillmentGetProductRequest", + "FulfillmentGetProductResponse", + "LocationCreateRequest", + "LocationUpdateRequest", + "DailyHourCreateRequest", + "DailyHourUpdateRequest", + "ExceptionHourCreateRequest", + "ExceptionHourUpdateRequest", + "StayCompleteRequest", + "TokenCredentialCreateRequest", + "TokenCredentialUpdateRequest", + "TokenCredentialCompleteRequest", + ], + ); + + assert!(bundle.defs["Checkout"]["properties"]["actions"] + .get("properties") + .is_none()); + assert_eq!( + bundle.defs["Checkout"]["properties"]["actions"]["$ref"], + "#/$defs/Actions" + ); + assert!(bundle.defs["Actions"]["properties"] + .get("dev.ucp.common.payment.device_data_collection") + .is_some()); + assert!(bundle.defs["Actions"]["properties"] + .get("dev.ucp.common.payment.three_ds_challenge") + .is_some()); + + assert_eq!( + bundle.defs["Booking"]["properties"]["policies"]["items"], + serde_json::json!({ "$ref": "#/$defs/Policy" }) + ); + assert_eq!( + bundle.defs["Policy"]["allOf"][0]["then"]["$ref"], + "#/$defs/CancellationItem" + ); + + let complete_ap2_allof = bundle.defs["CheckoutCompleteRequest"]["properties"]["ap2"]["allOf"] + .as_array() + .expect("CheckoutCompleteRequest.properties.ap2.allOf must be an array"); + assert_eq!(complete_ap2_allof.len(), 1); + assert_eq!( + complete_ap2_allof[0]["$ref"], + "#/$defs/Ap2WithCheckoutMandateCompleteRequest" + ); +} + +#[test] +fn ucp_corpus_reclassification_and_cart_checkout_overlay() { + let Some(schema_dir) = ucp_schemas_dir() else { + return; + }; + + let checkout_only = generate_types( + &GenerateTypesOptions::new() + .schema_dir(&schema_dir) + .capabilities(["dev.ucp.shopping.checkout"]), + ) + .unwrap(); + assert_bundle_invariants(&checkout_only.defs); + assert!(checkout_only.defs["Checkout"]["properties"] + .get("discounts") + .is_none()); + assert!(checkout_only.defs["Checkout"]["properties"] + .get("fulfillment") + .is_none()); + + let reclassified = generate_types( + &GenerateTypesOptions::new() + .schema_dir(&schema_dir) + .capabilities(["dev.ucp.shopping.checkout", "dev.ucp.shopping.fulfillment"]), + ) + .unwrap(); + assert_bundle_invariants(&reclassified.defs); + assert_eq!( + reclassified.defs["Checkout"]["properties"]["fulfillment"]["$ref"], + "#/$defs/Fulfillment" + ); + assert!(reclassified.defs["Checkout"]["properties"] + .get("discounts") + .is_none()); + + let cart_only = generate_types( + &GenerateTypesOptions::new() + .schema_dir(&schema_dir) + .capabilities(["dev.ucp.shopping.cart"]), + ) + .unwrap(); + assert_bundle_invariants(&cart_only.defs); + assert_has_defs(&cart_only.defs, &["Cart"]); + assert_lacks_defs(&cart_only.defs, &["Checkout"]); + + let cart_and_checkout = generate_types( + &GenerateTypesOptions::new() + .schema_dir(&schema_dir) + .capabilities(["dev.ucp.shopping.cart", "dev.ucp.shopping.checkout"]), + ) + .unwrap(); + assert_bundle_invariants(&cart_and_checkout.defs); + assert!( + cart_and_checkout.defs["CheckoutCreateRequest"]["properties"] + .get("cart_id") + .is_some() + ); + + let err = generate_types( + &GenerateTypesOptions::new() + .schema_dir(&schema_dir) + .capabilities(["checkout"]), + ) + .expect_err("short capability names must be rejected"); + assert_eq!(err.exit_code(), 2); +} + +#[test] +fn cli_generate_types_stdout_pretty_and_compact() { + let bin = env!("CARGO_BIN_EXE_ucp-schema"); + let schema_dir = fixture_schemas_dir(); + + // 1. Default --pretty is true (multi-line JSON to stdout) + let out = std::process::Command::new(bin) + .args([ + "generate-types", + "-s", + schema_dir.to_str().unwrap(), + "--capability", + "dev.ucp.shopping.checkout", + "--extension", + "dev.ucp.shopping.discount", + ]) + .output() + .expect("run ucp-schema generate-types"); + assert!( + out.status.success(), + "stderr: {}", + String::from_utf8_lossy(&out.stderr) + ); + let stdout = String::from_utf8(out.stdout).unwrap(); + assert!( + stdout.lines().count() > 5, + "expected pretty multi-line JSON by default" + ); + let doc: ucp_schema::TypesBundleDoc = serde_json::from_str(&stdout).unwrap(); + assert_eq!( + doc.schema_dialect, + "https://json-schema.org/draft/2020-12/schema" + ); + assert_eq!(doc.title, "UCP Schema Types"); + assert_bundle_invariants(&doc.defs); + assert!(doc.defs.contains_key("Checkout")); + assert!(doc.defs.contains_key("CheckoutCreateRequest")); + assert!(doc.defs["Checkout"]["properties"] + .get("discounts") + .is_some()); + assert!(!doc.defs.contains_key("Cart")); + + // 2. --pretty=false emits compact single-line JSON, and --schema-local-base alias works + let compact_out = std::process::Command::new(bin) + .args([ + "generate-types", + "--schema-local-base", + schema_dir.to_str().unwrap(), + "--capabilities", + "dev.ucp.shopping.checkout,dev.ucp.shopping.cart", + "--extensions", + "dev.ucp.shopping.discount", + "--pretty=false", + ]) + .output() + .expect("run ucp-schema generate-types --pretty=false"); + assert!( + compact_out.status.success(), + "stderr: {}", + String::from_utf8_lossy(&compact_out.stderr) + ); + let compact_stdout = String::from_utf8(compact_out.stdout).unwrap(); + assert_eq!( + compact_stdout.trim().lines().count(), + 1, + "expected single-line JSON with --pretty=false" + ); + let compact_doc: ucp_schema::TypesBundleDoc = serde_json::from_str(&compact_stdout).unwrap(); + assert_bundle_invariants(&compact_doc.defs); + assert!(compact_doc.defs.contains_key("Checkout")); + assert!(compact_doc.defs.contains_key("Cart")); +} + +#[test] +fn cli_generate_types_output_file_and_repeatable_flags() { + let bin = env!("CARGO_BIN_EXE_ucp-schema"); + let schema_dir = fixture_schemas_dir(); + let tmp = tempfile::tempdir().unwrap(); + let output_path = tmp.path().join("bundle.types.json"); + + let out = std::process::Command::new(bin) + .args([ + "generate-types", + "--schema-dir", + schema_dir.to_str().unwrap(), + "--capability", + "dev.ucp.shopping.checkout", + "--capability", + "dev.ucp.shopping.cart", + "--extension", + "dev.ucp.shopping.discount", + "--pretty", + "-o", + output_path.to_str().unwrap(), + ]) + .output() + .expect("run ucp-schema generate-types -o"); + assert!( + out.status.success(), + "stderr: {}", + String::from_utf8_lossy(&out.stderr) + ); + assert!( + out.stdout.is_empty(), + "stdout should be empty when -o is used" + ); + + let file_contents = std::fs::read_to_string(&output_path).unwrap(); + let doc: ucp_schema::TypesBundleDoc = serde_json::from_str(&file_contents).unwrap(); + assert_bundle_invariants(&doc.defs); + assert!(doc.defs.contains_key("Checkout")); + assert!(doc.defs.contains_key("Cart")); + assert!(doc.defs["Checkout"]["properties"] + .get("discounts") + .is_some()); +} + +#[test] +fn cli_generate_types_error_exit_codes() { + let bin = env!("CARGO_BIN_EXE_ucp-schema"); + let schema_dir = fixture_schemas_dir(); + + // 1. Missing --schema-dir without --profile -> exit code 2 + let missing_dir = std::process::Command::new(bin) + .arg("generate-types") + .output() + .unwrap(); + assert_eq!(missing_dir.status.code(), Some(2)); + + // 2. Nonexistent --schema-dir -> exit code 3 + let bad_dir = std::process::Command::new(bin) + .args(["generate-types", "--schema-dir", "/nonexistent/ucp/schemas"]) + .output() + .unwrap(); + assert_eq!(bad_dir.status.code(), Some(3)); + + // 3. Unknown capability -> exit code 2 + let bad_cap = std::process::Command::new(bin) + .args([ + "generate-types", + "--schema-dir", + schema_dir.to_str().unwrap(), + "--capability", + "dev.ucp.shopping.nonexistent", + ]) + .output() + .unwrap(); + assert_eq!(bad_cap.status.code(), Some(2)); + + // 4. Unwritable --output path -> exit code 3 + let bad_out = std::process::Command::new(bin) + .args([ + "generate-types", + "--schema-dir", + schema_dir.to_str().unwrap(), + "--output", + "/nonexistent/dir/bundle.json", + ]) + .output() + .unwrap(); + assert_eq!(bad_out.status.code(), Some(3)); +}