use std::{collections::BTreeMap, fmt};
use enumset::EnumSet;
use modelplease::{MediaKind, MediaSource, SourceKind};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "kind", content = "inner", rename_all = "snake_case")]
pub enum FieldType {
String,
Int,
Float,
Bool,
List(Box<Self>),
Object(Vec<ObjectField>),
Map(Box<Self>),
Enum(Vec<std::string::String>),
Nullable(Box<Self>),
OneOf {
arms: Vec<VariantArm>,
discriminator: Option<OneOfDiscriminator>,
},
AnyOf {
arms: Vec<VariantArm>,
},
Media {
kind: MediaKind,
accepted_sources: EnumSet<SourceKind>,
},
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct VariantArm {
pub description: std::string::String,
pub field_type: FieldType,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct OneOfDiscriminator {
pub property: std::string::String,
pub tags: Vec<std::string::String>,
}
impl FieldType {
#[must_use]
pub fn type_label(&self) -> std::string::String {
match self {
Self::String => "str".to_owned(),
Self::Int => "int".to_owned(),
Self::Float => "float".to_owned(),
Self::Bool => "bool".to_owned(),
Self::List(inner) => format!("list[{}]", inner.type_label()),
Self::Object(fields) => {
let parts: Vec<_> = fields
.iter()
.map(|f| format!("{}: {}", f.name, f.field_type.type_label()))
.collect();
format!("{{{}}}", parts.join(", "))
}
Self::Map(value_type) => format!("map[str, {}]", value_type.type_label()),
Self::Enum(variants) => {
format!("enum[{}]", variants.join(", "))
}
Self::Nullable(inner) => format!("optional[{}]", inner.type_label()),
Self::OneOf {
arms,
discriminator,
} => discriminator.as_ref().map_or_else(
|| {
let parts: Vec<_> = arms.iter().map(|a| a.field_type.type_label()).collect();
format!("oneof[{}]", parts.join(" | "))
},
|disc| format!("oneof[{}: {}]", disc.property, disc.tags.join(" | ")),
),
Self::AnyOf { arms } => {
let parts: Vec<_> = arms.iter().map(|a| a.field_type.type_label()).collect();
format!("anyof[{}]", parts.join(" | "))
}
Self::Media { kind, .. } => format!("media[{}]", kind.label()),
}
}
#[must_use]
pub fn output_format_hint(&self) -> Option<std::string::String> {
match self {
Self::String | Self::Media { .. } => None,
Self::Int => Some("a single integer".to_owned()),
Self::Float => Some("a single number".to_owned()),
Self::Bool => Some("`true` or `false`".to_owned()),
Self::Enum(variants) => Some(format!("exactly one of: {}", variants.join(", "))),
Self::List(inner) => Some(format!(
"a JSON array of {}, e.g. [\"...\", \"...\"] — not an object, not a code fence",
inner.type_label()
)),
Self::Object(fields) => {
let example = object_payload_example(fields);
Some(format!(
"a JSON object matching {} — e.g. {example} — not a code fence",
self.type_label()
))
}
Self::Map(value_type) => Some(format!(
"a JSON object with string keys and {} values — \
e.g. {{\"key1\": ..., \"key2\": ...}} — not a code fence",
value_type.type_label()
)),
Self::Nullable(inner) => {
Some(inner.output_format_hint().map_or_else(
|| "a value, or null when the value is not applicable".to_owned(),
|hint| format!("{hint}, or null when the value is not applicable"),
))
}
Self::OneOf { discriminator, .. } => Some(discriminator.as_ref().map_or_else(
|| {
"a JSON value matching exactly one of the shapes listed under \"Variant \
shapes\" above"
.to_owned()
},
|d| {
let example_tag = d.tags.first().map_or("...", String::as_str);
format!(
"a JSON object whose `{property}` field selects the variant — \
e.g. {{\"{property}\": \"{example_tag}\", ...}} \
(see \"Variant shapes\" above for each arm's fields)",
property = d.property,
)
},
)),
Self::AnyOf { .. } => Some(
"a JSON value matching any of the shapes listed under \"Variant shapes\" \
above (first match wins)"
.to_owned(),
),
}
}
}
impl fmt::Display for FieldType {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.type_label())
}
}
fn object_payload_example(fields: &[ObjectField]) -> String {
let parts: Vec<String> = fields
.iter()
.take(3)
.map(|f| format!("\"{}\": {}", f.name, type_placeholder(&f.field_type)))
.collect();
let suffix = if fields.len() > 3 { ", ..." } else { "" };
format!("{{{}{suffix}}}", parts.join(", "))
}
const fn type_placeholder(field_type: &FieldType) -> &'static str {
match field_type {
FieldType::String | FieldType::Enum(_) | FieldType::Media { .. } => "\"...\"",
FieldType::Int => "123",
FieldType::Float => "1.5",
FieldType::Bool => "true",
FieldType::List(_) => "[...]",
FieldType::Object(_) | FieldType::Map(_) => "{...}",
FieldType::Nullable(_) => "null",
FieldType::OneOf { .. } | FieldType::AnyOf { .. } => "...",
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ObjectField {
pub name: std::string::String,
pub description: std::string::String,
pub field_type: FieldType,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum FieldKind {
Input,
Output,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct FieldDef {
pub name: std::string::String,
pub description: std::string::String,
pub field_type: FieldType,
pub kind: FieldKind,
#[serde(default)]
pub cacheable: bool,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub examples: Vec<serde_json::Value>,
}
impl FieldDef {
pub fn input(
name: impl Into<std::string::String>,
field_type: FieldType,
description: impl Into<std::string::String>,
) -> Self {
Self {
name: name.into(),
description: description.into(),
field_type,
kind: FieldKind::Input,
cacheable: false,
examples: Vec::new(),
}
}
pub fn output(
name: impl Into<std::string::String>,
field_type: FieldType,
description: impl Into<std::string::String>,
) -> Self {
Self {
name: name.into(),
description: description.into(),
field_type,
kind: FieldKind::Output,
cacheable: false,
examples: Vec::new(),
}
}
#[must_use]
pub fn with_examples(mut self, examples: Vec<serde_json::Value>) -> Self {
self.examples = examples;
self
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(untagged)]
pub enum FieldValue {
Str(std::string::String),
Int(i64),
Float(f64),
Bool(bool),
List(Vec<Self>),
Variant {
arm_index: usize,
value: Box<Self>,
},
Object(BTreeMap<std::string::String, Self>),
Media(MediaValue),
Null,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct MediaValue {
pub kind: MediaKind,
pub source: MediaSource,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn type_label_primitives() {
assert_eq!(FieldType::String.type_label(), "str");
assert_eq!(FieldType::Int.type_label(), "int");
assert_eq!(FieldType::Float.type_label(), "float");
assert_eq!(FieldType::Bool.type_label(), "bool");
}
#[test]
fn type_label_nested() {
let list_int = FieldType::List(Box::new(FieldType::Int));
assert_eq!(list_int.type_label(), "list[int]");
let nullable_str = FieldType::Nullable(Box::new(FieldType::String));
assert_eq!(nullable_str.type_label(), "optional[str]");
let map_float = FieldType::Map(Box::new(FieldType::Float));
assert_eq!(map_float.type_label(), "map[str, float]");
}
#[test]
fn type_label_enum() {
let e = FieldType::Enum(vec!["a".into(), "b".into(), "c".into()]);
assert_eq!(e.type_label(), "enum[a, b, c]");
}
#[test]
fn type_label_object() {
let obj = FieldType::Object(vec![
ObjectField {
name: "x".into(),
description: "x coord".into(),
field_type: FieldType::Int,
},
ObjectField {
name: "y".into(),
description: "y coord".into(),
field_type: FieldType::Int,
},
]);
assert_eq!(obj.type_label(), "{x: int, y: int}");
}
#[test]
fn field_def_input_constructor() {
let f = FieldDef::input("question", FieldType::String, "The user question");
assert_eq!(f.name, "question");
assert_eq!(f.kind, FieldKind::Input);
}
#[test]
fn field_def_output_constructor() {
let f = FieldDef::output("answer", FieldType::String, "The answer");
assert_eq!(f.name, "answer");
assert_eq!(f.kind, FieldKind::Output);
}
#[test]
fn field_type_serde_round_trip_primitive() {
let ft = FieldType::String;
let json = serde_json::to_string(&ft).unwrap();
let deserialized: FieldType = serde_json::from_str(&json).unwrap();
assert_eq!(ft, deserialized);
}
#[test]
fn field_type_serde_round_trip_nested() {
let ft = FieldType::List(Box::new(FieldType::Nullable(Box::new(FieldType::Int))));
let json = serde_json::to_string(&ft).unwrap();
let deserialized: FieldType = serde_json::from_str(&json).unwrap();
assert_eq!(ft, deserialized);
}
#[test]
fn field_type_serde_round_trip_object() {
let ft = FieldType::Object(vec![ObjectField {
name: "name".into(),
description: "A name".into(),
field_type: FieldType::String,
}]);
let json = serde_json::to_string(&ft).unwrap();
let deserialized: FieldType = serde_json::from_str(&json).unwrap();
assert_eq!(ft, deserialized);
}
#[test]
fn field_value_serde_round_trip() {
let val = FieldValue::Object(BTreeMap::from([
("name".into(), FieldValue::Str("Alice".into())),
("age".into(), FieldValue::Int(30)),
("active".into(), FieldValue::Bool(true)),
]));
let json = serde_json::to_string(&val).unwrap();
let deserialized: FieldValue = serde_json::from_str(&json).unwrap();
assert_eq!(val, deserialized);
}
#[test]
fn field_def_serde_round_trip() {
let fd = FieldDef::input("text", FieldType::String, "Input text");
let json = serde_json::to_string(&fd).unwrap();
let deserialized: FieldDef = serde_json::from_str(&json).unwrap();
assert_eq!(fd, deserialized);
}
#[test]
fn output_format_hint_none_for_string_and_media() {
assert!(FieldType::String.output_format_hint().is_none());
let media = FieldType::Media {
kind: MediaKind::Image,
accepted_sources: EnumSet::all(),
};
assert!(media.output_format_hint().is_none());
}
#[test]
fn output_format_hint_list_says_json_array() {
let hint = FieldType::List(Box::new(FieldType::String))
.output_format_hint()
.expect("list has a hint");
assert!(hint.contains("JSON array"), "got: {hint}");
assert!(hint.contains('['), "should show array brackets: {hint}");
}
#[test]
fn output_format_hint_bool_says_true_false() {
let hint = FieldType::Bool
.output_format_hint()
.expect("bool has a hint");
assert!(
hint.contains("true") && hint.contains("false"),
"got: {hint}"
);
}
#[test]
fn output_format_hint_enum_lists_variants() {
let hint = FieldType::Enum(vec!["yes".into(), "no".into()])
.output_format_hint()
.expect("enum has a hint");
assert!(hint.contains("yes") && hint.contains("no"), "got: {hint}");
}
#[test]
fn output_format_hint_nullable_mentions_null() {
let hint = FieldType::Nullable(Box::new(FieldType::List(Box::new(FieldType::String))))
.output_format_hint()
.expect("nullable has a hint");
assert!(hint.contains("null"), "got: {hint}");
}
#[test]
fn output_format_hint_object_and_map_say_json_object() {
let obj = FieldType::Object(vec![ObjectField {
name: "x".into(),
description: String::new(),
field_type: FieldType::Int,
}]);
assert!(
obj.output_format_hint()
.expect("object hint")
.contains("JSON object")
);
let map = FieldType::Map(Box::new(FieldType::Int));
assert!(
map.output_format_hint()
.expect("map hint")
.contains("JSON object")
);
}
fn variant_arm_obj(name: &str, field_type: FieldType) -> VariantArm {
VariantArm {
description: format!("arm: {name}"),
field_type,
}
}
#[test]
fn type_label_oneof_tagged_names_discriminator_and_tags() {
let ft = FieldType::OneOf {
arms: vec![
variant_arm_obj("a", FieldType::Object(vec![])),
variant_arm_obj("b", FieldType::Object(vec![])),
],
discriminator: Some(OneOfDiscriminator {
property: "kind".into(),
tags: vec!["a".into(), "b".into()],
}),
};
let label = ft.type_label();
assert!(label.contains("oneof"), "got: {label}");
assert!(label.contains("kind"), "got: {label}");
assert!(label.contains('a'), "got: {label}");
assert!(label.contains('b'), "got: {label}");
}
#[test]
fn type_label_oneof_untagged_lists_arm_labels() {
let ft = FieldType::OneOf {
arms: vec![
variant_arm_obj("int", FieldType::Int),
variant_arm_obj("str", FieldType::String),
],
discriminator: None,
};
let label = ft.type_label();
assert!(label.starts_with("oneof["), "got: {label}");
assert!(label.contains("int"), "got: {label}");
assert!(label.contains("str"), "got: {label}");
}
#[test]
fn type_label_anyof_lists_arm_labels() {
let ft = FieldType::AnyOf {
arms: vec![
variant_arm_obj("int", FieldType::Int),
variant_arm_obj("str", FieldType::String),
],
};
let label = ft.type_label();
assert!(label.starts_with("anyof["), "got: {label}");
assert!(label.contains("int"), "got: {label}");
assert!(label.contains("str"), "got: {label}");
}
#[test]
fn output_format_hint_oneof_tagged_points_to_variant_shapes() {
let ft = FieldType::OneOf {
arms: vec![variant_arm_obj("a", FieldType::Object(vec![]))],
discriminator: Some(OneOfDiscriminator {
property: "toolName".into(),
tags: vec!["a".into()],
}),
};
let hint = ft.output_format_hint().expect("hint");
assert!(hint.contains("toolName"), "got: {hint}");
assert!(hint.contains("Variant shapes"), "got: {hint}");
}
#[test]
fn output_format_hint_oneof_untagged_points_to_variant_shapes() {
let ft = FieldType::OneOf {
arms: vec![variant_arm_obj("int", FieldType::Int)],
discriminator: None,
};
let hint = ft.output_format_hint().expect("hint");
assert!(hint.contains("exactly one"), "got: {hint}");
assert!(hint.contains("Variant shapes"), "got: {hint}");
}
#[test]
fn output_format_hint_anyof_mentions_first_match() {
let ft = FieldType::AnyOf {
arms: vec![variant_arm_obj("int", FieldType::Int)],
};
let hint = ft.output_format_hint().expect("hint");
assert!(hint.contains("first match"), "got: {hint}");
}
#[test]
fn type_label_composes_oneof_inside_list() {
let ft = FieldType::List(Box::new(FieldType::OneOf {
arms: vec![
variant_arm_obj("a", FieldType::Object(vec![])),
variant_arm_obj("b", FieldType::Object(vec![])),
],
discriminator: Some(OneOfDiscriminator {
property: "kind".into(),
tags: vec!["a".into(), "b".into()],
}),
}));
let label = ft.type_label();
assert!(label.starts_with("list[oneof["), "got: {label}");
assert!(label.contains("kind"), "got: {label}");
}
#[test]
fn field_type_oneof_serde_round_trip() {
let ft = FieldType::OneOf {
arms: vec![
variant_arm_obj("a", FieldType::Object(vec![])),
variant_arm_obj("b", FieldType::Object(vec![])),
],
discriminator: Some(OneOfDiscriminator {
property: "kind".into(),
tags: vec!["a".into(), "b".into()],
}),
};
let json = serde_json::to_string(&ft).unwrap();
let restored: FieldType = serde_json::from_str(&json).unwrap();
assert_eq!(ft, restored);
}
#[test]
fn field_type_anyof_serde_round_trip() {
let ft = FieldType::AnyOf {
arms: vec![
variant_arm_obj("int", FieldType::Int),
variant_arm_obj("str", FieldType::String),
],
};
let json = serde_json::to_string(&ft).unwrap();
let restored: FieldType = serde_json::from_str(&json).unwrap();
assert_eq!(ft, restored);
}
#[test]
fn field_value_variant_serde_round_trip() {
let value = FieldValue::Variant {
arm_index: 1,
value: Box::new(FieldValue::Object(BTreeMap::from([(
"toolName".into(),
FieldValue::Str("ranked_items".into()),
)]))),
};
let json = serde_json::to_string(&value).unwrap();
let restored: FieldValue = serde_json::from_str(&json).unwrap();
assert_eq!(value, restored);
}
#[test]
fn field_value_object_still_round_trips_with_variant_in_lattice() {
let value = FieldValue::Object(BTreeMap::from([
("name".into(), FieldValue::Str("Alice".into())),
("age".into(), FieldValue::Int(30)),
("active".into(), FieldValue::Bool(true)),
]));
let json = serde_json::to_string(&value).unwrap();
let restored: FieldValue = serde_json::from_str(&json).unwrap();
assert_eq!(value, restored);
}
}