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