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::Number(n) => {
530 if !n.is_finite() || n.fract() != 0.0 {
531 return Err(ParseError::WrongType {
532 path: path.into(),
533 expected: "integer",
534 });
535 }
536 Ok(*n as i128)
537 }
538 _ => Err(ParseError::WrongType {
539 path: path.into(),
540 expected: "integer",
541 }),
542 }
543}
544
545fn expect_f64(v: &Value, path: &str) -> Result<f64, ParseError> {
546 match v {
547 Value::Number(n) if n.is_finite() => Ok(*n),
548 Value::Number(_) => Err(ParseError::WrongType {
549 path: path.into(),
550 expected: "finite number",
551 }),
552 _ => Err(ParseError::WrongType {
553 path: path.into(),
554 expected: "number",
555 }),
556 }
557}
558
559#[cfg(test)]
560mod tests {
561 use super::*;
562
563 fn json(s: &str) -> Value {
564 crate::utils::json::parse_json(s)
565 .expect("valid test json")
566 .into()
567 }
568
569 #[test]
572 fn descriptor_top_level_keys_pinned() {
573 let d = descriptor();
574 let obj = d.as_object().unwrap();
575 let mut keys: Vec<&str> = obj.keys().map(|s| s.as_str()).collect();
576 keys.sort();
577 assert_eq!(keys, vec!["description", "inputSchema", "name"]);
578 }
579
580 #[test]
581 fn descriptor_name_is_reddb_ask() {
582 assert_eq!(
583 descriptor().get("name").and_then(|v| v.as_str()),
584 Some("reddb.ask")
585 );
586 assert_eq!(TOOL_NAME, "reddb.ask");
587 }
588
589 #[test]
590 fn description_emphasises_grounding() {
591 let desc = descriptor()
592 .get("description")
593 .and_then(|v| v.as_str())
594 .unwrap()
595 .to_string();
596 assert!(desc.contains("citation"), "description: {desc}");
597 assert!(desc.contains("sources_flat"), "description: {desc}");
598 assert!(desc.contains("URN"), "description: {desc}");
599 }
600
601 #[test]
602 fn input_schema_requires_question_only() {
603 let schema = descriptor().get("inputSchema").cloned().unwrap();
604 let req = schema.get("required").cloned().unwrap();
605 let arr = req.as_array().unwrap();
606 assert_eq!(arr.len(), 1);
607 assert_eq!(arr[0].as_str(), Some("question"));
608 }
609
610 #[test]
611 fn input_schema_rejects_additional_properties() {
612 let schema = descriptor().get("inputSchema").cloned().unwrap();
613 assert_eq!(
614 schema.get("additionalProperties").and_then(|v| v.as_bool()),
615 Some(false)
616 );
617 let opts = schema
618 .get("properties")
619 .and_then(|p| p.get("options"))
620 .cloned()
621 .unwrap();
622 assert_eq!(
623 opts.get("additionalProperties").and_then(|v| v.as_bool()),
624 Some(false)
625 );
626 }
627
628 #[test]
629 fn input_schema_options_keys_match_parser() {
630 let schema = descriptor().get("inputSchema").cloned().unwrap();
631 let opts = schema
632 .get("properties")
633 .and_then(|p| p.get("options"))
634 .and_then(|o| o.get("properties"))
635 .cloned()
636 .unwrap();
637 let mut keys: Vec<&str> = opts
638 .as_object()
639 .unwrap()
640 .keys()
641 .map(|s| s.as_str())
642 .collect();
643 keys.sort();
644 assert_eq!(
645 keys,
646 vec![
647 "cache",
648 "depth",
649 "limit",
650 "min_score",
651 "model",
652 "nocache",
653 "seed",
654 "strict",
655 "temperature",
656 "using",
657 ]
658 );
659 }
660
661 #[test]
662 fn input_schema_ranges_pinned() {
663 let schema = descriptor().get("inputSchema").cloned().unwrap();
664 let opts = schema
665 .get("properties")
666 .and_then(|p| p.get("options"))
667 .and_then(|o| o.get("properties"))
668 .cloned()
669 .unwrap();
670 let limit = opts.get("limit").cloned().unwrap();
671 assert_eq!(limit.get("minimum").and_then(|v| v.as_f64()), Some(1.0));
672 assert_eq!(limit.get("maximum").and_then(|v| v.as_f64()), Some(200.0));
673 assert_eq!(limit.get("default").and_then(|v| v.as_f64()), Some(20.0));
674 let depth = opts.get("depth").cloned().unwrap();
675 assert_eq!(depth.get("minimum").and_then(|v| v.as_f64()), Some(0.0));
676 assert_eq!(depth.get("maximum").and_then(|v| v.as_f64()), Some(10.0));
677 let temp = opts.get("temperature").cloned().unwrap();
678 assert_eq!(temp.get("maximum").and_then(|v| v.as_f64()), Some(2.0));
679 }
680
681 #[test]
682 fn descriptor_is_deterministic() {
683 let a = descriptor().to_string_compact();
684 let b = descriptor().to_string_compact();
685 assert_eq!(a, b);
686 }
687
688 #[test]
691 fn parse_minimal_question_only() {
692 let inv = parse(&json(r#"{"question":"hi"}"#)).unwrap();
693 assert_eq!(inv.question, "hi");
694 assert!(inv.strict.is_none() && inv.limit.is_none() && inv.cache_ttl.is_none());
695 }
696
697 #[test]
698 fn parse_full_options() {
699 let v = json(
700 r#"{
701 "question": "What is the cap of X?",
702 "options": {
703 "strict": false,
704 "using": "openai",
705 "model": "gpt-4o-mini",
706 "limit": 50,
707 "min_score": 0.7,
708 "depth": 2,
709 "temperature": 0,
710 "seed": 42,
711 "cache": {"ttl": "5m"}
712 }
713 }"#,
714 );
715 let inv = parse(&v).unwrap();
716 assert_eq!(inv.strict, Some(false));
717 assert_eq!(inv.using.as_deref(), Some("openai"));
718 assert_eq!(inv.model.as_deref(), Some("gpt-4o-mini"));
719 assert_eq!(inv.limit, Some(50));
720 assert_eq!(inv.min_score, Some(0.7));
721 assert_eq!(inv.depth, Some(2));
722 assert_eq!(inv.temperature, Some(0.0));
723 assert_eq!(inv.seed, Some(42));
724 assert_eq!(inv.cache_ttl.as_deref(), Some("5m"));
725 assert!(inv.nocache.is_none());
726 }
727
728 #[test]
729 fn parse_nocache_alone() {
730 let inv = parse(&json(r#"{"question":"q","options":{"nocache":true}}"#)).unwrap();
731 assert_eq!(inv.nocache, Some(true));
732 assert!(inv.cache_ttl.is_none());
733 }
734
735 #[test]
736 fn parse_preserves_untrimmed_question() {
737 let inv = parse(&json(r#"{"question":" hi "}"#)).unwrap();
739 assert_eq!(inv.question, " hi ");
740 }
741
742 #[test]
743 fn parse_seed_zero_preserved() {
744 let inv = parse(&json(r#"{"question":"q","options":{"seed":0}}"#)).unwrap();
747 assert_eq!(inv.seed, Some(0));
748 }
749
750 #[test]
751 fn parse_temperature_zero_preserved() {
752 let inv = parse(&json(r#"{"question":"q","options":{"temperature":0}}"#)).unwrap();
753 assert_eq!(inv.temperature, Some(0.0));
754 }
755
756 #[test]
759 fn parse_rejects_non_object_args() {
760 let err = parse(&json("[]")).unwrap_err();
761 assert_eq!(err, ParseError::NotAnObject);
762 }
763
764 #[test]
765 fn parse_rejects_missing_question() {
766 assert_eq!(parse(&json("{}")).unwrap_err(), ParseError::MissingQuestion);
767 }
768
769 #[test]
770 fn parse_rejects_empty_question() {
771 assert_eq!(
772 parse(&json(r#"{"question":" "}"#)).unwrap_err(),
773 ParseError::MissingQuestion
774 );
775 }
776
777 #[test]
778 fn parse_rejects_non_string_question() {
779 assert_eq!(
780 parse(&json(r#"{"question":42}"#)).unwrap_err(),
781 ParseError::QuestionWrongType
782 );
783 }
784
785 #[test]
786 fn parse_rejects_unknown_top_level_key() {
787 let err = parse(&json(r#"{"question":"q","extra":1}"#)).unwrap_err();
788 assert_eq!(
789 err,
790 ParseError::UnknownOption {
791 path: "extra".into()
792 }
793 );
794 }
795
796 #[test]
797 fn parse_rejects_unknown_option_key() {
798 let err = parse(&json(r#"{"question":"q","options":{"tempurature":0}}"#)).unwrap_err();
799 assert_eq!(
800 err,
801 ParseError::UnknownOption {
802 path: "options.tempurature".into()
803 }
804 );
805 }
806
807 #[test]
808 fn parse_rejects_options_not_object() {
809 let err = parse(&json(r#"{"question":"q","options":"strict"}"#)).unwrap_err();
810 match err {
811 ParseError::WrongType { path, expected } => {
812 assert_eq!(path, "options");
813 assert_eq!(expected, "object");
814 }
815 _ => panic!("wrong variant"),
816 }
817 }
818
819 #[test]
820 fn parse_rejects_limit_out_of_range_high() {
821 let err = parse(&json(r#"{"question":"q","options":{"limit":201}}"#)).unwrap_err();
822 match err {
823 ParseError::OutOfRange { path, .. } => assert_eq!(path, "options.limit"),
824 _ => panic!("wrong variant: {err:?}"),
825 }
826 }
827
828 #[test]
829 fn parse_rejects_limit_zero() {
830 let err = parse(&json(r#"{"question":"q","options":{"limit":0}}"#)).unwrap_err();
831 match err {
832 ParseError::OutOfRange { path, .. } => assert_eq!(path, "options.limit"),
833 _ => panic!("wrong variant: {err:?}"),
834 }
835 }
836
837 #[test]
838 fn parse_rejects_min_score_above_one() {
839 let err = parse(&json(r#"{"question":"q","options":{"min_score":1.5}}"#)).unwrap_err();
840 match err {
841 ParseError::OutOfRange { path, .. } => assert_eq!(path, "options.min_score"),
842 _ => panic!("wrong variant"),
843 }
844 }
845
846 #[test]
847 fn parse_rejects_temperature_negative() {
848 let err = parse(&json(r#"{"question":"q","options":{"temperature":-0.1}}"#)).unwrap_err();
849 match err {
850 ParseError::OutOfRange { path, .. } => assert_eq!(path, "options.temperature"),
851 _ => panic!("wrong variant"),
852 }
853 }
854
855 #[test]
856 fn parse_rejects_non_integer_seed() {
857 let err = parse(&json(r#"{"question":"q","options":{"seed":1.5}}"#)).unwrap_err();
858 match err {
859 ParseError::WrongType { path, expected } => {
860 assert_eq!(path, "options.seed");
861 assert_eq!(expected, "integer");
862 }
863 _ => panic!("wrong variant"),
864 }
865 }
866
867 #[test]
868 fn parse_rejects_negative_seed() {
869 let err = parse(&json(r#"{"question":"q","options":{"seed":-1}}"#)).unwrap_err();
870 match err {
871 ParseError::OutOfRange { path, .. } => assert_eq!(path, "options.seed"),
872 _ => panic!("wrong variant"),
873 }
874 }
875
876 #[test]
877 fn parse_rejects_cache_without_ttl() {
878 let err = parse(&json(r#"{"question":"q","options":{"cache":{}}}"#)).unwrap_err();
879 match err {
880 ParseError::WrongType { path, expected } => {
881 assert_eq!(path, "options.cache.ttl");
882 assert_eq!(expected, "string");
883 }
884 _ => panic!("wrong variant"),
885 }
886 }
887
888 #[test]
889 fn parse_rejects_cache_extra_key() {
890 let err = parse(&json(
891 r#"{"question":"q","options":{"cache":{"ttl":"5m","mode":"sliding"}}}"#,
892 ))
893 .unwrap_err();
894 match err {
895 ParseError::UnknownOption { path } => assert_eq!(path, "options.cache.mode"),
896 _ => panic!("wrong variant"),
897 }
898 }
899
900 #[test]
901 fn parse_rejects_cache_and_nocache_together() {
902 let err = parse(&json(
903 r#"{"question":"q","options":{"cache":{"ttl":"5m"},"nocache":true}}"#,
904 ))
905 .unwrap_err();
906 assert_eq!(err, ParseError::CacheAndNocache);
907 }
908
909 #[test]
910 fn parse_allows_nocache_false_with_cache() {
911 let inv = parse(&json(
914 r#"{"question":"q","options":{"cache":{"ttl":"5m"},"nocache":false}}"#,
915 ))
916 .unwrap();
917 assert_eq!(inv.cache_ttl.as_deref(), Some("5m"));
918 assert_eq!(inv.nocache, Some(false));
919 }
920
921 #[test]
922 fn parse_rejects_empty_using_string() {
923 let err = parse(&json(r#"{"question":"q","options":{"using":""}}"#)).unwrap_err();
924 match err {
925 ParseError::OutOfRange { path, .. } => assert_eq!(path, "options.using"),
926 _ => panic!("wrong variant"),
927 }
928 }
929
930 #[test]
931 fn parse_rejects_using_wrong_type() {
932 let err = parse(&json(r#"{"question":"q","options":{"using":1}}"#)).unwrap_err();
933 match err {
934 ParseError::WrongType { path, expected } => {
935 assert_eq!(path, "options.using");
936 assert_eq!(expected, "string");
937 }
938 _ => panic!("wrong variant"),
939 }
940 }
941
942 #[test]
943 fn parse_is_deterministic() {
944 let v = json(r#"{"question":"q","options":{"strict":true,"limit":10,"seed":7}}"#);
945 assert_eq!(parse(&v).unwrap(), parse(&v).unwrap());
946 }
947}