rulemorph 0.3.3

YAML-based declarative data transformation engine for CSV/JSON to JSON
Documentation
use std::collections::HashSet;

use crate::error::ErrorCode;
use crate::model::{Mapping, RuleFile};
use crate::path::{PathToken, parse_path};
use crate::v2_parser::is_v2_expr;
use crate::v2_validator::{V2ValidationCtx, validate_no_cyclic_dependencies};

use super::ValidationCtx;
use super::bool_expr::validate_when_expr;
use super::expr::validate_expr;
use super::refs::validate_source;
use super::scope::LocalScope;
use super::v2_expr::{expr_to_json_value, validate_v2_condition_expr, validate_v2_mapping_expr};

pub(super) fn validate_mappings_list(
    mappings: &[Mapping],
    base_path: &str,
    produced_targets: &mut HashSet<Vec<PathToken>>,
    v2_targets_with_deps: &mut Vec<(String, HashSet<String>)>,
    ctx: &mut ValidationCtx<'_>,
    rule_version: u8,
) {
    let is_v2_rule = rule_version == 2;
    for (index, mapping) in mappings.iter().enumerate() {
        let base = format!("{}[{}]", base_path, index);

        if mapping.target.trim().is_empty() {
            ctx.push(
                ErrorCode::MissingTarget,
                "mapping.target is required",
                format!("{}.target", base),
            );
        }

        let target_tokens = match parse_path(&mapping.target) {
            Ok(tokens) => tokens,
            Err(_) => {
                ctx.push(
                    ErrorCode::InvalidPath,
                    "target path is invalid",
                    format!("{}.target", base),
                );
                continue;
            }
        };
        if target_tokens
            .iter()
            .any(|token| matches!(token, PathToken::Index(_)))
        {
            ctx.push(
                ErrorCode::InvalidPath,
                "target path must not include indexes",
                format!("{}.target", base),
            );
            continue;
        }

        if produced_targets.contains(&target_tokens) {
            ctx.push(
                ErrorCode::DuplicateTarget,
                "mapping.target is duplicated",
                format!("{}.target", base),
            );
        }

        let value_count = count_value_fields(mapping);
        if value_count == 0 {
            ctx.push(
                ErrorCode::MissingMappingValue,
                "mapping must define source, value, or expr",
                base.clone(),
            );
        } else if value_count > 1 {
            ctx.push(
                ErrorCode::SourceValueExprExclusive,
                "exactly one of source/value/expr is required",
                base.clone(),
            );
        }

        if let Some(type_name) = &mapping.value_type {
            if !is_valid_type_name(type_name) {
                ctx.push(
                    ErrorCode::InvalidTypeName,
                    "type must be string|int|float|bool",
                    format!("{}.type", base),
                );
            }
        }

        if let Some(source) = &mapping.source {
            validate_source(source, &base, produced_targets, ctx);
        }

        if let Some(expr) = &mapping.expr {
            let expr_path = format!("{}.expr", base);
            let mut v2_handled = false;
            if is_v2_rule {
                if let Some(raw_value) = expr_to_json_value(expr) {
                    if is_v2_expr(&raw_value) {
                        validate_v2_mapping_expr(
                            &raw_value,
                            &expr_path,
                            produced_targets,
                            &mapping.target,
                            ctx,
                            v2_targets_with_deps,
                        );
                        v2_handled = true;
                    }
                }
            }
            if !v2_handled {
                validate_expr(expr, &expr_path, produced_targets, ctx, LocalScope::None);
            }
        }

        if let Some(when) = &mapping.when {
            let when_path = format!("{}.when", base);
            let mut v2_handled = false;
            if is_v2_rule {
                if let Some(raw_value) = expr_to_json_value(when) {
                    if is_v2_expr(&raw_value) {
                        validate_v2_condition_expr(&raw_value, &when_path, produced_targets, ctx);
                        v2_handled = true;
                    }
                }
            }
            if !v2_handled {
                validate_expr(when, &when_path, produced_targets, ctx, LocalScope::None);
                validate_when_expr(when, &when_path, ctx);
            }
        }

        produced_targets.insert(target_tokens);
    }
}

pub(super) fn validate_record_when(rule: &RuleFile, ctx: &mut ValidationCtx<'_>) {
    if rule.steps.is_some() {
        return;
    }
    let expr = match rule.record_when.as_ref() {
        Some(expr) => expr,
        None => return,
    };

    let base_path = "record_when";
    let produced_targets = HashSet::new();
    if rule.version == 2 {
        if let Some(raw_value) = expr_to_json_value(expr) {
            validate_v2_condition_expr(&raw_value, base_path, &produced_targets, ctx);
            return;
        }
    }

    validate_expr(expr, base_path, &produced_targets, ctx, LocalScope::None);
    validate_when_expr(expr, base_path, ctx);
}

pub(super) fn validate_mappings(rule: &RuleFile, ctx: &mut ValidationCtx<'_>) {
    if rule.steps.is_some() {
        return;
    }

    let mut produced_targets: HashSet<Vec<PathToken>> = HashSet::new();
    let mut v2_targets_with_deps: Vec<(String, HashSet<String>)> = Vec::new();

    validate_mappings_list(
        &rule.mappings,
        "mappings",
        &mut produced_targets,
        &mut v2_targets_with_deps,
        ctx,
        rule.version,
    );

    if rule.version == 2 && !v2_targets_with_deps.is_empty() {
        let mut v2_ctx = V2ValidationCtx::new(ctx.locator);
        validate_no_cyclic_dependencies(&v2_targets_with_deps, "mappings", &mut v2_ctx);
        for err in v2_ctx.errors() {
            ctx.errors.push(err.clone());
        }
    }
}

fn count_value_fields(mapping: &Mapping) -> usize {
    let mut count = 0;
    if mapping.source.is_some() {
        count += 1;
    }
    if mapping.value.is_some() {
        count += 1;
    }
    if mapping.expr.is_some() {
        count += 1;
    }
    count
}

fn is_valid_type_name(value: &str) -> bool {
    matches!(value, "string" | "int" | "float" | "bool")
}