af-workflow 0.4.0

Spec-driven workflow chassis: typed node expressions composed into a branched DAG. Port of agent_core/workflow.
Documentation
//! `$config.X` / `$instance.X` / `$event.X` template resolution.
//!
//! Port of `platform/templates.py::substitute_templates`. The compiler
//! instantiates one node for many running instances, so config templates are
//! resolved lazily per-event, not at compile time. A node calls
//! [`resolve_config`] at the top of `process`.

use serde_json::{Map, Value};

use crate::event::read_path;

/// Template resolution failure.
#[derive(Debug, thiserror::Error)]
pub enum TemplateError {
    /// Template '' references $event but no event is in scope.
    #[error("template '{0}' references $event but no event is in scope")]
    EventOutOfScope(String),
    /// Template '`token`' did not resolve (path not found).
    #[error("template '{token}' did not resolve (path not found)")]
    Unresolved {
        /// The unresolved token.
        token: String,
    },
}

/// Resolve every `$config.` / `$instance.` / `$event.` string in `config`
/// against the running instance. Non-template strings and non-string scalars
/// pass through unchanged; arrays and objects are walked recursively.
pub fn resolve_config(
    config: &Value,
    instance_config: &Value,
    instance_metadata: &Value,
    event_payload: Option<&Value>,
) -> Result<Value, TemplateError> {
    match config {
        Value::String(s) => resolve_string(s, instance_config, instance_metadata, event_payload),
        Value::Array(items) => {
            let mut out = Vec::with_capacity(items.len());
            for it in items {
                out.push(resolve_config(
                    it,
                    instance_config,
                    instance_metadata,
                    event_payload,
                )?);
            }
            Ok(Value::Array(out))
        }
        Value::Object(map) => {
            let mut out = Map::with_capacity(map.len());
            for (k, v) in map {
                out.insert(
                    k.clone(),
                    resolve_config(v, instance_config, instance_metadata, event_payload)?,
                );
            }
            Ok(Value::Object(out))
        }
        other => Ok(other.clone()),
    }
}

fn resolve_string(
    s: &str,
    instance_config: &Value,
    instance_metadata: &Value,
    event_payload: Option<&Value>,
) -> Result<Value, TemplateError> {
    let resolve_from = |src: &Value, path: &str| -> Option<Value> { read_path(src, path).cloned() };

    if let Some(path) = s.strip_prefix("$config.") {
        resolve_from(instance_config, path).ok_or_else(|| TemplateError::Unresolved {
            token: s.to_string(),
        })
    } else if let Some(path) = s.strip_prefix("$instance.") {
        resolve_from(instance_metadata, path).ok_or_else(|| TemplateError::Unresolved {
            token: s.to_string(),
        })
    } else if let Some(path) = s.strip_prefix("$event.") {
        let payload = event_payload.ok_or_else(|| TemplateError::EventOutOfScope(s.to_string()))?;
        resolve_from(payload, path).ok_or_else(|| TemplateError::Unresolved {
            token: s.to_string(),
        })
    } else {
        // Not a template — pass through.
        Ok(Value::String(s.to_string()))
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn instance_tokens_never_fall_back_to_config() {
        assert!(matches!(
            resolve_config(
                &Value::String("$instance.owner".into()),
                &serde_json::json!({"owner":"legacy"}),
                &serde_json::json!({}),
                None,
            ),
            Err(TemplateError::Unresolved { .. })
        ));
    }
    use serde_json::json;

    #[test]
    fn resolves_each_namespace() {
        let cfg = json!({
            "a": "$config.threshold",
            "b": "$event.price",
            "c": "literal",
            "nested": {"d": "$instance.run_id"}
        });
        let out = resolve_config(
            &cfg,
            &json!({"threshold": 0.5}),
            &json!({"run_id": "r1"}),
            Some(&json!({"price": 42})),
        )
        .unwrap();
        assert_eq!(
            out,
            json!({"a": 0.5, "b": 42, "c": "literal", "nested": {"d": "r1"}})
        );
    }

    #[test]
    fn event_template_without_event_errors() {
        let cfg = json!("$event.x");
        assert!(matches!(
            resolve_config(&cfg, &json!({}), &json!({}), None),
            Err(TemplateError::EventOutOfScope(_))
        ));
    }
}