Skip to main content

reddb_server/runtime/ai/
mcp_ask_tool.rs

1//! `McpAskTool` — pure descriptor + arg parser for exposing
2//! `ASK '...'` as an MCP tool (issue #409, PRD #391).
3//!
4//! Deep module: no I/O, no transport. The MCP server wiring owns
5//! tool-registration plumbing, the per-call dispatch into
6//! `execute_ask`, and any progressive-response framing. This module
7//! owns "what's the tool's JSON-Schema?" and "given an MCP `arguments`
8//! payload, are the inputs well-formed, and what did the caller
9//! ask for?".
10//!
11//! Two halves:
12//!
13//! 1. [`descriptor`] returns the full tool record — `name`,
14//!    `description`, `inputSchema` (JSON Schema draft-07 compatible).
15//!    Pinned by tests so the wire shape can't silently drift when a
16//!    field is renamed.
17//! 2. [`parse`] takes the MCP `arguments` object and returns either a
18//!    fully-typed [`AskInvocation`] or a typed [`ParseError`] naming
19//!    the offending field and the contract that failed. Mapping
20//!    errors → MCP error frames is the wiring layer's job.
21//!
22//! ## Why a deep module
23//!
24//! MCP clients call this tool with arbitrary JSON — wrong types,
25//! out-of-range numbers, both `cache` and `nocache` set, missing
26//! `question`. Drifting the validation rules between the SQL surface
27//! (`ASK '...' LIMIT N STRICT OFF`) and the MCP surface would let a
28//! tool call set a value that the SQL parser rejects, surfacing
29//! confusing errors deep in the pipeline. Pinning the parser here
30//! keeps the two surfaces aligned by construction: every option this
31//! module accepts MUST also be a recognised SQL clause; tests pin
32//! that 1:1 mapping.
33//!
34//! ## Option parity with SQL
35//!
36//! `ASK '...'` SQL options accepted, per #395/#396/#398/#400/#401/#403:
37//!
38//! | SQL                       | MCP option key   | Type           |
39//! |---------------------------|------------------|----------------|
40//! | `STRICT ON|OFF`           | `strict`         | `bool`         |
41//! | `USING <provider>`        | `using`          | `string`       |
42//! | `MODEL <name>`            | `model`          | `string`       |
43//! | `LIMIT <n>`               | `limit`          | `u32` 1..=200  |
44//! | `MIN_SCORE <f>`           | `min_score`      | `f64` 0..=1    |
45//! | `DEPTH <n>`               | `depth`          | `u32` 0..=10   |
46//! | `TEMPERATURE <f>`         | `temperature`    | `f64` 0..=2    |
47//! | `SEED <n>`                | `seed`           | `u64`          |
48//! | `CACHE TTL '5m'`          | `cache.ttl`      | duration str   |
49//! | `NOCACHE`                 | `nocache`        | `bool` (true)  |
50//!
51//! `cache` and `nocache` are mutually exclusive (a single ASK call
52//! either caches with a TTL or skips the cache; both is nonsensical
53//! and the SQL parser already rejects it, so the MCP parser must too).
54
55use crate::serde_json::{Map, Value};
56
57/// MCP tool name. Pinned — clients hard-code this.
58pub const TOOL_NAME: &str = "reddb.ask";
59
60/// JSON-Schema draft used in `inputSchema.$schema`. Draft-07 is what
61/// the MCP spec assumes and what every MCP client validates against.
62pub const SCHEMA_DRAFT: &str = "http://json-schema.org/draft-07/schema#";
63
64/// Caller-facing limits. Pinned at module level so a future change is
65/// visible in one place and asserted by tests.
66pub 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/// One MCP tool call's parsed, validated arguments. Every field bar
78/// `question` is optional — the engine applies defaults downstream
79/// (DeterminismDecider, RrfFuser, etc.), and `None` here means "no
80/// override, use the engine default" rather than "use zero".
81#[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/// Typed parse failures. Each variant names the offending JSON path
97/// (e.g. `options.limit`) so the wiring layer can echo it back in
98/// the MCP error frame without rebuilding the string.
99#[derive(Debug, Clone, PartialEq)]
100pub enum ParseError {
101    /// `arguments` is not a JSON object.
102    NotAnObject,
103    /// `question` missing or empty after trim.
104    MissingQuestion,
105    /// `question` is present but not a string.
106    QuestionWrongType,
107    /// An option carries the wrong JSON type.
108    WrongType { path: String, expected: &'static str },
109    /// A numeric option is outside its declared range.
110    OutOfRange { path: String, detail: String },
111    /// Both `cache` and `nocache` were set.
112    CacheAndNocache,
113    /// An unknown key was sent under `options`. Strict to catch
114    /// typos like `tempurature` early.
115    UnknownOption { path: String },
116}
117
118/// Build the MCP tool descriptor. Stable JSON; safe to cache.
119///
120/// Shape:
121/// ```json
122/// {
123///   "name": "reddb.ask",
124///   "description": "...",
125///   "inputSchema": { "$schema": "...", "type": "object", ... }
126/// }
127/// ```
128pub 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    // Pinned by `description_emphasises_grounding`. Touching it must
138    // also touch that test so the user-visible promise stays loud.
139    "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
325/// Validate and convert MCP `arguments` into a typed
326/// [`AskInvocation`]. The function is total: every input either
327/// becomes an `AskInvocation` or a typed `ParseError`. No panics, no
328/// silent coercion, no defaulting (defaulting is the engine's job).
329pub 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            // Preserve original (untrimmed) so downstream pipelines
342            // see exactly what the caller sent. Trim is a non-empty
343            // check, not a normalization.
344            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    // ---- descriptor ----
571
572    #[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    // ---- parse: happy path ----
679
680    #[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        // Question is non-empty after trim; original (with whitespace) preserved.
728        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        // Guard against `unwrap_or(0)` regressions — same property
735        // #400/#403 pin elsewhere.
736        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    // ---- parse: error paths ----
747
748    #[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        // `nocache: false` is the implicit default — pairing it with
905        // `cache` is benign, not a conflict.
906        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}