1use crate::serde_json::{Map, Value};
56
57pub const TOOL_NAME: &str = "reddb.ask";
59
60pub const SCHEMA_DRAFT: &str = "http://json-schema.org/draft-07/schema#";
63
64pub const LIMIT_MIN: u32 = 1;
67pub const LIMIT_MAX: u32 = 200;
68pub const LIMIT_DEFAULT: u32 = 20;
69pub const DEPTH_MIN: u32 = 0;
70pub const DEPTH_MAX: u32 = 10;
71pub const DEPTH_DEFAULT: u32 = 2;
72pub const MIN_SCORE_MIN: f64 = 0.0;
73pub const MIN_SCORE_MAX: f64 = 1.0;
74pub const TEMPERATURE_MIN: f64 = 0.0;
75pub const TEMPERATURE_MAX: f64 = 2.0;
76
77#[derive(Debug, Clone, PartialEq)]
82pub struct AskInvocation {
83 pub question: String,
84 pub strict: Option<bool>,
85 pub using: Option<String>,
86 pub model: Option<String>,
87 pub limit: Option<u32>,
88 pub min_score: Option<f64>,
89 pub depth: Option<u32>,
90 pub temperature: Option<f64>,
91 pub seed: Option<u64>,
92 pub cache_ttl: Option<String>,
93 pub nocache: Option<bool>,
94}
95
96#[derive(Debug, Clone, PartialEq)]
100pub enum ParseError {
101 NotAnObject,
103 MissingQuestion,
105 QuestionWrongType,
107 WrongType {
109 path: String,
110 expected: &'static str,
111 },
112 OutOfRange { path: String, detail: String },
114 CacheAndNocache,
116 UnknownOption { path: String },
119}
120
121pub fn descriptor() -> Value {
132 let mut top = Map::new();
133 top.insert("name".into(), Value::String(TOOL_NAME.into()));
134 top.insert("description".into(), Value::String(description_text()));
135 top.insert("inputSchema".into(), input_schema());
136 Value::Object(top)
137}
138
139fn description_text() -> String {
140 "Grounded question-answering against the RedDB engine. \
143 Runs `ASK '<question>'` with hybrid retrieval (BM25 + vector + graph), \
144 returns an answer with inline `[^N]` citations and a `sources_flat` list \
145 of URNs backing each citation. Validation is strict by default — answers \
146 that cite out-of-range sources are retried once, then rejected. Honour \
147 the citations: every factual claim in `answer` is grounded in \
148 `sources_flat[N-1]`."
149 .to_string()
150}
151
152fn input_schema() -> Value {
153 let mut schema = Map::new();
154 schema.insert("$schema".into(), Value::String(SCHEMA_DRAFT.into()));
155 schema.insert("type".into(), Value::String("object".into()));
156 schema.insert("additionalProperties".into(), Value::Bool(false));
157 schema.insert(
158 "required".into(),
159 Value::Array(vec![Value::String("question".into())]),
160 );
161
162 let mut props = Map::new();
163 props.insert(
164 "question".into(),
165 prop_with(&[
166 ("type", Value::String("string".into())),
167 ("minLength", Value::Number(1.0)),
168 (
169 "description",
170 Value::String("The natural-language question to ground.".into()),
171 ),
172 ]),
173 );
174 props.insert("options".into(), options_schema());
175 schema.insert("properties".into(), Value::Object(props));
176 Value::Object(schema)
177}
178
179fn options_schema() -> Value {
180 let mut s = Map::new();
181 s.insert("type".into(), Value::String("object".into()));
182 s.insert("additionalProperties".into(), Value::Bool(false));
183 s.insert(
184 "description".into(),
185 Value::String(
186 "Per-call overrides mirroring `ASK '...'` SQL clauses. All fields optional.".into(),
187 ),
188 );
189
190 let mut p = Map::new();
191 p.insert(
192 "strict".into(),
193 prop_with(&[
194 ("type", Value::String("boolean".into())),
195 (
196 "description",
197 Value::String(
198 "If false, retry-on-citation-mismatch is disabled and warnings are surfaced instead of errors.".into(),
199 ),
200 ),
201 ]),
202 );
203 p.insert(
204 "using".into(),
205 string_prop("Provider token override (e.g. \"openai\", \"anthropic\")."),
206 );
207 p.insert("model".into(), string_prop("Specific model id to invoke."));
208 p.insert(
209 "limit".into(),
210 ranged_int(
211 LIMIT_MIN,
212 LIMIT_MAX,
213 LIMIT_DEFAULT,
214 "Total source budget after RRF fusion.",
215 ),
216 );
217 p.insert(
218 "min_score".into(),
219 ranged_num(
220 MIN_SCORE_MIN,
221 MIN_SCORE_MAX,
222 "Per-bucket score floor applied before RRF.",
223 ),
224 );
225 p.insert(
226 "depth".into(),
227 ranged_int(
228 DEPTH_MIN,
229 DEPTH_MAX,
230 DEPTH_DEFAULT,
231 "Graph traversal depth for the graph bucket.",
232 ),
233 );
234 p.insert(
235 "temperature".into(),
236 ranged_num(
237 TEMPERATURE_MIN,
238 TEMPERATURE_MAX,
239 "Sampling temperature. Default 0 for determinism.",
240 ),
241 );
242 p.insert(
243 "seed".into(),
244 prop_with(&[
245 ("type", Value::String("integer".into())),
246 ("minimum", Value::Number(0.0)),
247 (
248 "description",
249 Value::String(
250 "Per-call seed override. Default is derived from question + sources fingerprint.".into(),
251 ),
252 ),
253 ]),
254 );
255
256 let mut cache_obj = Map::new();
257 cache_obj.insert("type".into(), Value::String("object".into()));
258 cache_obj.insert("additionalProperties".into(), Value::Bool(false));
259 cache_obj.insert(
260 "required".into(),
261 Value::Array(vec![Value::String("ttl".into())]),
262 );
263 let mut cache_props = Map::new();
264 cache_props.insert(
265 "ttl".into(),
266 prop_with(&[
267 ("type", Value::String("string".into())),
268 ("minLength", Value::Number(1.0)),
269 (
270 "description",
271 Value::String("TTL string accepted by the parser, e.g. \"5m\", \"1h\".".into()),
272 ),
273 ]),
274 );
275 cache_obj.insert("properties".into(), Value::Object(cache_props));
276 p.insert("cache".into(), Value::Object(cache_obj));
277
278 p.insert(
279 "nocache".into(),
280 prop_with(&[
281 ("type", Value::String("boolean".into())),
282 (
283 "description",
284 Value::String(
285 "If true, bypasses the answer cache for this call. Mutually exclusive with `cache`.".into(),
286 ),
287 ),
288 ]),
289 );
290
291 s.insert("properties".into(), Value::Object(p));
292 Value::Object(s)
293}
294
295fn prop_with(entries: &[(&str, Value)]) -> Value {
296 let mut m = Map::new();
297 for (k, v) in entries {
298 m.insert((*k).to_string(), v.clone());
299 }
300 Value::Object(m)
301}
302
303fn string_prop(desc: &str) -> Value {
304 prop_with(&[
305 ("type", Value::String("string".into())),
306 ("minLength", Value::Number(1.0)),
307 ("description", Value::String(desc.into())),
308 ])
309}
310
311fn ranged_int(min: u32, max: u32, default: u32, desc: &str) -> Value {
312 prop_with(&[
313 ("type", Value::String("integer".into())),
314 ("minimum", Value::Number(min as f64)),
315 ("maximum", Value::Number(max as f64)),
316 ("default", Value::Number(default as f64)),
317 ("description", Value::String(desc.into())),
318 ])
319}
320
321fn ranged_num(min: f64, max: f64, desc: &str) -> Value {
322 prop_with(&[
323 ("type", Value::String("number".into())),
324 ("minimum", Value::Number(min)),
325 ("maximum", Value::Number(max)),
326 ("description", Value::String(desc.into())),
327 ])
328}
329
330pub fn parse(args: &Value) -> Result<AskInvocation, ParseError> {
335 let obj = match args {
336 Value::Object(m) => m,
337 _ => return Err(ParseError::NotAnObject),
338 };
339
340 let question = match obj.get("question") {
341 Some(Value::String(s)) => {
342 let trimmed = s.trim();
343 if trimmed.is_empty() {
344 return Err(ParseError::MissingQuestion);
345 }
346 s.clone()
350 }
351 Some(_) => return Err(ParseError::QuestionWrongType),
352 None => return Err(ParseError::MissingQuestion),
353 };
354
355 let mut inv = AskInvocation {
356 question,
357 strict: None,
358 using: None,
359 model: None,
360 limit: None,
361 min_score: None,
362 depth: None,
363 temperature: None,
364 seed: None,
365 cache_ttl: None,
366 nocache: None,
367 };
368
369 for (key, _) in obj.iter() {
370 if key != "question" && key != "options" {
371 return Err(ParseError::UnknownOption { path: key.clone() });
372 }
373 }
374
375 if let Some(opts_v) = obj.get("options") {
376 let opts = match opts_v {
377 Value::Object(m) => m,
378 _ => {
379 return Err(ParseError::WrongType {
380 path: "options".into(),
381 expected: "object",
382 });
383 }
384 };
385 parse_options(opts, &mut inv)?;
386 }
387
388 if inv.cache_ttl.is_some() && matches!(inv.nocache, Some(true)) {
389 return Err(ParseError::CacheAndNocache);
390 }
391
392 Ok(inv)
393}
394
395fn parse_options(opts: &Map<String, Value>, inv: &mut AskInvocation) -> Result<(), ParseError> {
396 for (key, val) in opts.iter() {
397 match key.as_str() {
398 "strict" => inv.strict = Some(expect_bool(val, "options.strict")?),
399 "using" => inv.using = Some(expect_nonempty_string(val, "options.using")?),
400 "model" => inv.model = Some(expect_nonempty_string(val, "options.model")?),
401 "limit" => {
402 let n = expect_u32(val, "options.limit")?;
403 if !(LIMIT_MIN..=LIMIT_MAX).contains(&n) {
404 return Err(ParseError::OutOfRange {
405 path: "options.limit".into(),
406 detail: format!("must be in {}..={}", LIMIT_MIN, LIMIT_MAX),
407 });
408 }
409 inv.limit = Some(n);
410 }
411 "min_score" => {
412 let f = expect_f64(val, "options.min_score")?;
413 if !(MIN_SCORE_MIN..=MIN_SCORE_MAX).contains(&f) {
414 return Err(ParseError::OutOfRange {
415 path: "options.min_score".into(),
416 detail: format!("must be in {}..={}", MIN_SCORE_MIN, MIN_SCORE_MAX),
417 });
418 }
419 inv.min_score = Some(f);
420 }
421 "depth" => {
422 let n = expect_u32(val, "options.depth")?;
423 if !(DEPTH_MIN..=DEPTH_MAX).contains(&n) {
424 return Err(ParseError::OutOfRange {
425 path: "options.depth".into(),
426 detail: format!("must be in {}..={}", DEPTH_MIN, DEPTH_MAX),
427 });
428 }
429 inv.depth = Some(n);
430 }
431 "temperature" => {
432 let f = expect_f64(val, "options.temperature")?;
433 if !(TEMPERATURE_MIN..=TEMPERATURE_MAX).contains(&f) {
434 return Err(ParseError::OutOfRange {
435 path: "options.temperature".into(),
436 detail: format!("must be in {}..={}", TEMPERATURE_MIN, TEMPERATURE_MAX),
437 });
438 }
439 inv.temperature = Some(f);
440 }
441 "seed" => inv.seed = Some(expect_u64(val, "options.seed")?),
442 "cache" => {
443 let m = match val {
444 Value::Object(m) => m,
445 _ => {
446 return Err(ParseError::WrongType {
447 path: "options.cache".into(),
448 expected: "object",
449 });
450 }
451 };
452 let ttl = match m.get("ttl") {
453 Some(v) => expect_nonempty_string(v, "options.cache.ttl")?,
454 None => {
455 return Err(ParseError::WrongType {
456 path: "options.cache.ttl".into(),
457 expected: "string",
458 });
459 }
460 };
461 for (k, _) in m.iter() {
462 if k != "ttl" {
463 return Err(ParseError::UnknownOption {
464 path: format!("options.cache.{}", k),
465 });
466 }
467 }
468 inv.cache_ttl = Some(ttl);
469 }
470 "nocache" => inv.nocache = Some(expect_bool(val, "options.nocache")?),
471 other => {
472 return Err(ParseError::UnknownOption {
473 path: format!("options.{}", other),
474 });
475 }
476 }
477 }
478 Ok(())
479}
480
481fn expect_bool(v: &Value, path: &str) -> Result<bool, ParseError> {
482 match v {
483 Value::Bool(b) => Ok(*b),
484 _ => Err(ParseError::WrongType {
485 path: path.into(),
486 expected: "boolean",
487 }),
488 }
489}
490
491fn expect_nonempty_string(v: &Value, path: &str) -> Result<String, ParseError> {
492 match v {
493 Value::String(s) if !s.is_empty() => Ok(s.clone()),
494 Value::String(_) => Err(ParseError::OutOfRange {
495 path: path.into(),
496 detail: "must be a non-empty string".into(),
497 }),
498 _ => Err(ParseError::WrongType {
499 path: path.into(),
500 expected: "string",
501 }),
502 }
503}
504
505fn expect_u32(v: &Value, path: &str) -> Result<u32, ParseError> {
506 let n = expect_integer(v, path)?;
507 if n < 0 || n > u32::MAX as i128 {
508 return Err(ParseError::OutOfRange {
509 path: path.into(),
510 detail: format!("must fit in u32 (0..={})", u32::MAX),
511 });
512 }
513 Ok(n as u32)
514}
515
516fn expect_u64(v: &Value, path: &str) -> Result<u64, ParseError> {
517 let n = expect_integer(v, path)?;
518 if n < 0 || n > u64::MAX as i128 {
519 return Err(ParseError::OutOfRange {
520 path: path.into(),
521 detail: format!("must fit in u64 (0..={})", u64::MAX),
522 });
523 }
524 Ok(n as u64)
525}
526
527fn expect_integer(v: &Value, path: &str) -> Result<i128, ParseError> {
528 match v {
529 Value::Integer(n) => Ok(*n as i128),
530 Value::Number(n) => {
531 if !n.is_finite() || n.fract() != 0.0 {
532 return Err(ParseError::WrongType {
533 path: path.into(),
534 expected: "integer",
535 });
536 }
537 Ok(*n as i128)
538 }
539 _ => Err(ParseError::WrongType {
540 path: path.into(),
541 expected: "integer",
542 }),
543 }
544}
545
546fn expect_f64(v: &Value, path: &str) -> Result<f64, ParseError> {
547 match v {
548 Value::Integer(n) => Ok(*n as f64),
549 Value::Number(n) if n.is_finite() => Ok(*n),
550 Value::Number(_) => Err(ParseError::WrongType {
551 path: path.into(),
552 expected: "finite number",
553 }),
554 _ => Err(ParseError::WrongType {
555 path: path.into(),
556 expected: "number",
557 }),
558 }
559}
560
561#[cfg(test)]
562mod tests {
563 use super::*;
564
565 fn json(s: &str) -> Value {
566 crate::utils::json::parse_json(s)
567 .expect("valid test json")
568 .into()
569 }
570
571 #[test]
574 fn descriptor_top_level_keys_pinned() {
575 let d = descriptor();
576 let obj = d.as_object().unwrap();
577 let mut keys: Vec<&str> = obj.keys().map(|s| s.as_str()).collect();
578 keys.sort();
579 assert_eq!(keys, vec!["description", "inputSchema", "name"]);
580 }
581
582 #[test]
583 fn descriptor_name_is_reddb_ask() {
584 assert_eq!(
585 descriptor().get("name").and_then(|v| v.as_str()),
586 Some("reddb.ask")
587 );
588 assert_eq!(TOOL_NAME, "reddb.ask");
589 }
590
591 #[test]
592 fn description_emphasises_grounding() {
593 let desc = descriptor()
594 .get("description")
595 .and_then(|v| v.as_str())
596 .unwrap()
597 .to_string();
598 assert!(desc.contains("citation"), "description: {desc}");
599 assert!(desc.contains("sources_flat"), "description: {desc}");
600 assert!(desc.contains("URN"), "description: {desc}");
601 }
602
603 #[test]
604 fn input_schema_requires_question_only() {
605 let schema = descriptor().get("inputSchema").cloned().unwrap();
606 let req = schema.get("required").cloned().unwrap();
607 let arr = req.as_array().unwrap();
608 assert_eq!(arr.len(), 1);
609 assert_eq!(arr[0].as_str(), Some("question"));
610 }
611
612 #[test]
613 fn input_schema_rejects_additional_properties() {
614 let schema = descriptor().get("inputSchema").cloned().unwrap();
615 assert_eq!(
616 schema.get("additionalProperties").and_then(|v| v.as_bool()),
617 Some(false)
618 );
619 let opts = schema
620 .get("properties")
621 .and_then(|p| p.get("options"))
622 .cloned()
623 .unwrap();
624 assert_eq!(
625 opts.get("additionalProperties").and_then(|v| v.as_bool()),
626 Some(false)
627 );
628 }
629
630 #[test]
631 fn input_schema_options_keys_match_parser() {
632 let schema = descriptor().get("inputSchema").cloned().unwrap();
633 let opts = schema
634 .get("properties")
635 .and_then(|p| p.get("options"))
636 .and_then(|o| o.get("properties"))
637 .cloned()
638 .unwrap();
639 let mut keys: Vec<&str> = opts
640 .as_object()
641 .unwrap()
642 .keys()
643 .map(|s| s.as_str())
644 .collect();
645 keys.sort();
646 assert_eq!(
647 keys,
648 vec![
649 "cache",
650 "depth",
651 "limit",
652 "min_score",
653 "model",
654 "nocache",
655 "seed",
656 "strict",
657 "temperature",
658 "using",
659 ]
660 );
661 }
662
663 #[test]
664 fn input_schema_ranges_pinned() {
665 let schema = descriptor().get("inputSchema").cloned().unwrap();
666 let opts = schema
667 .get("properties")
668 .and_then(|p| p.get("options"))
669 .and_then(|o| o.get("properties"))
670 .cloned()
671 .unwrap();
672 let limit = opts.get("limit").cloned().unwrap();
673 assert_eq!(limit.get("minimum").and_then(|v| v.as_f64()), Some(1.0));
674 assert_eq!(limit.get("maximum").and_then(|v| v.as_f64()), Some(200.0));
675 assert_eq!(limit.get("default").and_then(|v| v.as_f64()), Some(20.0));
676 let depth = opts.get("depth").cloned().unwrap();
677 assert_eq!(depth.get("minimum").and_then(|v| v.as_f64()), Some(0.0));
678 assert_eq!(depth.get("maximum").and_then(|v| v.as_f64()), Some(10.0));
679 let temp = opts.get("temperature").cloned().unwrap();
680 assert_eq!(temp.get("maximum").and_then(|v| v.as_f64()), Some(2.0));
681 }
682
683 #[test]
684 fn descriptor_is_deterministic() {
685 let a = descriptor().to_string_compact();
686 let b = descriptor().to_string_compact();
687 assert_eq!(a, b);
688 }
689
690 #[test]
693 fn parse_minimal_question_only() {
694 let inv = parse(&json(r#"{"question":"hi"}"#)).unwrap();
695 assert_eq!(inv.question, "hi");
696 assert!(inv.strict.is_none() && inv.limit.is_none() && inv.cache_ttl.is_none());
697 }
698
699 #[test]
700 fn parse_full_options() {
701 let v = json(
702 r#"{
703 "question": "What is the cap of X?",
704 "options": {
705 "strict": false,
706 "using": "openai",
707 "model": "gpt-4o-mini",
708 "limit": 50,
709 "min_score": 0.7,
710 "depth": 2,
711 "temperature": 0,
712 "seed": 42,
713 "cache": {"ttl": "5m"}
714 }
715 }"#,
716 );
717 let inv = parse(&v).unwrap();
718 assert_eq!(inv.strict, Some(false));
719 assert_eq!(inv.using.as_deref(), Some("openai"));
720 assert_eq!(inv.model.as_deref(), Some("gpt-4o-mini"));
721 assert_eq!(inv.limit, Some(50));
722 assert_eq!(inv.min_score, Some(0.7));
723 assert_eq!(inv.depth, Some(2));
724 assert_eq!(inv.temperature, Some(0.0));
725 assert_eq!(inv.seed, Some(42));
726 assert_eq!(inv.cache_ttl.as_deref(), Some("5m"));
727 assert!(inv.nocache.is_none());
728 }
729
730 #[test]
731 fn parse_nocache_alone() {
732 let inv = parse(&json(r#"{"question":"q","options":{"nocache":true}}"#)).unwrap();
733 assert_eq!(inv.nocache, Some(true));
734 assert!(inv.cache_ttl.is_none());
735 }
736
737 #[test]
738 fn parse_preserves_untrimmed_question() {
739 let inv = parse(&json(r#"{"question":" hi "}"#)).unwrap();
741 assert_eq!(inv.question, " hi ");
742 }
743
744 #[test]
745 fn parse_seed_zero_preserved() {
746 let inv = parse(&json(r#"{"question":"q","options":{"seed":0}}"#)).unwrap();
749 assert_eq!(inv.seed, Some(0));
750 }
751
752 #[test]
753 fn parse_temperature_zero_preserved() {
754 let inv = parse(&json(r#"{"question":"q","options":{"temperature":0}}"#)).unwrap();
755 assert_eq!(inv.temperature, Some(0.0));
756 }
757
758 #[test]
761 fn parse_rejects_non_object_args() {
762 let err = parse(&json("[]")).unwrap_err();
763 assert_eq!(err, ParseError::NotAnObject);
764 }
765
766 #[test]
767 fn parse_rejects_missing_question() {
768 assert_eq!(parse(&json("{}")).unwrap_err(), ParseError::MissingQuestion);
769 }
770
771 #[test]
772 fn parse_rejects_empty_question() {
773 assert_eq!(
774 parse(&json(r#"{"question":" "}"#)).unwrap_err(),
775 ParseError::MissingQuestion
776 );
777 }
778
779 #[test]
780 fn parse_rejects_non_string_question() {
781 assert_eq!(
782 parse(&json(r#"{"question":42}"#)).unwrap_err(),
783 ParseError::QuestionWrongType
784 );
785 }
786
787 #[test]
788 fn parse_rejects_unknown_top_level_key() {
789 let err = parse(&json(r#"{"question":"q","extra":1}"#)).unwrap_err();
790 assert_eq!(
791 err,
792 ParseError::UnknownOption {
793 path: "extra".into()
794 }
795 );
796 }
797
798 #[test]
799 fn parse_rejects_unknown_option_key() {
800 let err = parse(&json(r#"{"question":"q","options":{"tempurature":0}}"#)).unwrap_err();
801 assert_eq!(
802 err,
803 ParseError::UnknownOption {
804 path: "options.tempurature".into()
805 }
806 );
807 }
808
809 #[test]
810 fn parse_rejects_options_not_object() {
811 let err = parse(&json(r#"{"question":"q","options":"strict"}"#)).unwrap_err();
812 match err {
813 ParseError::WrongType { path, expected } => {
814 assert_eq!(path, "options");
815 assert_eq!(expected, "object");
816 }
817 _ => panic!("wrong variant"),
818 }
819 }
820
821 #[test]
822 fn parse_rejects_limit_out_of_range_high() {
823 let err = parse(&json(r#"{"question":"q","options":{"limit":201}}"#)).unwrap_err();
824 match err {
825 ParseError::OutOfRange { path, .. } => assert_eq!(path, "options.limit"),
826 _ => panic!("wrong variant: {err:?}"),
827 }
828 }
829
830 #[test]
831 fn parse_rejects_limit_zero() {
832 let err = parse(&json(r#"{"question":"q","options":{"limit":0}}"#)).unwrap_err();
833 match err {
834 ParseError::OutOfRange { path, .. } => assert_eq!(path, "options.limit"),
835 _ => panic!("wrong variant: {err:?}"),
836 }
837 }
838
839 #[test]
840 fn parse_rejects_min_score_above_one() {
841 let err = parse(&json(r#"{"question":"q","options":{"min_score":1.5}}"#)).unwrap_err();
842 match err {
843 ParseError::OutOfRange { path, .. } => assert_eq!(path, "options.min_score"),
844 _ => panic!("wrong variant"),
845 }
846 }
847
848 #[test]
849 fn parse_rejects_temperature_negative() {
850 let err = parse(&json(r#"{"question":"q","options":{"temperature":-0.1}}"#)).unwrap_err();
851 match err {
852 ParseError::OutOfRange { path, .. } => assert_eq!(path, "options.temperature"),
853 _ => panic!("wrong variant"),
854 }
855 }
856
857 #[test]
858 fn parse_rejects_non_integer_seed() {
859 let err = parse(&json(r#"{"question":"q","options":{"seed":1.5}}"#)).unwrap_err();
860 match err {
861 ParseError::WrongType { path, expected } => {
862 assert_eq!(path, "options.seed");
863 assert_eq!(expected, "integer");
864 }
865 _ => panic!("wrong variant"),
866 }
867 }
868
869 #[test]
870 fn parse_rejects_negative_seed() {
871 let err = parse(&json(r#"{"question":"q","options":{"seed":-1}}"#)).unwrap_err();
872 match err {
873 ParseError::OutOfRange { path, .. } => assert_eq!(path, "options.seed"),
874 _ => panic!("wrong variant"),
875 }
876 }
877
878 #[test]
879 fn parse_rejects_cache_without_ttl() {
880 let err = parse(&json(r#"{"question":"q","options":{"cache":{}}}"#)).unwrap_err();
881 match err {
882 ParseError::WrongType { path, expected } => {
883 assert_eq!(path, "options.cache.ttl");
884 assert_eq!(expected, "string");
885 }
886 _ => panic!("wrong variant"),
887 }
888 }
889
890 #[test]
891 fn parse_rejects_cache_extra_key() {
892 let err = parse(&json(
893 r#"{"question":"q","options":{"cache":{"ttl":"5m","mode":"sliding"}}}"#,
894 ))
895 .unwrap_err();
896 match err {
897 ParseError::UnknownOption { path } => assert_eq!(path, "options.cache.mode"),
898 _ => panic!("wrong variant"),
899 }
900 }
901
902 #[test]
903 fn parse_rejects_cache_and_nocache_together() {
904 let err = parse(&json(
905 r#"{"question":"q","options":{"cache":{"ttl":"5m"},"nocache":true}}"#,
906 ))
907 .unwrap_err();
908 assert_eq!(err, ParseError::CacheAndNocache);
909 }
910
911 #[test]
912 fn parse_allows_nocache_false_with_cache() {
913 let inv = parse(&json(
916 r#"{"question":"q","options":{"cache":{"ttl":"5m"},"nocache":false}}"#,
917 ))
918 .unwrap();
919 assert_eq!(inv.cache_ttl.as_deref(), Some("5m"));
920 assert_eq!(inv.nocache, Some(false));
921 }
922
923 #[test]
924 fn parse_rejects_empty_using_string() {
925 let err = parse(&json(r#"{"question":"q","options":{"using":""}}"#)).unwrap_err();
926 match err {
927 ParseError::OutOfRange { path, .. } => assert_eq!(path, "options.using"),
928 _ => panic!("wrong variant"),
929 }
930 }
931
932 #[test]
933 fn parse_rejects_using_wrong_type() {
934 let err = parse(&json(r#"{"question":"q","options":{"using":1}}"#)).unwrap_err();
935 match err {
936 ParseError::WrongType { path, expected } => {
937 assert_eq!(path, "options.using");
938 assert_eq!(expected, "string");
939 }
940 _ => panic!("wrong variant"),
941 }
942 }
943
944 #[test]
945 fn parse_is_deterministic() {
946 let v = json(r#"{"question":"q","options":{"strict":true,"limit":10,"seed":7}}"#);
947 assert_eq!(parse(&v).unwrap(), parse(&v).unwrap());
948 }
949}