1use std::borrow::Cow;
13use std::collections::{HashMap, HashSet};
14use std::fmt;
15
16use ergo_runtime::action::{ActionEffects, IntentSpec};
17use ergo_runtime::common::{
18 doc_anchor_for_rule, resolve_manifest_name, ErrorInfo, Phase, ValueType,
19};
20pub use ergo_runtime::source::{ContextRequirement, SourceRequires};
21
22use crate::provides::AdapterProvides;
23
24#[derive(Debug)]
25#[non_exhaustive]
26pub enum CompositionError {
27 MissingContextKey {
28 key: String,
29 index: usize,
30 },
31 ContextTypeMismatch {
32 key: String,
33 expected: String,
34 got: String,
35 index: usize,
36 },
37 UnsupportedCaptureFormat {
38 version: String,
39 },
40 WriteTargetNotProvided {
41 key: String,
42 index: usize,
43 },
44 WriteTargetNotWritable {
45 key: String,
46 index: usize,
47 },
48 WriteTypeMismatch {
49 key: String,
50 expected: String,
51 got: String,
52 index: usize,
53 },
54 MissingSetContextEffect,
55 MissingIntentEffect {
56 kind: String,
57 index: usize,
58 },
59 MissingIntentPayloadSchema {
60 kind: String,
61 index: usize,
62 },
63 IntentPayloadSchemaIncompatible {
64 kind: String,
65 index: usize,
66 detail: String,
67 },
68 ManifestNameResolutionFailed {
69 binding: String,
70 index: usize,
71 context: &'static str,
72 },
73}
74
75impl ErrorInfo for CompositionError {
76 fn rule_id(&self) -> &'static str {
77 match self {
78 Self::MissingContextKey { .. } => "COMP-1",
79 Self::ContextTypeMismatch { .. } => "COMP-2",
80 Self::UnsupportedCaptureFormat { .. } => "COMP-3",
81 Self::WriteTargetNotProvided { .. } => "COMP-11",
82 Self::WriteTargetNotWritable { .. } => "COMP-12",
83 Self::WriteTypeMismatch { .. } => "COMP-13",
84 Self::MissingSetContextEffect => "COMP-14",
85 Self::MissingIntentEffect { .. } => "COMP-17",
86 Self::MissingIntentPayloadSchema { .. } => "COMP-18",
87 Self::IntentPayloadSchemaIncompatible { .. } => "COMP-19",
88 Self::ManifestNameResolutionFailed { .. } => "COMP-16",
89 }
90 }
91
92 fn phase(&self) -> Phase {
93 Phase::Composition
94 }
95
96 fn doc_anchor(&self) -> &'static str {
97 doc_anchor_for_rule(self.rule_id())
98 }
99
100 fn summary(&self) -> Cow<'static, str> {
101 match self {
102 Self::MissingContextKey { key, .. } => Cow::Owned(format!(
103 "Required context key '{}' not provided by adapter",
104 key
105 )),
106 Self::ContextTypeMismatch {
107 key, expected, got, ..
108 } => Cow::Owned(format!(
109 "Context key '{}' type mismatch: expected '{}', got '{}'",
110 key, expected, got
111 )),
112 Self::UnsupportedCaptureFormat { version } => {
113 Cow::Owned(format!("Unsupported capture format version: '{}'", version))
114 }
115 Self::WriteTargetNotProvided { key, .. } => Cow::Owned(format!(
116 "Action write target '{}' not provided by adapter",
117 key
118 )),
119 Self::WriteTargetNotWritable { key, .. } => Cow::Owned(format!(
120 "Action write target '{}' is not writable in adapter",
121 key
122 )),
123 Self::WriteTypeMismatch {
124 key, expected, got, ..
125 } => Cow::Owned(format!(
126 "Action write target '{}' type mismatch: expected '{}', got '{}'",
127 key, expected, got
128 )),
129 Self::MissingSetContextEffect => {
130 Cow::Borrowed("Adapter does not accept set_context effect required for writes")
131 }
132 Self::MissingIntentEffect { kind, .. } => Cow::Owned(format!(
133 "Adapter does not accept intent effect kind '{}' required by action manifest",
134 kind
135 )),
136 Self::MissingIntentPayloadSchema { kind, .. } => Cow::Owned(format!(
137 "Adapter effect '{}' is missing payload_schema required for intent compatibility checks",
138 kind
139 )),
140 Self::IntentPayloadSchemaIncompatible { kind, detail, .. } => Cow::Owned(format!(
141 "Adapter payload_schema for intent kind '{}' is incompatible: {}",
142 kind, detail
143 )),
144 Self::ManifestNameResolutionFailed { binding, .. } => Cow::Owned(format!(
145 "Failed to resolve parameter-bound manifest name '{}'",
146 binding
147 )),
148 }
149 }
150
151 fn path(&self) -> Option<Cow<'static, str>> {
152 match self {
153 Self::MissingContextKey { index, .. } => {
154 Some(Cow::Owned(format!("$.requires.context[{}].name", index)))
155 }
156 Self::ContextTypeMismatch { index, .. } => {
157 Some(Cow::Owned(format!("$.requires.context[{}].type", index)))
158 }
159 Self::UnsupportedCaptureFormat { .. } => {
160 Some(Cow::Borrowed("$.capture.format_version"))
161 }
162 Self::WriteTargetNotProvided { index, .. } => {
163 Some(Cow::Owned(format!("$.effects.writes[{}].name", index)))
164 }
165 Self::WriteTargetNotWritable { index, .. } => {
166 Some(Cow::Owned(format!("$.effects.writes[{}].name", index)))
167 }
168 Self::WriteTypeMismatch { index, .. } => {
169 Some(Cow::Owned(format!("$.effects.writes[{}].type", index)))
170 }
171 Self::MissingSetContextEffect => Some(Cow::Borrowed("$.effects.writes")),
172 Self::MissingIntentEffect { index, .. } => {
173 Some(Cow::Owned(format!("$.effects.intents[{}].name", index)))
174 }
175 Self::MissingIntentPayloadSchema { index, .. }
176 | Self::IntentPayloadSchemaIncompatible { index, .. } => {
177 Some(Cow::Owned(format!("$.effects.intents[{}].fields", index)))
178 }
179 Self::ManifestNameResolutionFailed { index, context, .. } => {
180 Some(Cow::Owned(format!("$.{context}[{index}].name")))
181 }
182 }
183 }
184
185 fn fix(&self) -> Option<Cow<'static, str>> {
186 match self {
187 Self::MissingContextKey { key, .. } => Some(Cow::Owned(format!(
188 "Add context key '{}' to the adapter's context_keys",
189 key
190 ))),
191 Self::ContextTypeMismatch { key, expected, .. } => Some(Cow::Owned(format!(
192 "Change type of '{}' in adapter's context_keys to '{}'",
193 key, expected
194 ))),
195 Self::UnsupportedCaptureFormat { .. } => {
196 Some(Cow::Borrowed("Use a supported capture format version: 1"))
197 }
198 Self::WriteTargetNotProvided { key, .. } => Some(Cow::Owned(format!(
199 "Add context key '{}' to the adapter's context_keys",
200 key
201 ))),
202 Self::WriteTargetNotWritable { key, .. } => Some(Cow::Owned(format!(
203 "Mark context key '{}' as writable in the adapter manifest",
204 key
205 ))),
206 Self::WriteTypeMismatch { key, expected, .. } => Some(Cow::Owned(format!(
207 "Change type of '{}' in adapter's context_keys to '{}'",
208 key, expected
209 ))),
210 Self::MissingSetContextEffect => Some(Cow::Borrowed(
211 "Add 'set_context' to adapter accepts.effects",
212 )),
213 Self::MissingIntentEffect { kind, .. } => Some(Cow::Owned(format!(
214 "Add '{}' to adapter accepts.effects",
215 kind
216 ))),
217 Self::MissingIntentPayloadSchema { kind, .. } => Some(Cow::Owned(format!(
218 "Add payload_schema for '{}' under adapter accepts.effects",
219 kind
220 ))),
221 Self::IntentPayloadSchemaIncompatible { .. } => Some(Cow::Borrowed(
222 "Adjust accepts.effects payload_schema to match the intent fields/types declared by the action manifest",
223 )),
224 Self::ManifestNameResolutionFailed { binding, .. } => Some(Cow::Owned(format!(
225 "Ensure parameter referenced by '{}' exists and is a String type",
226 binding
227 ))),
228 }
229 }
230}
231
232impl fmt::Display for CompositionError {
233 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
234 write!(f, "[{}] {}", self.rule_id(), self.summary())
235 }
236}
237
238impl std::error::Error for CompositionError {}
239
240const SUPPORTED_CAPTURE_VERSIONS: &[&str] = &["1"];
242
243pub fn validate_source_adapter_composition(
248 source: &SourceRequires,
249 adapter: &AdapterProvides,
250 parameters: &HashMap<String, ergo_runtime::cluster::ParameterValue>,
251) -> Result<(), CompositionError> {
252 for (index, req) in source.context.iter().enumerate() {
253 let resolved_name = resolve_manifest_name(&req.name, parameters).map_err(|_| {
256 CompositionError::ManifestNameResolutionFailed {
257 binding: req.name.clone(),
258 index,
259 context: "requires.context",
260 }
261 })?;
262
263 let provided = match adapter.context.get(&resolved_name) {
264 Some(p) => p,
265 None => {
266 if !req.required {
267 continue;
268 }
269
270 return Err(CompositionError::MissingContextKey {
272 key: resolved_name,
273 index,
274 });
275 }
276 };
277
278 let provided_ty = match parse_value_type(&provided.ty) {
280 Some(ty) => ty,
281 None => {
282 return Err(CompositionError::ContextTypeMismatch {
283 key: resolved_name,
284 expected: value_type_name(&req.ty).to_string(),
285 got: provided.ty.clone(),
286 index,
287 });
288 }
289 };
290
291 if req.ty != provided_ty {
292 return Err(CompositionError::ContextTypeMismatch {
293 key: resolved_name,
294 expected: value_type_name(&req.ty).to_string(),
295 got: provided.ty.clone(),
296 index,
297 });
298 }
299 }
300 Ok(())
301}
302
303pub fn validate_capture_format(version: &str) -> Result<(), CompositionError> {
305 if !SUPPORTED_CAPTURE_VERSIONS.contains(&version) {
306 return Err(CompositionError::UnsupportedCaptureFormat {
307 version: version.to_string(),
308 });
309 }
310 Ok(())
311}
312
313pub fn validate_action_adapter_composition(
320 effects: &ActionEffects,
321 adapter: &AdapterProvides,
322 parameters: &HashMap<String, ergo_runtime::cluster::ParameterValue>,
323) -> Result<(), CompositionError> {
324 if effects.writes.is_empty() && effects.intents.is_empty() {
325 return Ok(());
326 }
327 let has_mirror_writes = effects
328 .intents
329 .iter()
330 .any(|intent| !intent.mirror_writes.is_empty());
331
332 for (index, write) in effects.writes.iter().enumerate() {
333 let resolved_name = resolve_manifest_name(&write.name, parameters).map_err(|_| {
335 CompositionError::ManifestNameResolutionFailed {
336 binding: write.name.clone(),
337 index,
338 context: "effects.writes",
339 }
340 })?;
341
342 let provided = match adapter.context.get(&resolved_name) {
343 Some(p) => p,
344 None => {
345 return Err(CompositionError::WriteTargetNotProvided {
346 key: resolved_name,
347 index,
348 });
349 }
350 };
351
352 if !provided.writable {
353 return Err(CompositionError::WriteTargetNotWritable {
354 key: resolved_name,
355 index,
356 });
357 }
358
359 let provided_ty = match parse_value_type(&provided.ty) {
360 Some(ty) => ty,
361 None => {
362 return Err(CompositionError::WriteTypeMismatch {
363 key: resolved_name,
364 expected: value_type_name(&write.value_type).to_string(),
365 got: provided.ty.clone(),
366 index,
367 });
368 }
369 };
370
371 if provided_ty != write.value_type {
372 return Err(CompositionError::WriteTypeMismatch {
373 key: resolved_name,
374 expected: value_type_name(&write.value_type).to_string(),
375 got: provided.ty.clone(),
376 index,
377 });
378 }
379 }
380
381 if (!effects.writes.is_empty() || has_mirror_writes) && !adapter.effects.contains("set_context")
382 {
383 return Err(CompositionError::MissingSetContextEffect);
384 }
385
386 for (index, intent) in effects.intents.iter().enumerate() {
387 if !adapter.effects.contains(&intent.name) {
388 return Err(CompositionError::MissingIntentEffect {
389 kind: intent.name.clone(),
390 index,
391 });
392 }
393
394 let payload_schema = adapter.effect_schemas.get(&intent.name).ok_or_else(|| {
395 CompositionError::MissingIntentPayloadSchema {
396 kind: intent.name.clone(),
397 index,
398 }
399 })?;
400 validate_intent_schema_compatibility(intent, payload_schema).map_err(|detail| {
401 CompositionError::IntentPayloadSchemaIncompatible {
402 kind: intent.name.clone(),
403 index,
404 detail,
405 }
406 })?;
407 }
408
409 Ok(())
410}
411
412fn validate_intent_schema_compatibility(
413 intent: &IntentSpec,
414 payload_schema: &serde_json::Value,
415) -> Result<(), String> {
416 let schema = payload_schema
417 .as_object()
418 .ok_or_else(|| "payload_schema must be a JSON object".to_string())?;
419
420 if let Some(keyword) = unsupported_schema_keyword(schema) {
421 return Err(format!("unsupported JSON Schema keyword '{}'", keyword));
422 }
423
424 let schema_type = schema
425 .get("type")
426 .and_then(|value| value.as_str())
427 .ok_or_else(|| "payload_schema.type must be present and set to 'object'".to_string())?;
428 if schema_type != "object" {
429 return Err(format!(
430 "payload_schema.type must be 'object', found '{}'",
431 schema_type
432 ));
433 }
434
435 let properties = schema
436 .get("properties")
437 .and_then(|value| value.as_object())
438 .ok_or_else(|| "payload_schema.properties must be present and be an object".to_string())?;
439
440 let field_names: HashSet<&str> = intent
441 .fields
442 .iter()
443 .map(|field| field.name.as_str())
444 .collect();
445
446 if let Some(required) = schema.get("required") {
447 let required = required
448 .as_array()
449 .ok_or_else(|| "payload_schema.required must be an array of field names".to_string())?;
450 for item in required {
451 let required_name = item
452 .as_str()
453 .ok_or_else(|| "payload_schema.required entries must be strings".to_string())?;
454 if !field_names.contains(required_name) {
455 return Err(format!(
456 "required field '{}' is not declared in intent.fields",
457 required_name
458 ));
459 }
460 }
461 }
462
463 for field in &intent.fields {
464 let property_schema = properties.get(&field.name).ok_or_else(|| {
465 format!(
466 "intent field '{}' is missing from payload_schema.properties",
467 field.name
468 )
469 })?;
470 validate_field_schema_compatibility(&field.value_type, property_schema, &field.name)?;
471 }
472
473 Ok(())
474}
475
476fn validate_field_schema_compatibility(
477 field_type: &ValueType,
478 property_schema: &serde_json::Value,
479 field_name: &str,
480) -> Result<(), String> {
481 let property = property_schema
482 .as_object()
483 .ok_or_else(|| format!("field '{}' schema must be a JSON object", field_name))?;
484 if let Some(keyword) = unsupported_schema_keyword(property) {
485 return Err(format!(
486 "field '{}' uses unsupported JSON Schema keyword '{}'",
487 field_name, keyword
488 ));
489 }
490
491 match field_type {
492 ValueType::Number | ValueType::Bool | ValueType::String => {
493 let expected = value_type_to_json_type(field_type);
494 let actual = property
495 .get("type")
496 .and_then(|value| value.as_str())
497 .ok_or_else(|| {
498 format!(
499 "field '{}' schema must declare type '{}'",
500 field_name, expected
501 )
502 })?;
503 if actual != expected {
504 return Err(format!(
505 "field '{}' expected JSON type '{}', found '{}'",
506 field_name, expected, actual
507 ));
508 }
509 }
510 ValueType::Series => {
511 let actual = property
512 .get("type")
513 .and_then(|value| value.as_str())
514 .ok_or_else(|| {
515 format!("field '{}' schema must declare type 'array'", field_name)
516 })?;
517 if actual != "array" {
518 return Err(format!(
519 "field '{}' expected JSON type 'array', found '{}'",
520 field_name, actual
521 ));
522 }
523 let items = property
524 .get("items")
525 .and_then(|value| value.as_object())
526 .ok_or_else(|| {
527 format!(
528 "field '{}' array schema must define object 'items'",
529 field_name
530 )
531 })?;
532 if let Some(keyword) = unsupported_schema_keyword(items) {
533 return Err(format!(
534 "field '{}' array items use unsupported JSON Schema keyword '{}'",
535 field_name, keyword
536 ));
537 }
538 let item_type = items
539 .get("type")
540 .and_then(|value| value.as_str())
541 .ok_or_else(|| {
542 format!(
543 "field '{}' array items must declare type 'number'",
544 field_name
545 )
546 })?;
547 if item_type != "number" {
548 return Err(format!(
549 "field '{}' array items expected type 'number', found '{}'",
550 field_name, item_type
551 ));
552 }
553 }
554 }
555 Ok(())
556}
557
558fn value_type_to_json_type(value_type: &ValueType) -> &'static str {
559 match value_type {
560 ValueType::Number => "number",
561 ValueType::Bool => "boolean",
562 ValueType::String => "string",
563 ValueType::Series => "array",
564 }
565}
566
567fn unsupported_schema_keyword(schema: &serde_json::Map<String, serde_json::Value>) -> Option<&str> {
568 [
569 "$ref", "oneOf", "anyOf", "allOf", "not", "if", "then", "else",
570 ]
571 .iter()
572 .copied()
573 .find(|keyword| schema.contains_key(*keyword))
574}
575
576fn parse_value_type(value: &str) -> Option<ValueType> {
577 match value {
578 "Number" => Some(ValueType::Number),
579 "Series" => Some(ValueType::Series),
580 "Bool" => Some(ValueType::Bool),
581 "String" => Some(ValueType::String),
582 _ => None,
583 }
584}
585
586fn value_type_name(value: &ValueType) -> &'static str {
587 match value {
588 ValueType::Number => "Number",
589 ValueType::Series => "Series",
590 ValueType::Bool => "Bool",
591 ValueType::String => "String",
592 }
593}