oas3-gen 0.18.1

A rust type generator for OpenAPI v3.1.x specification.
use std::sync::LazyLock;

use num_format::{CustomFormat, Grouping, ToFormattedString};
use oas3::spec::{ObjectSchema, SchemaType, SchemaTypeSet};
use regex::Regex;

use crate::generator::{
  ast::{RustPrimitive, TypeRef},
  utils::doc_comment_lines,
};

static UNDERSCORE_FORMAT: LazyLock<CustomFormat> = LazyLock::new(|| {
  CustomFormat::builder()
    .grouping(Grouping::Standard)
    .separator("_")
    .build()
    .expect("formatter failed to build.")
});

#[derive(Clone, Default)]
pub(crate) struct FieldMetadata {
  pub docs: Vec<String>,
  pub validation_attrs: Vec<String>,
  pub regex_validation: Option<String>,
  pub default_value: Option<serde_json::Value>,
  pub read_only: bool,
  pub write_only: bool,
  pub deprecated: bool,
  pub multiple_of: Option<serde_json::Number>,
}

impl FieldMetadata {
  pub(crate) fn from_schema(prop_name: &str, is_required: bool, schema: &ObjectSchema, type_ref: &TypeRef) -> Self {
    Self {
      docs: extract_docs(schema.description.as_ref()),
      validation_attrs: extract_validation_attrs(is_required, schema, type_ref),
      regex_validation: extract_validation_pattern(prop_name, schema).cloned(),
      default_value: extract_default_value(schema),
      read_only: schema.read_only.unwrap_or(false),
      write_only: schema.write_only.unwrap_or(false),
      deprecated: schema.deprecated.unwrap_or(false),
      multiple_of: schema.multiple_of.clone(),
    }
  }
}

pub(crate) fn extract_docs(desc: Option<&String>) -> Vec<String> {
  desc.map_or_else(Vec::new, |d| doc_comment_lines(d))
}

pub(crate) fn extract_default_value(schema: &ObjectSchema) -> Option<serde_json::Value> {
  schema
    .default
    .clone()
    .or_else(|| schema.const_value.clone())
    .or_else(|| {
      if schema.enum_values.len() == 1 {
        schema.enum_values.first().cloned()
      } else {
        None
      }
    })
}

pub(crate) fn extract_validation_pattern<'s>(prop_name: &str, schema: &'s ObjectSchema) -> Option<&'s String> {
  match (schema.schema_type.as_ref(), schema.pattern.as_ref()) {
    (Some(SchemaTypeSet::Single(SchemaType::String)), Some(pattern)) => {
      let is_non_string_format = schema.format.as_ref().is_some_and(|f| {
        matches!(
          f.as_str(),
          "date" | "date-time" | "duration" | "time" | "binary" | "byte" | "uuid"
        )
      });

      if is_non_string_format {
        return None;
      }

      if !schema.enum_values.is_empty() {
        return None;
      }

      if Regex::new(pattern).is_ok() {
        Some(pattern)
      } else {
        eprintln!("Warning: Invalid regex pattern '{pattern}' for property '{prop_name}'");
        None
      }
    }
    _ => None,
  }
}

pub(crate) fn filter_regex_validation(rust_type: &TypeRef, regex: Option<String>) -> Option<String> {
  match &rust_type.base_type {
    RustPrimitive::DateTime | RustPrimitive::Date | RustPrimitive::Time | RustPrimitive::Uuid => None,
    _ => regex,
  }
}

pub(crate) fn extract_validation_attrs(is_required: bool, schema: &ObjectSchema, type_ref: &TypeRef) -> Vec<String> {
  let mut attrs = Vec::new();

  if let Some(ref format) = schema.format {
    match format.as_str() {
      "email" => attrs.push("email".to_string()),
      "uri" | "url" => attrs.push("url".to_string()),
      _ => {}
    }
  }

  if let Some(ref schema_type) = schema.schema_type {
    if matches!(
      schema_type,
      SchemaTypeSet::Single(SchemaType::Number | SchemaType::Integer)
    ) && let Some(range_attr) = build_range_validation_attr(schema, type_ref)
    {
      attrs.push(range_attr);
    }

    if matches!(schema_type, SchemaTypeSet::Single(SchemaType::String))
      && schema.enum_values.is_empty()
      && let Some(length_attr) = build_string_length_validation_attr(is_required, schema)
    {
      attrs.push(length_attr);
    }

    if matches!(schema_type, SchemaTypeSet::Single(SchemaType::Array))
      && let Some(length_attr) = build_array_length_validation_attr(schema)
    {
      attrs.push(length_attr);
    }
  }

  attrs
}

fn build_range_validation_attr(schema: &ObjectSchema, type_ref: &TypeRef) -> Option<String> {
  let primitive = &type_ref.base_type;

  let mut parts = Vec::<String>::new();
  if let Some(v) = schema.exclusive_minimum.as_ref() {
    parts.push(format!("exclusive_min = {}", primitive.format_number(v)));
  }
  if let Some(v) = schema.exclusive_maximum.as_ref() {
    parts.push(format!("exclusive_max = {}", primitive.format_number(v)));
  }
  if let Some(v) = schema.minimum.as_ref() {
    parts.push(format!("min = {}", primitive.format_number(v)));
  }
  if let Some(v) = schema.maximum.as_ref() {
    parts.push(format!("max = {}", primitive.format_number(v)));
  }

  if parts.is_empty() {
    None
  } else {
    Some(format!("range({})", parts.join(", ")))
  }
}

fn build_string_length_validation_attr(is_required: bool, schema: &ObjectSchema) -> Option<String> {
  let is_non_string_format = schema.format.as_ref().is_some_and(|f| {
    matches!(
      f.as_str(),
      "date" | "date-time" | "duration" | "time" | "binary" | "byte" | "uuid"
    )
  });

  if is_non_string_format {
    return None;
  }

  let min = schema.min_length.map(|l| l.to_formatted_string(&*UNDERSCORE_FORMAT));
  let max = schema.max_length.map(|l| l.to_formatted_string(&*UNDERSCORE_FORMAT));

  build_length_attribute(min, max, is_required)
}

fn build_array_length_validation_attr(schema: &ObjectSchema) -> Option<String> {
  let min = schema.min_items.map(|l| l.to_formatted_string(&*UNDERSCORE_FORMAT));
  let max = schema.max_items.map(|l| l.to_formatted_string(&*UNDERSCORE_FORMAT));
  build_length_attribute(min, max, false)
}

fn build_length_attribute(min: Option<String>, max: Option<String>, is_required_non_empty: bool) -> Option<String> {
  match (min, max) {
    (Some(min), Some(max)) => Some(format!("length(min = {min}, max = {max})")),
    (Some(min), None) => Some(format!("length(min = {min})")),
    (None, Some(max)) => Some(format!("length(max = {max})")),
    (None, None) if is_required_non_empty => Some("length(min = 1)".to_string()),
    _ => None,
  }
}