1use async_trait::async_trait;
38use futures_util::SinkExt as _;
39use futures_util::StreamExt as _;
40use serde_json::{Map, Value};
41
42use crate::ports::{
43 Capability, Completion, CompletionBuilder, Prompt, TextDelta, TextModel, TextModelError,
44 ToolCall, ToolResult, ToolSpec, Turn,
45};
46use crate::stream::{BoxStream, pump};
47
48#[async_trait]
54pub trait ToolExecutor: Send + Sync {
55 async fn execute(&self, call: &ToolCall) -> Result<String, String>;
62}
63
64#[derive(Debug, Clone, PartialEq, Eq)]
71#[non_exhaustive]
72pub struct ToolBudget {
73 pub max_steps: u32,
77 pub max_total_tokens: Option<u64>,
80}
81
82impl ToolBudget {
83 #[must_use]
85 pub fn steps(max_steps: u32) -> Self {
86 Self {
87 max_steps,
88 max_total_tokens: None,
89 }
90 }
91
92 #[must_use]
94 pub fn max_total_tokens(mut self, max_total_tokens: u64) -> Self {
95 self.max_total_tokens = Some(max_total_tokens);
96 self
97 }
98}
99
100#[derive(Debug, Clone, PartialEq, Eq)]
104pub struct StepUsage {
105 pub input_tokens: u64,
106 pub output_tokens: u64,
107 pub cached_input_tokens: Option<u64>,
111 pub tool_calls: usize,
113}
114
115#[derive(Debug, Clone, PartialEq)]
117pub struct ToolLoopOutcome {
118 pub completion: Completion,
120 pub messages: Vec<Turn>,
125 pub steps: Vec<StepUsage>,
127}
128
129impl ToolLoopOutcome {
130 #[must_use]
132 pub fn input_tokens(&self) -> u64 {
133 self.steps.iter().map(|step| step.input_tokens).sum()
134 }
135
136 #[must_use]
138 pub fn output_tokens(&self) -> u64 {
139 self.steps.iter().map(|step| step.output_tokens).sum()
140 }
141}
142
143#[derive(Debug, Clone, PartialEq, Eq)]
147#[non_exhaustive]
148pub enum ToolLoopError {
149 Model(TextModelError),
152 StepBudgetExhausted { steps: Vec<StepUsage> },
156 TokenBudgetExhausted { used: u64, steps: Vec<StepUsage> },
159 InvalidToolSchema { tool: String, reason: String },
163}
164
165impl std::fmt::Display for ToolLoopError {
166 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
167 let scrub = crate::logging::scrub_text;
168 match self {
169 Self::Model(error) => write!(f, "the text model failed: {error}"),
170 Self::StepBudgetExhausted { steps } => write!(
171 f,
172 "the tool loop exhausted its step budget after {} model calls",
173 steps.len()
174 ),
175 Self::TokenBudgetExhausted { used, steps } => write!(
176 f,
177 "the tool loop spent {used} tokens over {} model calls, past its token budget",
178 steps.len()
179 ),
180 Self::InvalidToolSchema { tool, reason } => write!(
181 f,
182 "tool {tool:?} has an invalid argument schema: {}",
183 scrub(reason)
184 ),
185 }
186 }
187}
188
189impl std::error::Error for ToolLoopError {
190 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
191 match self {
192 Self::Model(error) => Some(error),
193 _ => None,
194 }
195 }
196}
197
198pub async fn run_tool_loop(
224 model: &dyn TextModel,
225 mut prompt: Prompt,
226 executor: &dyn ToolExecutor,
227 budget: ToolBudget,
228) -> Result<ToolLoopOutcome, ToolLoopError> {
229 preflight(model, &prompt)?;
230
231 let mut steps: Vec<StepUsage> = Vec::new();
232 let mut step_count: u32 = 0;
233 let mut used_tokens: u64 = 0;
234
235 loop {
236 if step_count >= budget.max_steps {
240 return Err(ToolLoopError::StepBudgetExhausted { steps });
241 }
242
243 let completion = model
244 .complete(&prompt)
245 .await
246 .map_err(ToolLoopError::Model)?;
247 step_count += 1;
248 match settle_step(
249 completion,
250 &mut prompt.messages,
251 &mut steps,
252 &mut used_tokens,
253 &budget,
254 )
255 .after
256 {
257 AfterStep::Final(outcome) => return Ok(outcome),
258 AfterStep::Exhausted(error) => return Err(error),
259 AfterStep::Calls(calls) => {
260 let mut results = Vec::with_capacity(calls.len());
261 for call in &calls {
262 results.push(execute_call(&prompt.tools, executor, call).await);
263 }
264 prompt.messages.push(Turn::tool_results(results));
265 }
266 }
267 }
268}
269
270#[derive(Debug, Clone, PartialEq)]
281#[non_exhaustive]
282pub enum ToolLoopEvent {
283 Delta {
287 step: usize,
290 delta: TextDelta,
292 },
293 ToolResult {
298 step: usize,
300 call: ToolCall,
302 result: ToolResult,
304 },
305 StepFinished {
309 step: usize,
311 usage: StepUsage,
313 },
314 Done(ToolLoopOutcome),
317}
318
319type EventSender = futures_channel::mpsc::Sender<Result<ToolLoopEvent, ToolLoopError>>;
324
325pub fn run_tool_loop_stream<'a>(
370 model: &'a dyn TextModel,
371 prompt: Prompt,
372 executor: &'a dyn ToolExecutor,
373 budget: ToolBudget,
374) -> BoxStream<'a, Result<ToolLoopEvent, ToolLoopError>> {
375 pump(move |tx| stream_loop(model, prompt, executor, budget, tx))
380}
381
382async fn stream_loop(
386 model: &dyn TextModel,
387 mut prompt: Prompt,
388 executor: &dyn ToolExecutor,
389 budget: ToolBudget,
390 mut tx: EventSender,
391) {
392 if let Err(error) = preflight(model, &prompt) {
395 let _ = tx.send(Err(error)).await;
396 return;
397 }
398
399 let mut steps: Vec<StepUsage> = Vec::new();
400 let mut step_count: u32 = 0;
401 let mut used_tokens: u64 = 0;
402
403 loop {
404 if step_count >= budget.max_steps {
407 let _ = tx
408 .send(Err(ToolLoopError::StepBudgetExhausted { steps }))
409 .await;
410 return;
411 }
412 let step = usize::try_from(step_count).unwrap_or(usize::MAX);
413 step_count += 1;
414
415 let Some(completion) = stream_step(model, &prompt, step, &mut tx).await else {
416 return;
419 };
420
421 let settled = settle_step(
422 completion,
423 &mut prompt.messages,
424 &mut steps,
425 &mut used_tokens,
426 &budget,
427 );
428 if tx
429 .send(Ok(ToolLoopEvent::StepFinished {
430 step,
431 usage: settled.usage,
432 }))
433 .await
434 .is_err()
435 {
436 return;
437 }
438 match settled.after {
439 AfterStep::Final(outcome) => {
440 let _ = tx.send(Ok(ToolLoopEvent::Done(outcome))).await;
441 return;
442 }
443 AfterStep::Exhausted(error) => {
444 let _ = tx.send(Err(error)).await;
445 return;
446 }
447 AfterStep::Calls(calls) => {
448 let mut results = Vec::with_capacity(calls.len());
449 for call in &calls {
450 let result = execute_call(&prompt.tools, executor, call).await;
451 results.push(result.clone());
452 if tx
453 .send(Ok(ToolLoopEvent::ToolResult {
454 step,
455 call: call.clone(),
456 result,
457 }))
458 .await
459 .is_err()
460 {
461 return;
462 }
463 }
464 prompt.messages.push(Turn::tool_results(results));
465 }
466 }
467 }
468}
469
470async fn stream_step(
478 model: &dyn TextModel,
479 prompt: &Prompt,
480 step: usize,
481 tx: &mut EventSender,
482) -> Option<Completion> {
483 let mut builder = CompletionBuilder::default();
484 let mut deltas = model.stream(prompt);
485 while let Some(delta) = deltas.next().await {
486 match delta {
487 Ok(delta) => {
488 builder.push(&delta);
489 if tx
490 .send(Ok(ToolLoopEvent::Delta { step, delta }))
491 .await
492 .is_err()
493 {
494 return None;
495 }
496 }
497 Err(error) => {
498 let _ = tx.send(Err(ToolLoopError::Model(error))).await;
499 return None;
500 }
501 }
502 }
503 Some(builder.finish())
504}
505
506enum AfterStep {
511 Final(ToolLoopOutcome),
514 Exhausted(ToolLoopError),
518 Calls(Vec<ToolCall>),
522}
523
524struct Settled {
527 usage: StepUsage,
528 after: AfterStep,
529}
530
531fn settle_step(
537 completion: Completion,
538 messages: &mut Vec<Turn>,
539 steps: &mut Vec<StepUsage>,
540 used_tokens: &mut u64,
541 budget: &ToolBudget,
542) -> Settled {
543 let usage = step_usage(&completion);
544 steps.push(usage.clone());
545 *used_tokens = used_tokens.saturating_add(
546 completion
547 .input_tokens
548 .saturating_add(completion.output_tokens),
549 );
550
551 if completion.tool_calls.is_empty() {
556 return Settled {
557 usage,
558 after: AfterStep::Final(ToolLoopOutcome {
559 completion,
560 messages: std::mem::take(messages),
561 steps: std::mem::take(steps),
562 }),
563 };
564 }
565
566 if let Some(limit) = budget.max_total_tokens
570 && *used_tokens > limit
571 {
572 return Settled {
573 usage,
574 after: AfterStep::Exhausted(ToolLoopError::TokenBudgetExhausted {
575 used: *used_tokens,
576 steps: std::mem::take(steps),
577 }),
578 };
579 }
580
581 let calls = completion.tool_calls;
582 messages.push(Turn::assistant_tool_calls(completion.text, calls.clone()));
583 Settled {
584 usage,
585 after: AfterStep::Calls(calls),
586 }
587}
588
589fn preflight(model: &dyn TextModel, prompt: &Prompt) -> Result<(), ToolLoopError> {
595 prompt.check_images().map_err(ToolLoopError::Model)?;
598
599 if !prompt.tools.is_empty() && !model.supports(prompt.tier, Capability::Tools) {
604 return Err(ToolLoopError::Model(TextModelError::Unsupported(
605 Capability::Tools,
606 )));
607 }
608 if prompt.has_images() && !model.supports(prompt.tier, Capability::Images) {
611 return Err(ToolLoopError::Model(TextModelError::Unsupported(
612 Capability::Images,
613 )));
614 }
615 for tool in &prompt.tools {
616 validate_tool_schema(&tool.name, &tool.parameters)?;
617 }
618 Ok(())
619}
620
621fn step_usage(completion: &Completion) -> StepUsage {
624 StepUsage {
625 input_tokens: completion.input_tokens,
626 output_tokens: completion.output_tokens,
627 cached_input_tokens: completion.cached_input_tokens,
628 tool_calls: completion.tool_calls.len(),
629 }
630}
631
632async fn execute_call(
636 tools: &[ToolSpec],
637 executor: &dyn ToolExecutor,
638 call: &ToolCall,
639) -> ToolResult {
640 let Some(spec) = tools.iter().find(|tool| tool.name == call.name) else {
641 return ToolResult::error(
642 call.id.clone(),
643 format!("no tool named {:?} was offered", call.name),
644 );
645 };
646 if !call.arguments.is_object() {
654 return ToolResult::error(
655 call.id.clone(),
656 format!("arguments for tool {:?} must be a JSON object", call.name),
657 );
658 }
659 if let Err(violation) = validate_instance(&spec.parameters, &call.arguments) {
660 return ToolResult::error(
661 call.id.clone(),
662 format!("arguments do not match tool {:?}: {violation}", call.name),
663 );
664 }
665 match executor.execute(call).await {
666 Ok(content) => ToolResult::ok(call.id.clone(), content),
667 Err(message) => ToolResult::error(call.id.clone(), message),
668 }
669}
670
671const ANNOTATIONS: &[&str] = &[
675 "title",
676 "description",
677 "default",
678 "examples",
679 "$schema",
680 "$id",
681 "$comment",
682 "format",
683];
684
685const TYPE_NAMES: &[&str] = &[
687 "object", "array", "string", "number", "integer", "boolean", "null",
688];
689
690fn invalid_schema(tool: &str, reason: impl Into<String>) -> ToolLoopError {
691 ToolLoopError::InvalidToolSchema {
692 tool: tool.to_owned(),
693 reason: reason.into(),
694 }
695}
696
697fn validate_tool_schema(tool: &str, schema: &Value) -> Result<(), ToolLoopError> {
701 validate_schema(schema).map_err(|reason| invalid_schema(tool, reason))
702}
703
704pub(crate) fn validate_schema(schema: &Value) -> Result<(), String> {
709 let Some(object) = schema.as_object() else {
710 return Err("the schema is not a JSON object".to_owned());
711 };
712 for (keyword, value) in object {
713 match keyword.as_str() {
714 "type" => check_type_keyword(value)?,
715 "properties" => check_properties(value)?,
716 "required" => check_required(value)?,
717 "additionalProperties" => check_additional_properties(value)?,
718 "items" => validate_schema(value)?,
719 "enum" => {
720 if !value.is_array() {
721 return Err("`enum` must be an array".to_owned());
722 }
723 }
724 "const" => {}
725 "minimum" | "maximum" | "exclusiveMinimum" | "exclusiveMaximum" => {
726 if !value.is_number() {
727 return Err(format!("`{keyword}` must be a number"));
728 }
729 }
730 "minLength" | "maxLength" | "minItems" | "maxItems" => {
731 if value.as_u64().is_none() {
732 return Err(format!("`{keyword}` must be a non-negative integer"));
733 }
734 }
735 "anyOf" | "oneOf" | "allOf" => check_schema_list(keyword, value)?,
736 other if ANNOTATIONS.contains(&other) => {}
737 other => return Err(format!("the `{other}` keyword is not supported")),
738 }
739 }
740 Ok(())
741}
742
743fn check_type_keyword(value: &Value) -> Result<(), String> {
744 let names: Vec<&str> = match value {
745 Value::String(name) => vec![name.as_str()],
746 Value::Array(items) => {
747 if !items.iter().all(Value::is_string) {
748 return Err("`type` must name strings when given as an array".to_owned());
749 }
750 items.iter().filter_map(Value::as_str).collect()
751 }
752 _ => return Err("`type` must be a string or an array of strings".to_owned()),
753 };
754 if names.is_empty() || !names.iter().all(|name| TYPE_NAMES.contains(name)) {
755 return Err("`type` names an unknown JSON type".to_owned());
756 }
757 Ok(())
758}
759
760fn check_properties(value: &Value) -> Result<(), String> {
761 let Some(properties) = value.as_object() else {
762 return Err("`properties` must be an object".to_owned());
763 };
764 for subschema in properties.values() {
765 validate_schema(subschema)?;
766 }
767 Ok(())
768}
769
770fn check_required(value: &Value) -> Result<(), String> {
771 let Some(names) = value.as_array() else {
772 return Err("`required` must be an array".to_owned());
773 };
774 if !names.iter().all(Value::is_string) {
775 return Err("`required` must name strings".to_owned());
776 }
777 Ok(())
778}
779
780fn check_additional_properties(value: &Value) -> Result<(), String> {
781 match value {
782 Value::Bool(_) => Ok(()),
783 Value::Object(_) => validate_schema(value),
784 _ => Err("`additionalProperties` must be a boolean or a schema".to_owned()),
785 }
786}
787
788fn check_schema_list(keyword: &str, value: &Value) -> Result<(), String> {
789 let Some(schemas) = value.as_array() else {
790 return Err(format!("`{keyword}` must be an array of schemas"));
791 };
792 if schemas.is_empty() {
793 return Err(format!("`{keyword}` must not be empty"));
794 }
795 for subschema in schemas {
796 validate_schema(subschema)?;
797 }
798 Ok(())
799}
800
801pub(crate) fn validate_instance(schema: &Value, instance: &Value) -> Result<(), String> {
806 let Some(object) = schema.as_object() else {
807 return Err("the schema is not an object".to_owned());
808 };
809
810 if let Some(subschemas) = object.get("allOf").and_then(Value::as_array) {
813 for subschema in subschemas {
814 validate_instance(subschema, instance)?;
815 }
816 }
817 if let Some(subschemas) = object.get("anyOf").and_then(Value::as_array)
818 && !subschemas
819 .iter()
820 .any(|subschema| validate_instance(subschema, instance).is_ok())
821 {
822 return Err("the value matches none of the `anyOf` schemas".to_owned());
823 }
824 if let Some(subschemas) = object.get("oneOf").and_then(Value::as_array) {
825 let matches = subschemas
826 .iter()
827 .filter(|subschema| validate_instance(subschema, instance).is_ok())
828 .count();
829 if matches != 1 {
830 return Err(format!(
831 "the value matches {matches} of the `oneOf` schemas, not exactly one"
832 ));
833 }
834 }
835
836 if let Some(constant) = object.get("const")
837 && constant != instance
838 {
839 return Err("the value is not the required `const`".to_owned());
840 }
841 if let Some(allowed) = object.get("enum").and_then(Value::as_array)
842 && !allowed.contains(instance)
843 {
844 return Err("the value is none of the `enum` values".to_owned());
845 }
846 if let Some(type_keyword) = object.get("type")
847 && !matches_type(type_keyword, instance)
848 {
849 return Err("the value is not of the declared `type`".to_owned());
850 }
851
852 check_number_bounds(object, instance)?;
853 check_string_bounds(object, instance)?;
854 check_array_bounds(object, instance)?;
855 check_object_shape(object, instance)
856}
857
858fn check_number_bounds(object: &Map<String, Value>, instance: &Value) -> Result<(), String> {
859 let Some(number) = instance.as_f64() else {
860 return Ok(());
861 };
862 if let Some(minimum) = object.get("minimum").and_then(Value::as_f64)
863 && number < minimum
864 {
865 return Err(format!("the number is below the `minimum` {minimum}"));
866 }
867 if let Some(minimum) = object.get("exclusiveMinimum").and_then(Value::as_f64)
868 && number <= minimum
869 {
870 return Err(format!(
871 "the number is not above the `exclusiveMinimum` {minimum}"
872 ));
873 }
874 if let Some(maximum) = object.get("maximum").and_then(Value::as_f64)
875 && number > maximum
876 {
877 return Err(format!("the number is above the `maximum` {maximum}"));
878 }
879 if let Some(maximum) = object.get("exclusiveMaximum").and_then(Value::as_f64)
880 && number >= maximum
881 {
882 return Err(format!(
883 "the number is not below the `exclusiveMaximum` {maximum}"
884 ));
885 }
886 Ok(())
887}
888
889fn check_string_bounds(object: &Map<String, Value>, instance: &Value) -> Result<(), String> {
890 let Some(text) = instance.as_str() else {
891 return Ok(());
892 };
893 let length = u64::try_from(text.chars().count()).unwrap_or(u64::MAX);
895 if let Some(minimum) = object.get("minLength").and_then(Value::as_u64)
896 && length < minimum
897 {
898 return Err(format!("the string is shorter than `minLength` {minimum}"));
899 }
900 if let Some(maximum) = object.get("maxLength").and_then(Value::as_u64)
901 && length > maximum
902 {
903 return Err(format!("the string is longer than `maxLength` {maximum}"));
904 }
905 Ok(())
906}
907
908fn check_array_bounds(object: &Map<String, Value>, instance: &Value) -> Result<(), String> {
909 let Some(items) = instance.as_array() else {
910 return Ok(());
911 };
912 let length = u64::try_from(items.len()).unwrap_or(u64::MAX);
913 if let Some(minimum) = object.get("minItems").and_then(Value::as_u64)
914 && length < minimum
915 {
916 return Err(format!("the array has fewer than `minItems` {minimum}"));
917 }
918 if let Some(maximum) = object.get("maxItems").and_then(Value::as_u64)
919 && length > maximum
920 {
921 return Err(format!("the array has more than `maxItems` {maximum}"));
922 }
923 if let Some(item_schema) = object.get("items") {
924 for item in items {
925 validate_instance(item_schema, item)
926 .map_err(|reason| format!("an array item is invalid: {reason}"))?;
927 }
928 }
929 Ok(())
930}
931
932fn check_object_shape(object: &Map<String, Value>, instance: &Value) -> Result<(), String> {
933 let Some(fields) = instance.as_object() else {
934 return Ok(());
935 };
936 if let Some(required) = object.get("required").and_then(Value::as_array) {
937 for name in required.iter().filter_map(Value::as_str) {
938 if !fields.contains_key(name) {
939 return Err(format!("the required property {name:?} is missing"));
940 }
941 }
942 }
943 let properties = object.get("properties").and_then(Value::as_object);
944 let additional = object.get("additionalProperties");
945 for (key, field) in fields {
946 if let Some(property_schema) = properties.and_then(|properties| properties.get(key)) {
947 validate_instance(property_schema, field)
948 .map_err(|reason| format!("the property {key:?} is invalid: {reason}"))?;
949 continue;
950 }
951 match additional {
952 Some(Value::Bool(false)) => {
953 return Err(format!(
954 "the property {key:?} is not allowed by `additionalProperties`"
955 ));
956 }
957 Some(schema @ Value::Object(_)) => {
958 validate_instance(schema, field).map_err(|reason| {
959 format!("the additional property {key:?} is invalid: {reason}")
960 })?;
961 }
962 _ => {}
963 }
964 }
965 Ok(())
966}
967
968fn matches_type(type_keyword: &Value, instance: &Value) -> bool {
971 match type_keyword {
972 Value::String(name) => matches_one_type(name, instance),
973 Value::Array(names) => names.iter().any(|name| {
974 name.as_str()
975 .is_some_and(|name| matches_one_type(name, instance))
976 }),
977 _ => false,
978 }
979}
980
981fn matches_one_type(name: &str, instance: &Value) -> bool {
982 match name {
983 "object" => instance.is_object(),
984 "array" => instance.is_array(),
985 "string" => instance.is_string(),
986 "number" => instance.is_number(),
987 "integer" => instance
988 .as_f64()
989 .is_some_and(|number| number.fract() == 0.0),
990 "boolean" => instance.is_boolean(),
991 "null" => instance.is_null(),
992 _ => false,
993 }
994}
995
996#[cfg(test)]
997mod tests {
998 use super::*;
999 use serde_json::json;
1000
1001 #[test]
1002 fn a_known_schema_is_accepted_and_an_unknown_keyword_is_named() {
1003 let good = json!({
1004 "type": "object",
1005 "description": "a note",
1006 "properties": { "q": { "type": "string", "minLength": 1 } },
1007 "required": ["q"],
1008 "additionalProperties": false,
1009 });
1010 validate_tool_schema("lookup", &good).expect("known keywords are accepted");
1011
1012 let bad = json!({ "type": "object", "pattern": "^a" });
1013 let error = validate_tool_schema("lookup", &bad).unwrap_err();
1014 match error {
1015 ToolLoopError::InvalidToolSchema { tool, reason } => {
1016 assert_eq!(tool, "lookup");
1017 assert!(reason.contains("pattern"), "{reason}");
1018 }
1019 other => panic!("expected an invalid-schema error, got {other:?}"),
1020 }
1021 }
1022
1023 #[test]
1024 fn a_nested_unknown_keyword_is_reached() {
1025 let schema = json!({
1028 "type": "object",
1029 "properties": { "q": { "$ref": "#/defs/thing" } },
1030 });
1031 let error = validate_tool_schema("lookup", &schema).unwrap_err();
1032 assert!(error.to_string().contains("$ref"), "{error}");
1033 }
1034
1035 #[test]
1036 fn annotations_are_ignored_but_a_malformed_known_keyword_is_refused() {
1037 let annotated = json!({ "type": "string", "format": "email", "title": "Q" });
1038 validate_tool_schema("t", &annotated).expect("annotations are ignored");
1039
1040 let malformed = json!({ "type": "list-of-things" });
1041 assert!(validate_tool_schema("t", &malformed).is_err());
1042 }
1043
1044 #[test]
1045 fn instance_validation_covers_types_bounds_and_objects() {
1046 let schema = json!({
1047 "type": "object",
1048 "additionalProperties": false,
1049 "required": ["name", "age"],
1050 "properties": {
1051 "name": { "type": "string", "minLength": 1, "maxLength": 5 },
1052 "age": { "type": "integer", "minimum": 0, "maximum": 120 },
1053 "tags": { "type": "array", "items": { "enum": ["a", "b"] } },
1054 },
1055 });
1056 let good = json!({ "name": "al", "age": 30, "tags": ["a"] });
1057 assert_eq!(validate_instance(&schema, &good), Ok(()));
1058
1059 let short = json!({ "name": "", "age": 30 });
1060 assert!(validate_instance(&schema, &short).is_err());
1061 let wrong_type = json!({ "name": "al", "age": "old" });
1062 assert!(validate_instance(&schema, &wrong_type).is_err());
1063 let below = json!({ "name": "al", "age": -1 });
1064 assert!(validate_instance(&schema, &below).is_err());
1065 let extra = json!({ "name": "al", "age": 30, "extra": true });
1066 assert!(validate_instance(&schema, &extra).is_err());
1067 let bad_item = json!({ "name": "al", "age": 30, "tags": ["c"] });
1068 assert!(validate_instance(&schema, &bad_item).is_err());
1069 let missing = json!({ "name": "al" });
1070 assert!(validate_instance(&schema, &missing).is_err());
1071 }
1072
1073 #[test]
1074 fn combinators_are_enforced() {
1075 let any_of = json!({ "anyOf": [{ "type": "string" }, { "type": "integer" }] });
1076 assert_eq!(validate_instance(&any_of, &json!("x")), Ok(()));
1077 assert!(validate_instance(&any_of, &json!(true)).is_err());
1078
1079 let one_of = json!({ "oneOf": [{ "type": "number" }, { "type": "integer" }] });
1080 assert!(validate_instance(&one_of, &json!(3)).is_err());
1082 assert_eq!(validate_instance(&one_of, &json!(3.5)), Ok(()));
1083
1084 let all_of = json!({
1085 "allOf": [
1086 { "type": "integer" },
1087 { "minimum": 10 },
1088 ],
1089 });
1090 assert!(validate_instance(&all_of, &json!(3)).is_err());
1091 assert_eq!(validate_instance(&all_of, &json!(12)), Ok(()));
1092 }
1093
1094 #[test]
1095 fn const_and_enum_are_enforced() {
1096 let schema = json!({ "const": "fixed" });
1097 assert_eq!(validate_instance(&schema, &json!("fixed")), Ok(()));
1098 assert!(validate_instance(&schema, &json!("other")).is_err());
1099
1100 let schema = json!({ "enum": [1, 2] });
1101 assert_eq!(validate_instance(&schema, &json!(2)), Ok(()));
1102 assert!(validate_instance(&schema, &json!(3)).is_err());
1103 }
1104}