Skip to main content

ergo_adapter/
composition.rs

1//! composition.rs — Adapter-to-runtime composition and binding
2//!
3//! Purpose:
4//! - Composes a validated adapter manifest with runtime primitives,
5//!   producing the `AdapterProvides` structure that the host uses
6//!   to configure execution context and event routing.
7//!
8//! Owns:
9//! - Mapping adapter context keys to runtime source inputs
10//! - Mapping adapter event kinds to runtime trigger configurations
11
12use 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
240/// Supported capture format versions.
241const SUPPORTED_CAPTURE_VERSIONS: &[&str] = &["1"];
242
243/// Validate that an adapter provides what a source requires.
244/// COMP-1: Required context keys must exist in adapter.
245/// COMP-2: Context key types must match.
246/// COMP-16: Parameter-bound manifest names ($key) must resolve.
247pub 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        // COMP-16: Resolve $key bindings before required check so optional
254        // parameter-bound keys are still resolved.
255        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                // COMP-1: Check key exists (required only)
271                return Err(CompositionError::MissingContextKey {
272                    key: resolved_name,
273                    index,
274                });
275            }
276        };
277
278        // COMP-2: Check types match
279        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
303/// COMP-3: Validate capture format version is supported.
304pub 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
313/// Validate that an adapter satisfies action write requirements.
314/// COMP-11: Write targets exist in adapter context.
315/// COMP-12: Write targets are writable.
316/// COMP-13: Write target types match.
317/// COMP-14: Writes require set_context effect acceptance.
318/// COMP-16: Parameter-bound manifest names ($key) must resolve.
319pub 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        // COMP-16: Resolve $key bindings
334        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}