use serde_json::Value;
use crate::errors;
use crate::spec::validate::{self, VRule};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum FieldKind {
#[default]
String,
Bool,
I8,
I16,
I32,
I64,
U8,
U16,
U32,
U64,
F32,
F64,
Duration,
Time,
Bytes,
Ptr,
Slice,
Struct,
Union,
}
impl FieldKind {
pub fn schema_type(&self) -> &'static str {
match self {
FieldKind::String | FieldKind::Duration | FieldKind::Time | FieldKind::Bytes => {
"string"
}
FieldKind::Bool => "boolean",
FieldKind::I8
| FieldKind::I16
| FieldKind::I32
| FieldKind::I64
| FieldKind::U8
| FieldKind::U16
| FieldKind::U32
| FieldKind::U64 => "integer",
FieldKind::F32 | FieldKind::F64 => "number",
FieldKind::Slice => "array",
FieldKind::Ptr => "pointer",
FieldKind::Struct | FieldKind::Union => "object",
}
}
pub fn is_scalar(&self) -> bool {
matches!(
self,
FieldKind::String
| FieldKind::Bool
| FieldKind::I8
| FieldKind::I16
| FieldKind::I32
| FieldKind::I64
| FieldKind::U8
| FieldKind::U16
| FieldKind::U32
| FieldKind::U64
| FieldKind::F32
| FieldKind::F64
)
}
pub fn is_numeric(&self) -> bool {
self.is_scalar() && !matches!(self, FieldKind::String | FieldKind::Bool)
}
}
#[derive(Debug, Clone)]
pub struct UnionSpec {
pub tag: &'static str,
pub variants: Vec<UnionVariantSpec>,
}
#[derive(Debug, Clone)]
pub struct UnionVariantSpec {
pub name: &'static str,
pub fields: Vec<FieldSpec>,
}
#[derive(Debug, Clone)]
pub struct UnionMeta {
pub tag: String,
pub variants: Vec<UnionVariant>,
}
#[derive(Debug, Clone)]
pub struct UnionVariant {
pub name: String,
pub meta: Vec<FieldMeta>,
}
#[derive(Debug, Clone)]
pub struct FieldSpec {
pub rust_name: &'static str,
pub json_name: &'static str,
pub kind: FieldKind,
pub desc: &'static str,
pub required: bool,
pub secret: bool,
pub skip: bool,
pub validate_s: &'static str,
pub enum_s: &'static str,
pub default_s: &'static str,
pub cli_s: &'static str,
pub http_s: &'static str,
pub http_name: Option<&'static str>,
pub elem: Option<Box<FieldSpec>>,
pub children: Vec<FieldSpec>,
pub union: Option<UnionSpec>,
}
pub fn synthetic(
kind: FieldKind,
children: Vec<FieldSpec>,
elem: Option<Box<FieldSpec>>,
) -> FieldSpec {
FieldSpec {
rust_name: "",
json_name: "",
kind,
desc: "",
required: false,
secret: false,
skip: false,
validate_s: "",
enum_s: "",
default_s: "",
cli_s: "",
http_s: "",
http_name: None,
elem,
children,
union: None,
}
}
#[derive(Debug, Clone, Default)]
pub struct CliField {
pub shorthand: Option<char>,
pub positional: bool,
pub hidden: bool,
pub skip: bool,
pub env_var: Option<String>,
pub default: Option<Value>,
}
#[derive(Debug, Clone, Default)]
pub struct HTTPField {
pub location: String,
pub name: String,
pub default: Option<Value>,
}
#[derive(Debug, Clone, Default)]
pub struct MCPField {
pub default: Option<Value>,
}
#[derive(Debug, Clone, Default)]
pub struct FieldMeta {
pub name: String,
pub json_name: String,
pub kind: FieldKind,
pub description: String,
pub required: bool,
pub secret: bool,
pub validate: String,
pub rules: Vec<VRule>,
pub enum_values: Vec<Value>,
pub default: Option<Value>,
pub skip: bool,
pub cli: CliField,
pub http: HTTPField,
pub mcp: MCPField,
pub elem: Option<Box<FieldMeta>>,
pub children: Vec<FieldMeta>,
pub union: Option<UnionMeta>,
}
pub const MAX_SPEC_DEPTH: usize = 20;
thread_local! {
static SPEC_DEPTH: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
}
pub fn spec_depth_guard() {
SPEC_DEPTH.with(|d| {
let cur = d.get();
if cur > MAX_SPEC_DEPTH {
panic!(
"xyz: argument struct nesting exceeds {} levels (recursive type?)",
MAX_SPEC_DEPTH
);
}
d.set(cur + 1);
})
}
pub fn meta_from_spec(spec: &FieldSpec) -> errors::Result<FieldMeta> {
let cli = parse_cli_tag(spec.cli_s)?;
let http = parse_http_tag(spec.http_s, spec.http_name)?;
let rules = validate::parse_validate_tag(spec.validate_s)?;
let default = if spec.default_s.is_empty() {
None
} else {
Some(parse_default(spec, spec.default_s)?)
};
let enum_values = if spec.enum_s.is_empty() {
Vec::new()
} else {
parse_enum(spec.kind, spec.enum_s)?
};
let elem = match &spec.elem {
Some(e) => Some(Box::new(meta_from_spec(e)?)),
None => None,
};
let children = spec
.children
.iter()
.map(meta_from_spec)
.collect::<errors::Result<Vec<_>>>()?;
let union = match &spec.union {
Some(u) => Some(UnionMeta {
tag: u.tag.to_string(),
variants: u
.variants
.iter()
.map(|v| {
Ok(UnionVariant {
name: v.name.to_string(),
meta: v
.fields
.iter()
.map(meta_from_spec)
.collect::<errors::Result<Vec<_>>>()?,
})
})
.collect::<errors::Result<Vec<_>>>()?,
}),
None => None,
};
Ok(FieldMeta {
name: spec.rust_name.to_string(),
json_name: spec.json_name.to_string(),
kind: spec.kind,
description: spec.desc.to_string(),
required: spec.required,
secret: spec.secret,
validate: spec.validate_s.to_string(),
rules,
enum_values,
default,
skip: spec.skip,
cli,
http,
mcp: MCPField::default(),
elem,
children,
union,
})
}
pub fn parse_cli_tag(s: &str) -> errors::Result<CliField> {
let mut f = CliField::default();
if s.trim() == "-" {
f.skip = true;
return Ok(f);
}
for tok in s.split(',').map(str::trim).filter(|t| !t.is_empty()) {
if tok == "positional" {
f.positional = true;
} else if tok == "hidden" {
f.hidden = true;
} else if let Some(sh) = tok.strip_prefix("shorthand=") {
let mut chars = sh.chars();
match (chars.next(), chars.next()) {
(Some(c), None) => f.shorthand = Some(c),
_ => {
return Err(errors::Error::new(
errors::Kind::Internal,
format!("cli shorthand must be one character, got {sh:?}"),
));
}
}
} else if let Some(ev) = tok.strip_prefix("env=") {
if ev.is_empty() {
return Err(errors::Error::new(
errors::Kind::Internal,
"cli env requires a variable name".to_string(),
));
}
f.env_var = Some(ev.to_string());
} else {
return Err(errors::Error::new(
errors::Kind::Internal,
format!("unknown cli option {tok:?} (want positional|hidden|shorthand=X|env=X|-)"),
));
}
}
Ok(f)
}
pub fn valid_http_location(s: &str) -> bool {
matches!(s, "" | "query" | "path" | "header" | "form" | "body")
}
pub fn parse_http_tag(s: &str, http_name: Option<&str>) -> errors::Result<HTTPField> {
if !valid_http_location(s) {
return Err(errors::Error::new(
errors::Kind::Internal,
format!("unknown http location {s:?} (want query|path|header|form|body)"),
));
}
Ok(HTTPField {
location: s.to_string(),
name: http_name.unwrap_or_default().to_string(),
default: None,
})
}
pub fn parse_default(spec: &FieldSpec, s: &str) -> errors::Result<Value> {
match spec.kind {
FieldKind::Duration => {
crate::spec::scalar::parse_duration(s).map_err(|e| {
errors::Error::new(errors::Kind::Internal, format!("bad default {s:?}: {e}"))
})?;
Ok(Value::String(s.to_string()))
}
FieldKind::Time => {
crate::spec::scalar::parse_datetime(s).map_err(|e| {
errors::Error::new(errors::Kind::Internal, format!("bad default {s:?}: {e}"))
})?;
Ok(Value::String(s.to_string()))
}
FieldKind::Bytes => Ok(Value::String(s.to_string())),
FieldKind::Slice => {
let elem_kind = spec.elem.as_ref().map(|e| e.kind).ok_or_else(|| {
errors::Error::new(
errors::Kind::Internal,
"slice without elem kind".to_string(),
)
})?;
s.split(',')
.map(str::trim)
.filter(|p| !p.is_empty())
.map(|p| {
crate::spec::scalar::parse_scalar_literal(elem_kind, p).map_err(|e| {
errors::Error::new(errors::Kind::Internal, format!("element {p:?}: {e}"))
})
})
.collect()
}
FieldKind::Ptr => match &spec.elem {
Some(e) => parse_default(e, s),
None => Err(errors::Error::new(
errors::Kind::Internal,
"pointer without elem kind".to_string(),
)),
},
FieldKind::Struct => Err(errors::Error::new(
errors::Kind::Internal,
format!("unsupported scalar kind Struct for default {s:?}"),
)),
_ => crate::spec::scalar::parse_scalar_literal(spec.kind, s).map_err(|e| {
errors::Error::new(errors::Kind::Internal, format!("bad default {s:?}: {e}"))
}),
}
}
pub fn parse_enum(kind: FieldKind, s: &str) -> errors::Result<Vec<Value>> {
if !matches!(
kind,
FieldKind::String
| FieldKind::Bool
| FieldKind::I8
| FieldKind::I16
| FieldKind::I32
| FieldKind::I64
| FieldKind::U8
| FieldKind::U16
| FieldKind::U32
| FieldKind::U64
| FieldKind::F32
| FieldKind::F64
) {
return Err(errors::Error::new(
errors::Kind::Internal,
format!("enum is only supported on scalar fields, got {kind:?}"),
));
}
let mut out = Vec::new();
for p in s.split(',').map(str::trim) {
if p.is_empty() {
return Err(errors::Error::new(
errors::Kind::Internal,
"enum contains an empty value".to_string(),
));
}
let v = crate::spec::scalar::parse_scalar_literal(kind, p)?;
out.push(v);
}
Ok(out)
}