wist-validate 0.1.2

Static validators for wist contracts, configuration, and runtime state
Documentation
//! `ActionPlan` validation entrypoints.

use std::collections::BTreeSet;

use wist_contracts::API_VERSION_V1;
use wist_contracts::action_plan::{
    ACTION_PLAN_KIND, ActionPlan, STEP_KIND_INVOKE, is_known_step_kind,
};

use crate::{ValidationError, parse_rfc3339, require_non_empty};

pub fn validate_action_plan(contract: &ActionPlan) -> Result<(), ValidationError> {
    if contract.api_version != API_VERSION_V1 {
        return Err(ValidationError::new("invalid_api_version"));
    }
    if contract.kind != ACTION_PLAN_KIND {
        return Err(ValidationError::new("invalid_kind"));
    }

    require_non_empty(&contract.meta.action_id, "missing_action_id")?;
    require_non_empty(&contract.meta.request_id, "missing_request_id")?;
    require_non_empty(&contract.meta.tenant_id, "missing_tenant_id")?;
    require_non_empty(&contract.meta.environment_id, "missing_environment_id")?;
    if contract.meta.plan_version == 0 {
        return Err(ValidationError::new("invalid_plan_version"));
    }

    let compiled_at = parse_rfc3339(&contract.meta.compiled_at, "invalid_compiled_at")?;
    let expires_at = parse_rfc3339(&contract.meta.expires_at, "invalid_expires_at")?;
    if expires_at <= compiled_at {
        return Err(ValidationError::new("expired_or_non_increasing_window"));
    }

    require_non_empty(&contract.target.agent_id, "missing_target_agent_id")?;
    require_non_empty(&contract.target.node_id, "missing_target_node_id")?;
    require_non_empty(&contract.target.platform, "missing_target_platform")?;
    require_non_empty(&contract.target.arch, "missing_target_arch")?;

    require_non_empty(
        &contract.constraints.requested_by,
        "missing_constraints_requested_by",
    )?;
    if contract.constraints.max_total_duration_ms == 0 {
        return Err(ValidationError::new("invalid_max_total_duration_ms"));
    }
    if contract.constraints.step_timeout_default_ms == 0 {
        return Err(ValidationError::new("invalid_step_timeout_default_ms"));
    }
    if contract.constraints.step_timeout_default_ms > contract.constraints.max_total_duration_ms {
        return Err(ValidationError::new("step_timeout_exceeds_total_duration"));
    }
    require_non_empty(
        &contract.constraints.execution_profile,
        "missing_execution_profile",
    )?;

    require_non_empty(&contract.program.entry, "missing_program_entry")?;
    if contract.program.steps.is_empty() {
        return Err(ValidationError::new("missing_program_steps"));
    }

    let mut step_ids = BTreeSet::new();
    let mut entry_found = false;
    for step in &contract.program.steps {
        require_non_empty(&step.id, "missing_step_id")?;
        require_non_empty(&step.kind, "missing_step_kind")?;
        if !is_known_step_kind(&step.kind) {
            return Err(ValidationError::new("invalid_step_kind"));
        }

        if step.kind == STEP_KIND_INVOKE {
            let op = step.op.as_deref().unwrap_or_default();
            require_non_empty(op, "missing_invoke_op")?;
        }

        if !step_ids.insert(step.id.as_str()) {
            return Err(ValidationError::new("duplicate_step_id"));
        }
        if step.id == contract.program.entry {
            entry_found = true;
        }
    }

    if !entry_found {
        return Err(ValidationError::new("program_entry_not_found"));
    }

    Ok(())
}