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 {
109        path: String,
110        expected: &'static str,
111    },
112    /// A numeric option is outside its declared range.
113    OutOfRange { path: String, detail: String },
114    /// Both `cache` and `nocache` were set.
115    CacheAndNocache,
116    /// An unknown key was sent under `options`. Strict to catch
117    /// typos like `tempurature` early.
118    UnknownOption { path: String },
119}
120
121/// Build the MCP tool descriptor. Stable JSON; safe to cache.
122///
123/// Shape:
124/// ```json
125/// {
126///   "name": "reddb.ask",
127///   "description": "...",
128///   "inputSchema": { "$schema": "...", "type": "object", ... }
129/// }
130/// ```
131pub 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    // Pinned by `description_emphasises_grounding`. Touching it must
141    // also touch that test so the user-visible promise stays loud.
142    "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
330/// Validate and convert MCP `arguments` into a typed
331/// [`AskInvocation`]. The function is total: every input either
332/// becomes an `AskInvocation` or a typed `ParseError`. No panics, no
333/// silent coercion, no defaulting (defaulting is the engine's job).
334pub 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            // Preserve original (untrimmed) so downstream pipelines
347            // see exactly what the caller sent. Trim is a non-empty
348            // check, not a normalization.
349            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    // ---- descriptor ----
570
571    #[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    // ---- parse: happy path ----
689
690    #[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        // Question is non-empty after trim; original (with whitespace) preserved.
738        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        // Guard against `unwrap_or(0)` regressions — same property
745        // #400/#403 pin elsewhere.
746        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    // ---- parse: error paths ----
757
758    #[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        // `nocache: false` is the implicit default — pairing it with
912        // `cache` is benign, not a conflict.
913        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}