online-dsl-forge 0.3.0

A bounded parser and runtime for in-memory DSL expressions.
Documentation
use std::collections::HashSet;

use crate::rulepack_render::error::{RenderResult, fail};
use crate::rulepack_render::types::{
  RulepackBinding, RulepackDiscovery, RulepackProfile, RulepackVariable,
};
use crate::rulepack_render::validation::{
  validate_cidr, validate_human_text, validate_label, validate_rate,
};

pub(crate) fn reject_legacy_variable_discovery(
  value: &toml::Value,
  source: &str,
) -> RenderResult<()> {
  for variable in value
    .get("variables")
    .and_then(toml::Value::as_array)
    .into_iter()
    .flatten()
  {
    let Some(table) = variable.as_table() else {
      continue;
    };
    let name = table
      .get("name")
      .and_then(toml::Value::as_str)
      .unwrap_or("<unknown>");
    if table.contains_key("discovery") {
      return fail(format!(
        "{source} variable {name} uses [variables.discovery]; route and other environment objects must be declared with explicit [[bindings]] and bind_as"
      ));
    }
  }
  Ok(())
}

pub(crate) fn validate_rulepack_inputs(
  source: &str,
  variables: &[RulepackVariable],
  bindings: &[RulepackBinding],
  profiles: &[RulepackProfile],
) -> RenderResult<()> {
  let mut variable_names = HashSet::new();
  for variable in variables {
    validate_label(source, "variables.name", &variable.name)?;
    validate_variable_type(source, variable)?;
    validate_optional_human_text(
      source,
      "variables.description",
      variable.description.as_deref(),
    )?;
    validate_optional_human_text(source, "variables.prompt", variable.prompt.as_deref())?;
    if let Some(default) = &variable.default {
      validate_variable_value(source, variable, default)?;
    }
    if !variable_names.insert(variable.name.clone()) {
      return fail(format!(
        "{source} contains duplicate variable {}",
        variable.name
      ));
    }
  }

  let mut binding_names = HashSet::new();
  let mut binding_targets = HashSet::new();
  for binding in bindings {
    validate_label(source, "bindings.name", &binding.name)?;
    validate_label(source, "bindings.bind_as", &binding.bind_as)?;
    if variable_names.contains(&binding.bind_as) {
      return fail(format!(
        "{source} binding {} bind_as {} conflicts with a declared variable; route and other environment objects must use [[bindings]], while [[variables]] is only for scalar values",
        binding.name, binding.bind_as
      ));
    }
    if !binding_targets.insert(binding.bind_as.clone()) {
      return fail(format!(
        "{source} contains duplicate binding render target {}",
        binding.bind_as
      ));
    }
    if !binding_names.insert(binding.name.clone()) {
      return fail(format!(
        "{source} contains duplicate binding {}",
        binding.name
      ));
    }
    if variable_names.contains(&binding.name) {
      return fail(format!(
        "{source} binding {} conflicts with a declared variable; use distinct names for bind and var inputs",
        binding.name
      ));
    }
    validate_optional_human_text(
      source,
      "bindings.description",
      binding.description.as_deref(),
    )?;
    validate_optional_human_text(source, "bindings.prompt", binding.prompt.as_deref())?;
    validate_discovery(source, "bindings.discovery", &binding.discovery)?;
  }

  let mut profile_names = HashSet::new();
  for profile in profiles {
    validate_label(source, "profiles.name", &profile.name)?;
    if !profile_names.insert(profile.name.clone()) {
      return fail(format!(
        "{source} contains duplicate profile {}",
        profile.name
      ));
    }
    for (name, value) in &profile.values {
      let Some(variable) = variables.iter().find(|variable| variable.name == *name) else {
        return fail(format!(
          "{source} profile {} sets unknown variable {name}",
          profile.name
        ));
      };
      validate_variable_value(source, variable, value)?;
    }
  }

  Ok(())
}

pub(crate) fn validate_variable_value(
  source: &str,
  variable: &RulepackVariable,
  value: &str,
) -> RenderResult<()> {
  match variable.value_type.as_deref() {
    Some("cidr") => validate_cidr(value).map_err(|error| {
      crate::rulepack_render::RulepackRenderError::new(format!(
        "{source} variable {} must be a valid CIDR: {error}",
        variable.name
      ))
    }),
    Some("rate") => validate_rate(value).map_err(|error| {
      crate::rulepack_render::RulepackRenderError::new(format!(
        "{source} variable {} must be a valid rate: {error}",
        variable.name
      ))
    }),
    Some("string") | None => Ok(()),
    Some("route") => fail(format!(
      "{source} variable {} uses type = \"route\"; route objects must be declared with [[bindings]] and bind_as",
      variable.name
    )),
    Some(other) => fail(format!(
      "{source} variable {} uses unsupported type {}; supported types are string, cidr, and rate",
      variable.name, other
    )),
  }
}

fn validate_variable_type(source: &str, variable: &RulepackVariable) -> RenderResult<()> {
  let Some(value_type) = &variable.value_type else {
    return Ok(());
  };
  validate_label(source, "variables.type", value_type)?;
  match value_type.as_str() {
    "string" | "cidr" | "rate" => Ok(()),
    "route" => fail(format!(
      "{source} variable {} uses type = \"route\"; route objects must be declared with [[bindings]] and bind_as",
      variable.name
    )),
    _ => fail(format!(
      "{source} variable {} uses unsupported type {}; supported types are string, cidr, and rate",
      variable.name, value_type
    )),
  }
}

fn validate_optional_human_text(
  source: &str,
  field: &str,
  value: Option<&str>,
) -> RenderResult<()> {
  if let Some(value) = value {
    validate_human_text(source, field, value)?;
  }
  Ok(())
}

fn validate_discovery(
  source: &str,
  field: &str,
  discovery: &RulepackDiscovery,
) -> RenderResult<()> {
  for token in discovery
    .name_any
    .iter()
    .chain(discovery.host_contains_any.iter())
    .chain(discovery.upstream_contains_any.iter())
  {
    validate_discovery_token(source, field, token)?;
  }
  for prefix in &discovery.path_prefix_any {
    if prefix.trim().is_empty()
      || prefix.len() > 512
      || prefix.bytes().any(|byte| byte.is_ascii_control())
    {
      return fail(format!(
        "{source} {field}.path_prefix_any entries must be 1 to 512 printable bytes"
      ));
    }
    if !prefix.starts_with('/') {
      return fail(format!(
        "{source} {field}.path_prefix_any entries must start with /"
      ));
    }
  }
  Ok(())
}

fn validate_discovery_token(source: &str, field: &str, value: &str) -> RenderResult<()> {
  if value.trim().is_empty()
    || value.len() > 128
    || value.bytes().any(|byte| byte.is_ascii_control())
  {
    return fail(format!(
      "{source} {field} entries must be 1 to 128 printable bytes"
    ));
  }
  Ok(())
}