use crate::api::ApiClient;
use crate::message::Message;
pub trait StructuredOutput: Sized + Send + 'static {
fn name() -> &'static str;
fn schema() -> serde_json::Value;
fn from_value(v: serde_json::Value) -> Result<Self, StructuredError>
where
Self: serde::de::DeserializeOwned,
{
serde_json::from_value(v).map_err(StructuredError::Deserialize)
}
}
#[derive(Debug, Clone)]
pub struct ResponseFormat {
pub name: String,
pub schema: serde_json::Value,
pub strict: bool,
}
impl ResponseFormat {
#[must_use]
pub fn from_type<T: StructuredOutput>() -> Self {
Self {
name: T::name().to_string(),
schema: T::schema(),
strict: true,
}
}
#[must_use]
pub fn new(name: impl Into<String>, schema: serde_json::Value) -> Self {
Self {
name: name.into(),
schema,
strict: true,
}
}
}
#[derive(Debug, Clone, Default)]
#[non_exhaustive]
pub enum ToolConstraint {
#[default]
None,
Strict,
#[cfg(feature = "grammar")]
Grammar(std::sync::Arc<dyn crate::provider::grammar::ToolGrammarProvider>),
}
#[derive(Debug, Clone, Default)]
pub struct RequestOptions {
pub response_format: Option<ResponseFormat>,
pub tool_constraint: ToolConstraint,
pub model: Option<String>,
}
impl RequestOptions {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn with_model(mut self, model: impl Into<String>) -> Self {
let model = model.into();
if model.trim().is_empty() {
return self;
}
self.model = Some(model);
self
}
#[must_use]
pub fn with_response_format(mut self, rf: ResponseFormat) -> Self {
self.response_format = Some(rf);
self
}
#[must_use]
pub fn with_tool_constraint(mut self, c: ToolConstraint) -> Self {
self.tool_constraint = c;
self
}
}
#[derive(Debug, thiserror::Error)]
pub enum StructuredError {
#[error("structured output did not match the expected schema: {0}")]
Deserialize(#[from] serde_json::Error),
#[error("API error during structured output request: {0}")]
Api(crate::api::error::ApiError),
}
pub(crate) fn parse_json_lenient(text: &str) -> Option<serde_json::Value> {
if let Ok(v) = serde_json::from_str(text) {
return Some(v);
}
extract_json_substring(text)
}
pub(crate) fn extract_json_substring(text: &str) -> Option<serde_json::Value> {
let bytes = text.as_bytes();
let mut start = None;
let mut brace_depth: usize = 0;
let mut bracket_depth: usize = 0;
let mut in_string = false;
let mut escaped = false;
for (i, &byte) in bytes.iter().enumerate() {
if in_string {
if escaped {
escaped = false;
} else if byte == b'\\' {
escaped = true;
} else if byte == b'"' {
in_string = false;
}
continue;
}
match byte {
b'"' => {
in_string = true;
}
b'{' | b'[' => {
if start.is_none() {
start = Some(i);
}
if byte == b'{' {
brace_depth = brace_depth.saturating_add(1);
} else {
bracket_depth = bracket_depth.saturating_add(1);
}
}
b'}' | b']' => {
let Some(s) = start else {
continue;
};
let depth = if byte == b'}' {
&mut brace_depth
} else {
&mut bracket_depth
};
if *depth == 0 {
start = None;
brace_depth = 0;
bracket_depth = 0;
} else {
*depth = depth.saturating_sub(1);
if brace_depth == 0 && bracket_depth == 0 {
let slice = text.get(s..=i).unwrap_or(text);
if let Ok(v) = serde_json::from_str(slice) {
return Some(v);
}
start = None;
}
}
}
_ => {}
}
}
None
}
#[cfg(any(
feature = "anthropic",
feature = "grammar",
feature = "openai",
feature = "gemini"
))]
pub(crate) fn tighten_json_schema(schema: &serde_json::Value) -> serde_json::Value {
let mut out = schema.clone();
tighten_in_place(&mut out);
out
}
#[cfg(any(
feature = "anthropic",
feature = "grammar",
feature = "openai",
feature = "gemini"
))]
fn tighten_in_place(schema: &mut serde_json::Value) {
let Some(obj) = schema.as_object_mut() else {
return;
};
if let Some(properties) = obj
.get_mut("properties")
.and_then(serde_json::Value::as_object_mut)
{
for child in properties.values_mut() {
tighten_in_place(child);
}
}
if let Some(items) = obj.get_mut("items") {
tighten_in_place(items);
}
for key in ["allOf", "anyOf", "oneOf"] {
if let Some(arr) = obj.get_mut(key).and_then(serde_json::Value::as_array_mut) {
for child in arr {
tighten_in_place(child);
}
}
}
for key in ["$defs", "definitions"] {
if let Some(defs) = obj.get_mut(key).and_then(serde_json::Value::as_object_mut) {
for child in defs.values_mut() {
tighten_in_place(child);
}
}
}
let is_object = obj
.get("type")
.and_then(serde_json::Value::as_str)
.is_some_and(|t| t == "object");
if !is_object {
return;
}
obj.insert(
"additionalProperties".to_string(),
serde_json::Value::Bool(false),
);
let property_keys: Vec<String> = obj
.get("properties")
.and_then(serde_json::Value::as_object)
.map(|props| props.keys().cloned().collect())
.unwrap_or_default();
let mut required: Vec<serde_json::Value> = obj
.get("required")
.and_then(serde_json::Value::as_array)
.cloned()
.unwrap_or_default();
let already_listed: std::collections::HashSet<String> = required
.iter()
.filter_map(serde_json::Value::as_str)
.map(str::to_string)
.collect();
for key in property_keys {
if !already_listed.contains(key.as_str()) {
required.push(serde_json::Value::String(key));
}
}
obj.insert("required".to_string(), serde_json::Value::Array(required));
}
pub async fn request_structured<T: StructuredOutput + serde::de::DeserializeOwned>(
client: &dyn ApiClient,
messages: Vec<Message>,
system: Option<String>,
) -> Result<T, StructuredError> {
let opts = RequestOptions::new().with_response_format(ResponseFormat::from_type::<T>());
let request = crate::api::StreamRequest {
messages,
system,
tools: None,
};
let response = client
.create_message_with_options(&request, opts)
.await
.map_err(StructuredError::Api)?;
let value = client.extract_structured(&response.message);
T::from_value(value)
}
#[cfg(test)]
mod tests {
use super::*;
use std::future::Future;
use std::pin::Pin;
#[derive(Debug, serde::Serialize, serde::Deserialize, PartialEq)]
struct Action {
tool: String,
args: serde_json::Value,
}
impl StructuredOutput for Action {
fn name() -> &'static str {
"action"
}
fn schema() -> serde_json::Value {
serde_json::json!({
"type": "object",
"properties": {
"tool": { "type": "string" },
"args": {}
},
"required": ["tool", "args"],
"additionalProperties": false
})
}
}
fn fixture_action() -> serde_json::Value {
serde_json::json!({
"tool": "write",
"args": { "path": "/tmp/test.txt" }
})
}
#[test]
fn with_model_ignores_empty_and_whitespace_names() {
let opts = RequestOptions::default().with_model("");
assert!(
opts.model.is_none(),
"an empty model name leaves the override unset — providers reject nameless models"
);
let opts = RequestOptions::default().with_model(" ");
assert!(
opts.model.is_none(),
"a whitespace-only model name leaves the override unset"
);
let opts = RequestOptions::default().with_model("fallback-model");
assert_eq!(opts.model.as_deref(), Some("fallback-model"));
}
#[test]
fn structured_output_round_trip() {
let v = fixture_action();
let action: Action = Action::from_value(v).expect("should deserialize");
assert_eq!(action.tool, "write");
assert_eq!(action.args, serde_json::json!({ "path": "/tmp/test.txt" }));
}
#[test]
fn response_format_from_type() {
let rf = ResponseFormat::from_type::<Action>();
assert_eq!(rf.name, "action");
assert_eq!(rf.schema, Action::schema());
assert!(rf.strict);
}
#[test]
fn request_options_builder() {
let opts = RequestOptions::new();
assert!(opts.response_format.is_none());
let rf = ResponseFormat::from_type::<Action>();
let opts = RequestOptions::new().with_response_format(rf);
assert!(opts.response_format.is_some());
assert_eq!(opts.response_format.as_ref().unwrap().name, "action");
}
#[test]
fn parse_json_lenient_plain_json() {
let v = parse_json_lenient(r#"{"a": 1}"#).unwrap();
assert_eq!(v["a"], 1);
}
#[test]
fn parse_json_lenient_with_prefix() {
let v = parse_json_lenient(r#"Here is the JSON: {"a": 1}"#).unwrap();
assert_eq!(v["a"], 1);
}
#[test]
fn parse_json_lenient_markdown_fences() {
let v = parse_json_lenient("```json\n{\"a\": 1}\n```").unwrap();
assert_eq!(v["a"], 1);
}
#[test]
fn parse_json_lenient_array() {
let v = parse_json_lenient(r#"prefix [1, 2, 3] suffix"#).unwrap();
assert_eq!(v[0], 1);
}
#[test]
fn parse_json_lenient_no_json() {
let result = parse_json_lenient("just prose, nothing here");
assert!(result.is_none());
}
#[test]
fn structured_error_displays() {
let json_err = serde_json::from_str::<serde_json::Value>("bad").unwrap_err();
let err = StructuredError::Deserialize(json_err);
assert!(err.to_string().contains("schema"));
}
#[test]
fn parse_json_lenient_brace_inside_string() {
let v = parse_json_lenient(r#"prefix {"a": "}"} suffix"#).unwrap();
assert_eq!(v["a"], "}");
}
#[test]
fn parse_json_lenient_bracket_inside_string() {
let v = parse_json_lenient(r#"before {"x": "]"} after"#).unwrap();
assert_eq!(v["x"], "]");
}
#[test]
fn parse_json_lenient_escaped_quote_in_string() {
let v = parse_json_lenient(r#"here {"a": "he said \"hi\""} there"#).unwrap();
assert_eq!(v["a"], "he said \"hi\"");
}
#[test]
fn parse_json_lenient_nested_objects_in_prose() {
let v = parse_json_lenient(r#"result: {"outer": {"inner": 42}}"#).unwrap();
assert_eq!(v["outer"]["inner"], 42);
}
#[test]
fn parse_json_lenient_mismatched_delimiter_then_valid() {
let v = parse_json_lenient(r#"{oops] then {"a":1}"#).unwrap();
assert_eq!(v["a"], 1);
}
struct PlainMockClient;
impl crate::api::ApiClient for PlainMockClient {
fn model(&self) -> String {
"test".to_string()
}
fn stream_messages(
&self,
_request: &crate::api::StreamRequest,
) -> Pin<
Box<
dyn futures::Stream<
Item = Result<crate::stream::StreamEvent, crate::api::error::ApiError>,
> + Send
+ 'static,
>,
> {
Box::pin(futures::stream::empty())
}
fn create_message(
&self,
_request: &crate::api::StreamRequest,
) -> Pin<
Box<
dyn Future<
Output = Result<
crate::api::NonStreamingResponse,
crate::api::error::ApiError,
>,
> + Send
+ '_,
>,
> {
Box::pin(async {
Ok(crate::api::NonStreamingResponse {
message: crate::message::Message::assistant(""),
stop_reason: crate::stream::StreamStopReason::EndTurn,
usage: Some(crate::stream::Usage::default()),
})
})
}
}
#[tokio::test]
async fn default_client_rejects_response_format() {
let client = PlainMockClient;
let opts =
RequestOptions::new().with_response_format(ResponseFormat::from_type::<Action>());
let request = crate::api::StreamRequest::new(vec![]);
let result = client.create_message_with_options(&request, opts).await;
assert!(
result.is_err(),
"client without structured-output support should reject response_format"
);
let err_msg = result.unwrap_err().to_string();
assert!(
err_msg.contains("does not support structured output"),
"error should explain why: {err_msg}"
);
}
#[tokio::test]
async fn default_client_delegates_empty_options() {
let client = PlainMockClient;
let opts = RequestOptions::new();
let request = crate::api::StreamRequest::new(vec![]);
let result = client.create_message_with_options(&request, opts).await;
assert!(result.is_ok(), "empty options should delegate normally");
}
struct StructuredMockClient;
impl crate::api::ApiClient for StructuredMockClient {
fn model(&self) -> String {
"test".to_string()
}
fn stream_messages(
&self,
_request: &crate::api::StreamRequest,
) -> Pin<
Box<
dyn futures::Stream<
Item = Result<crate::stream::StreamEvent, crate::api::error::ApiError>,
> + Send
+ 'static,
>,
> {
Box::pin(futures::stream::empty())
}
fn create_message(
&self,
_request: &crate::api::StreamRequest,
) -> Pin<
Box<
dyn Future<
Output = Result<
crate::api::NonStreamingResponse,
crate::api::error::ApiError,
>,
> + Send
+ '_,
>,
> {
Box::pin(async {
Ok(crate::api::NonStreamingResponse {
message: crate::message::Message::assistant(""),
stop_reason: crate::stream::StreamStopReason::EndTurn,
usage: Some(crate::stream::Usage::default()),
})
})
}
fn create_message_with_options(
&self,
_request: &crate::api::StreamRequest,
_options: RequestOptions,
) -> Pin<
Box<
dyn Future<
Output = Result<
crate::api::NonStreamingResponse,
crate::api::error::ApiError,
>,
> + Send
+ '_,
>,
> {
Box::pin(async {
Ok(crate::api::NonStreamingResponse {
message: crate::message::Message::assistant(
r#"{"tool": "write", "args": {"path": "/test"}}"#,
),
stop_reason: crate::stream::StreamStopReason::EndTurn,
usage: Some(crate::stream::Usage::default()),
})
})
}
}
#[tokio::test]
async fn request_structured_end_to_end() {
let client = StructuredMockClient;
let action: Action = request_structured(&client, vec![], None)
.await
.expect("should succeed");
assert_eq!(action.tool, "write");
}
struct ProseMockClient;
impl crate::api::ApiClient for ProseMockClient {
fn model(&self) -> String {
"test".to_string()
}
fn stream_messages(
&self,
_request: &crate::api::StreamRequest,
) -> Pin<
Box<
dyn futures::Stream<
Item = Result<crate::stream::StreamEvent, crate::api::error::ApiError>,
> + Send
+ 'static,
>,
> {
Box::pin(futures::stream::empty())
}
fn create_message(
&self,
_request: &crate::api::StreamRequest,
) -> Pin<
Box<
dyn Future<
Output = Result<
crate::api::NonStreamingResponse,
crate::api::error::ApiError,
>,
> + Send
+ '_,
>,
> {
Box::pin(async {
Ok(crate::api::NonStreamingResponse {
message: crate::message::Message::assistant(""),
stop_reason: crate::stream::StreamStopReason::EndTurn,
usage: Some(crate::stream::Usage::default()),
})
})
}
fn create_message_with_options(
&self,
_request: &crate::api::StreamRequest,
_options: RequestOptions,
) -> Pin<
Box<
dyn Future<
Output = Result<
crate::api::NonStreamingResponse,
crate::api::error::ApiError,
>,
> + Send
+ '_,
>,
> {
Box::pin(async {
Ok(crate::api::NonStreamingResponse {
message: crate::message::Message::assistant("I cannot produce that."),
stop_reason: crate::stream::StreamStopReason::EndTurn,
usage: Some(crate::stream::Usage::default()),
})
})
}
}
#[tokio::test]
async fn request_structured_prose_returns_deserialize_error() {
let client = ProseMockClient;
let err = request_structured::<Action>(&client, vec![], None)
.await
.expect_err("should fail");
assert!(matches!(err, StructuredError::Deserialize(_)));
}
#[test]
fn tool_constraint_default_is_none() {
assert!(matches!(ToolConstraint::default(), ToolConstraint::None));
assert!(matches!(
RequestOptions::default().tool_constraint,
ToolConstraint::None
));
}
#[test]
fn request_options_tool_constraint_builder() {
let opts = RequestOptions::new().with_tool_constraint(ToolConstraint::Strict);
assert!(matches!(opts.tool_constraint, ToolConstraint::Strict));
let rf = ResponseFormat::from_type::<Action>();
let opts = RequestOptions::new()
.with_response_format(rf)
.with_tool_constraint(ToolConstraint::Strict);
assert!(opts.response_format.is_some());
assert!(matches!(opts.tool_constraint, ToolConstraint::Strict));
}
#[test]
fn tool_constraint_clone_compiles() {
let opts = RequestOptions::new().with_tool_constraint(ToolConstraint::Strict);
let cloned = opts.clone();
assert!(matches!(opts.tool_constraint, ToolConstraint::Strict));
assert!(matches!(cloned.tool_constraint, ToolConstraint::Strict));
}
#[cfg(any(
feature = "anthropic",
feature = "grammar",
feature = "openai",
feature = "gemini"
))]
#[cfg(any(
feature = "anthropic",
feature = "grammar",
feature = "openai",
feature = "gemini"
))]
#[test]
fn tighten_sets_additional_properties_false() {
let schema = serde_json::json!({
"type": "object",
"properties": {"a": {"type": "string"}}
});
let tightened = tighten_json_schema(&schema);
assert_eq!(tightened["additionalProperties"], false);
}
#[cfg(any(
feature = "anthropic",
feature = "grammar",
feature = "openai",
feature = "gemini"
))]
#[cfg(any(
feature = "anthropic",
feature = "grammar",
feature = "openai",
feature = "gemini"
))]
#[test]
fn tighten_enumerates_required() {
let schema = serde_json::json!({
"type": "object",
"properties": {
"a": {"type": "string"},
"b": {"type": "number"}
}
});
let tightened = tighten_json_schema(&schema);
let required = tightened["required"].as_array().unwrap();
assert_eq!(required.len(), 2);
let keys: Vec<&str> = required.iter().map(|v| v.as_str().unwrap()).collect();
assert!(keys.contains(&"a"));
assert!(keys.contains(&"b"));
}
#[cfg(any(
feature = "anthropic",
feature = "grammar",
feature = "openai",
feature = "gemini"
))]
#[cfg(any(
feature = "anthropic",
feature = "grammar",
feature = "openai",
feature = "gemini"
))]
#[test]
fn tighten_recurses_into_nested_objects() {
let schema = serde_json::json!({
"type": "object",
"properties": {
"inner": {
"type": "object",
"properties": {"x": {"type": "string"}}
}
}
});
let tightened = tighten_json_schema(&schema);
assert_eq!(
tightened["properties"]["inner"]["additionalProperties"],
false
);
let inner_required = tightened["properties"]["inner"]["required"]
.as_array()
.unwrap();
assert_eq!(inner_required.len(), 1);
assert_eq!(inner_required[0], "x");
}
#[cfg(any(
feature = "anthropic",
feature = "grammar",
feature = "openai",
feature = "gemini"
))]
#[cfg(any(
feature = "anthropic",
feature = "grammar",
feature = "openai",
feature = "gemini"
))]
#[test]
fn tighten_preserves_non_object_schemas() {
let schema = serde_json::json!({"type": "string"});
let tightened = tighten_json_schema(&schema);
assert_eq!(tightened, schema);
assert!(tightened.get("additionalProperties").is_none());
assert!(tightened.get("required").is_none());
}
#[cfg(any(
feature = "anthropic",
feature = "grammar",
feature = "openai",
feature = "gemini"
))]
#[cfg(any(
feature = "anthropic",
feature = "grammar",
feature = "openai",
feature = "gemini"
))]
#[test]
fn tighten_idempotent_on_already_strict() {
let schema = serde_json::json!({
"type": "object",
"additionalProperties": false,
"properties": {"a": {"type": "string"}},
"required": ["a"]
});
let once = tighten_json_schema(&schema);
let twice = tighten_json_schema(&once);
assert_eq!(once, twice);
}
#[cfg(any(
feature = "anthropic",
feature = "grammar",
feature = "openai",
feature = "gemini"
))]
#[cfg(any(
feature = "anthropic",
feature = "grammar",
feature = "openai",
feature = "gemini"
))]
#[test]
fn tighten_object_without_properties() {
let schema = serde_json::json!({"type": "object"});
let tightened = tighten_json_schema(&schema);
assert_eq!(tightened["additionalProperties"], false);
assert_eq!(tightened["required"].as_array().unwrap().len(), 0);
}
#[cfg(any(
feature = "anthropic",
feature = "grammar",
feature = "openai",
feature = "gemini"
))]
#[cfg(any(
feature = "anthropic",
feature = "grammar",
feature = "openai",
feature = "gemini"
))]
#[test]
fn tighten_recurses_into_local_defs() {
let schema = serde_json::json!({
"type": "object",
"properties": {
"filter": {"$ref": "#/$defs/Filter"}
},
"$defs": {
"Filter": {
"type": "object",
"properties": {
"lang": {"type": "string"},
"limit": {"type": "number"}
}
}
}
});
let tightened = tighten_json_schema(&schema);
assert_eq!(tightened["additionalProperties"], false);
assert_eq!(tightened["required"], serde_json::json!(["filter"]));
let filter = &tightened["$defs"]["Filter"];
assert_eq!(filter["additionalProperties"], false);
let required = filter["required"].as_array().unwrap();
assert_eq!(required.len(), 2);
let keys: Vec<&str> = required.iter().map(|v| v.as_str().unwrap()).collect();
assert!(keys.contains(&"lang"));
assert!(keys.contains(&"limit"));
assert_eq!(tightened["properties"]["filter"]["$ref"], "#/$defs/Filter");
}
#[cfg(any(
feature = "anthropic",
feature = "grammar",
feature = "openai",
feature = "gemini"
))]
#[cfg(any(
feature = "anthropic",
feature = "grammar",
feature = "openai",
feature = "gemini"
))]
#[test]
fn tighten_recurses_into_legacy_definitions() {
let schema = serde_json::json!({
"type": "object",
"properties": {"x": {"$ref": "#/definitions/X"}},
"definitions": {
"X": {
"type": "object",
"properties": {"a": {"type": "string"}}
}
}
});
let tightened = tighten_json_schema(&schema);
let def = &tightened["definitions"]["X"];
assert_eq!(def["additionalProperties"], false);
assert_eq!(def["required"].as_array().unwrap().len(), 1);
}
#[test]
fn parse_json_lenient_object_containing_array() {
let v = parse_json_lenient(r#"prefix {"a": [1, 2]} suffix"#).unwrap();
assert_eq!(v["a"], serde_json::json!([1, 2]));
}
#[test]
fn parse_json_lenient_array_containing_object() {
let v = parse_json_lenient(r#"prefix [{"a": 1}] suffix"#).unwrap();
assert_eq!(v[0]["a"], 1);
}
#[test]
fn parse_json_lenient_fenced_failure_analysis_shape() {
let v = parse_json_lenient("```json\n{\"m\": {\"p\": [1]}}\n```").unwrap();
assert!(v.is_object());
assert_eq!(v["m"]["p"], serde_json::json!([1]));
}
#[cfg(any(
feature = "anthropic",
feature = "grammar",
feature = "openai",
feature = "gemini"
))]
#[test]
fn tighten_preserves_required_entries_without_matching_property() {
let schema = serde_json::json!({
"type": "object",
"properties": {"a": {"type": "string"}},
"required": ["a", "meta"]
});
let tightened = tighten_json_schema(&schema);
let required = tightened["required"].as_array().unwrap();
assert!(
required.iter().any(|v| v == "meta"),
"required entry without a matching property must survive tightening: {required:?}"
);
}
#[test]
fn parse_json_lenient_deeply_mixed_nesting_extracts_outermost() {
let v = parse_json_lenient(
r#"analysis: {"is_recoverable":true,"correction":{"modified_input":{"path":["a"]}}}"#,
)
.unwrap();
assert_eq!(
v["correction"]["modified_input"]["path"],
serde_json::json!(["a"])
);
}
#[test]
fn parse_json_lenient_object_with_array_of_objects() {
let v = parse_json_lenient(r#"{"a": [{"b": 2}]}"#).unwrap();
assert_eq!(v["a"][0]["b"], 2);
}
#[test]
fn parse_json_lenient_stray_brace_in_array_resumes_scan() {
let v = parse_json_lenient(r#"[1, 2} then {"a":1}"#).unwrap();
assert_eq!(
v["a"], 1,
"a stray brace inside an array candidate aborts it; the later object still extracts"
);
}
#[cfg(any(
feature = "anthropic",
feature = "grammar",
feature = "openai",
feature = "gemini"
))]
#[test]
fn tighten_required_union_keeps_order_then_appends_properties() {
let schema = serde_json::json!({
"type": "object",
"properties": {"b": {"type": "number"}, "a": {"type": "string"}},
"required": ["meta"]
});
let tightened = tighten_json_schema(&schema);
let required = tightened["required"].as_array().unwrap();
assert_eq!(
required.first(),
Some(&serde_json::json!("meta")),
"pre-existing entries keep their position ahead of appended property keys"
);
assert_eq!(
required.len(),
3,
"every property key is appended exactly once"
);
assert!(
required.contains(&serde_json::json!("a"))
&& required.contains(&serde_json::json!("b")),
"the unlisted property keys join the union, in map order: {required:?}"
);
let twice = tighten_json_schema(&tightened);
assert_eq!(
twice["required"], tightened["required"],
"the union is idempotent: a second pass neither reorders nor duplicates"
);
}
#[test]
fn parse_json_lenient_first_valid_candidate_wins() {
let v = parse_json_lenient(r#"{"a": 1} and {"b": 2}"#).unwrap();
assert_eq!(
v["a"], 1,
"the outermost candidate that parses wins; a later sibling is not preferred"
);
}
#[test]
fn parse_json_lenient_unparseable_candidate_then_valid() {
let v = parse_json_lenient(r#"{"a": } then {"b": 1}"#).unwrap();
assert_eq!(
v["b"], 1,
"a balanced but invalid candidate is abandoned at its close; the scan resumes"
);
}
#[test]
fn parse_json_lenient_unterminated_json_returns_none() {
let result = parse_json_lenient(r#"prefix {"a": 1"#);
assert_eq!(
result, None,
"a candidate that never closes yields nothing, not a panic or a partial value"
);
}
#[test]
fn parse_json_lenient_nested_arrays() {
let v = parse_json_lenient(r#"result [[1, 2], [3]]"#).unwrap();
assert_eq!(v[0][1], 2);
assert_eq!(v[1][0], 3);
}
#[test]
fn parse_json_lenient_empty_containers_in_prose() {
let empty_object = parse_json_lenient(r#"text {} more"#).unwrap();
assert!(
empty_object
.as_object()
.is_some_and(serde_json::Map::is_empty)
);
let empty_array = parse_json_lenient(r#"text [] more"#).unwrap();
assert!(empty_array.as_array().is_some_and(Vec::is_empty));
}
#[test]
fn parse_json_lenient_escaped_backslash_in_string() {
let v = parse_json_lenient(r#"{"path": "c:\\"}"#).unwrap();
assert_eq!(v["path"], "c:\\");
}
#[cfg(any(
feature = "anthropic",
feature = "grammar",
feature = "openai",
feature = "gemini"
))]
#[test]
fn tighten_required_union_applies_to_nested_objects() {
let schema = serde_json::json!({
"type": "object",
"properties": {
"filter": {
"type": "object",
"properties": {"lang": {"type": "string"}},
"required": ["secret"]
}
}
});
let tightened = tighten_json_schema(&schema);
assert_eq!(
tightened["properties"]["filter"]["required"],
serde_json::json!(["secret", "lang"]),
"the union applies at every object level, not just the root"
);
}
}