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