Skip to main content

assay_core/model/
serde.rs

1use crate::on_error::ErrorPolicy;
2use serde::de::Error as _;
3use serde::Deserialize;
4
5use super::types::{Expected, TestCase, TestInput};
6
7/// Legacy (pre-`type:`-tagged) keys an `expected:` block may still be written with.
8///
9/// This surface is frozen: new metrics are only reachable through the tagged form
10/// (`type: <metric>`). It is listed here so error messages can name the accepted
11/// alternatives instead of leaving the author to guess.
12const LEGACY_EXPECTED_KEYS: [&str; 4] = ["$ref", "must_contain", "sequence", "schema"];
13
14#[derive(Default)]
15enum RawExpected {
16    #[default]
17    Missing,
18    Present(serde_json::Value),
19}
20
21impl<'de> Deserialize<'de> for RawExpected {
22    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
23    where
24        D: serde::Deserializer<'de>,
25    {
26        serde_json::Value::deserialize(deserializer).map(Self::Present)
27    }
28}
29
30/// Describe a JSON value's shape for error messages.
31fn value_kind(v: &serde_json::Value) -> &'static str {
32    match v {
33        serde_json::Value::Null => "null",
34        serde_json::Value::Bool(_) => "a boolean",
35        serde_json::Value::Number(_) => "a number",
36        serde_json::Value::String(_) => "a string",
37        serde_json::Value::Array(_) => "a list",
38        serde_json::Value::Object(_) => "a mapping",
39    }
40}
41
42/// Parse a single `expected:` entry into an [`Expected`].
43///
44/// Resolution order is strict V1 (`type:`-tagged) first, then the frozen legacy
45/// heuristics in [`LEGACY_EXPECTED_KEYS`].
46///
47/// An entry matching neither is an error and **never** falls back to
48/// `Expected::default()`. That default is an empty `must_contain`, which the
49/// `must_contain` metric passes unconditionally, so a silent fallback turns a
50/// misspelled key into a test that always reports green.
51///
52/// The same function serves both the scalar and the list position. Keeping one
53/// implementation is deliberate: the two positions previously diverged (the list
54/// branch applied legacy heuristics, the scalar branch did not), which made
55/// `expected: {must_contain: [...]}` silently vacuous while the list-wrapped form
56/// of the same YAML worked.
57pub(crate) fn parse_expected_entry(item: &serde_json::Value) -> Result<Expected, String> {
58    // 1. Strict V1 (tagged).
59    let strict_err = match serde_json::from_value::<Expected>(item.clone()) {
60        Ok(exp) => return reject_for_parse(exp),
61        Err(e) => e,
62    };
63
64    let Some(obj) = item.as_object() else {
65        return Err(format!(
66            "`expected:` must be a mapping (or a list of one mapping), found {}",
67            value_kind(item)
68        ));
69    };
70
71    let matched_keys: Vec<&str> = LEGACY_EXPECTED_KEYS
72        .iter()
73        .copied()
74        .filter(|key| obj.contains_key(*key))
75        .collect();
76
77    // A failed tagged parse may enter the legacy decoder only for the two frozen
78    // compatibility forms. Establish that before decoding any legacy value: an
79    // unrelated malformed value must not replace the original tagged error.
80    if let Some(tag) = obj.get("type") {
81        let compatibility_key = match tag.as_str() {
82            Some("must_contain") => "must_contain",
83            Some("sequence") => "sequence",
84            _ => return Err(format!("invalid `expected:` block: {}", strict_err)),
85        };
86
87        if matched_keys.len() > 1 {
88            return Err(format!(
89                "ambiguous legacy `expected:` block contains multiple assertions {:?}; \
90                 use one tagged assertion or move additional checks to `assertions:`",
91                matched_keys
92            ));
93        }
94
95        let only_compatibility_key = matched_keys.as_slice() == [compatibility_key];
96        let has_unknown_key = obj
97            .keys()
98            .any(|key| key != "type" && key != compatibility_key);
99        if !only_compatibility_key || has_unknown_key {
100            return Err(format!("invalid `expected:` block: {}", strict_err));
101        }
102    } else {
103        let unknown_keys: Vec<&str> = obj
104            .keys()
105            .map(String::as_str)
106            .filter(|key| !LEGACY_EXPECTED_KEYS.contains(key))
107            .collect();
108        if !unknown_keys.is_empty() {
109            return Err(format!(
110                "unrecognized legacy `expected:` key(s) {:?}; supported keys are {:?}",
111                unknown_keys, LEGACY_EXPECTED_KEYS
112            ));
113        }
114    }
115
116    // 2. Legacy heuristics.
117    //
118    // These also recognize two tagged compatibility forms: a scalar value for
119    // `type: must_contain`, and the historical `type: sequence`. A failed tagged
120    // parse may not fall back through an unrelated legacy key, because that would
121    // silently change the metric the author selected.
122    let mut parsed = None;
123
124    if let Some(r) = obj.get("$ref") {
125        let path = r
126            .as_str()
127            .ok_or_else(|| format!("`$ref` must be a string, found {}", value_kind(r)))?;
128        parsed = Some(Expected::Reference {
129            path: path.to_string(),
130        });
131    }
132
133    // Don't chain else-ifs, check all to detect ambiguity
134    if let Some(mc) = obj.get("must_contain") {
135        // No `unwrap_or_default()` here: an unparsable value used to collapse to an
136        // empty vec, i.e. an assertion that passes for any response — the very bug
137        // this module exists to prevent.
138        let val: Vec<String> = if let Some(s) = mc.as_str() {
139            vec![s.to_string()]
140        } else {
141            serde_json::from_value(mc.clone()).map_err(|e| {
142                format!(
143                    "`must_contain` must be a string or a list of strings, found {}: {}",
144                    value_kind(mc),
145                    e
146                )
147            })?
148        };
149        // Last match wins for parsed, but we warn below
150        if parsed.is_none() {
151            parsed = Some(Expected::MustContain { must_contain: val });
152        }
153    }
154
155    if let Some(seq) = obj.get("sequence") {
156        if parsed.is_none() {
157            // Previously `.ok()`, which turned a bad value into `sequence: None`.
158            // `sequence_valid` passes unconditionally when it has neither a sequence
159            // nor rules, so that silently produced an always-green test.
160            let sequence: Vec<String> = serde_json::from_value(seq.clone()).map_err(|e| {
161                format!(
162                    "`sequence` must be a list of strings, found {}: {}",
163                    value_kind(seq),
164                    e
165                )
166            })?;
167            parsed = Some(Expected::SequenceValid {
168                policy: None,
169                sequence: Some(sequence),
170                rules: None,
171            });
172        }
173    }
174
175    if obj.get("schema").is_some() && parsed.is_none() {
176        parsed = Some(Expected::ArgsValid {
177            policy: None,
178            schema: obj.get("schema").cloned(),
179        });
180    }
181
182    if matched_keys.len() > 1 {
183        return Err(format!(
184            "ambiguous legacy `expected:` block contains multiple assertions {:?}; \
185             use one tagged assertion or move additional checks to `assertions:`",
186            matched_keys
187        ));
188    }
189
190    if let Some(p) = parsed {
191        return reject_for_parse(p);
192    }
193
194    // 3. Nothing matched. A block that carries `type:` was asking for the tagged
195    // form, so report why that parse failed rather than listing legacy keys it
196    // never wanted.
197    if obj.contains_key("type") {
198        return Err(format!("invalid `expected:` block: {}", strict_err));
199    }
200
201    let found: Vec<&str> = obj.keys().map(String::as_str).collect();
202    Err(format!(
203        "unrecognized `expected:` block, found key(s) {:?}. Use the tagged form \
204         (e.g. `type: must_contain` with `must_contain: [...]`) or one of the legacy \
205         keys {:?}",
206        found, LEGACY_EXPECTED_KEYS
207    ))
208}
209
210/// Reject an `expected:` block that was written out in full but asserts nothing.
211///
212/// An empty `must_contain` / `must_not_contain` gives the metric no substring to
213/// look for, so it passes for any response. Catching it here rather than only in
214/// `assay validate` matters: this path runs for every command that loads a config,
215/// including `assay run` and `assay ci`, which are the gates that decide outcomes.
216///
217/// This applies only to an assertion the author actually wrote. Omitting `expected:`
218/// altogether stays permissive — see the note in the `TestCase` deserializer — and
219/// is reported as a warning by the `W_CFG_VACUOUS_EXPECTED` rule instead.
220fn reject_vacuous(exp: Expected) -> Result<Expected, String> {
221    let Some(field) = super::validation::vacuous_expected_field(&exp) else {
222        return Ok(exp);
223    };
224
225    Err(format!(
226        "`{}` asserts nothing, so this test would pass for any response. \
227         Give it at least one entry, or remove the `expected:` block and put the \
228         test's checks in `assertions:`.",
229        field
230    ))
231}
232
233fn reject_for_parse(exp: Expected) -> Result<Expected, String> {
234    let exp = reject_vacuous(exp)?;
235    if let Some(reason) = super::validation::non_executable_expected_reason(&exp) {
236        return Err(format!("expected block is not executable: {reason}"));
237    }
238    if let Some(reason) = super::validation::ineffective_expected_reason(&exp) {
239        return Err(reason.to_string());
240    }
241    Ok(exp)
242}
243
244/// Parse the whole `expected:` value (scalar or list form) for one test case.
245///
246/// Multi-element lists are rejected. `TestCase::expected` holds exactly one
247/// [`Expected`]; the previous code kept element 0 and dropped the rest without a
248/// word, so a two-assertion block enforced half of what it claimed. Supporting
249/// them properly would mean making `expected` a collection, which changes the
250/// metric-dispatch contract everywhere it is matched on; until that happens the
251/// honest behaviour is to refuse the input and name the fix. Single-element lists
252/// stay accepted for legacy compatibility.
253fn parse_expected_value(test_id: &str, val: &serde_json::Value) -> Result<Expected, String> {
254    let Some(arr) = val.as_array() else {
255        return parse_expected_entry(val).map_err(|e| format!("test '{}': {}", test_id, e));
256    };
257
258    match arr.len() {
259        0 => Err(format!(
260            "test '{}': `expected:` is an empty list, which asserts nothing. \
261             Remove the key or give it an assertion.",
262            test_id
263        )),
264        1 => parse_expected_entry(&arr[0])
265            .map_err(|e| format!("test '{}': `expected:` entry 0 is invalid: {}", test_id, e)),
266        n => Err(format!(
267            "test '{}': `expected:` has {} entries but only one is supported \
268             (earlier versions silently dropped all but the first). \
269             Split them into separate tests, or move the extra checks to `assertions:`.",
270            test_id, n
271        )),
272    }
273}
274
275impl<'de> Deserialize<'de> for TestCase {
276    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
277    where
278        D: serde::Deserializer<'de>,
279    {
280        #[derive(Deserialize)]
281        #[serde(deny_unknown_fields)]
282        struct RawTestCase {
283            id: String,
284            input: TestInput,
285            #[serde(default)]
286            expected: RawExpected,
287            assertions: Option<Vec<crate::agent_assertions::model::TraceAssertion>>,
288            #[serde(default)]
289            on_error: Option<ErrorPolicy>,
290            #[serde(default)]
291            tags: Vec<String>,
292            metadata: Option<serde_json::Value>,
293        }
294
295        let raw = RawTestCase::deserialize(deserializer)?;
296        let extra_assertions = raw.assertions.unwrap_or_default();
297
298        // A missing `expected:` key stays permissive: a test may carry its checks in
299        // `assertions:` instead. It resolves to the vacuous default, which the
300        // `W_CFG_VACUOUS_EXPECTED` rule in `assay validate` reports when the test has
301        // no assertions either. A present-but-unparsable key is a different matter and
302        // is a hard error below.
303        let expected_main = match &raw.expected {
304            RawExpected::Present(val) => {
305                parse_expected_value(&raw.id, val).map_err(D::Error::custom)?
306            }
307            RawExpected::Missing => Expected::default(),
308        };
309
310        Ok(TestCase {
311            id: raw.id,
312            input: raw.input,
313            expected: expected_main,
314            assertions: if extra_assertions.is_empty() {
315                None
316            } else {
317                Some(extra_assertions)
318            },
319            on_error: raw.on_error,
320            tags: raw.tags,
321            metadata: raw.metadata,
322        })
323    }
324}
325
326impl<'de> Deserialize<'de> for TestInput {
327    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
328    where
329        D: serde::Deserializer<'de>,
330    {
331        struct TestInputVisitor;
332
333        impl<'de> serde::de::Visitor<'de> for TestInputVisitor {
334            type Value = TestInput;
335
336            fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
337                formatter.write_str("string or struct TestInput")
338            }
339
340            fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
341            where
342                E: serde::de::Error,
343            {
344                Ok(TestInput {
345                    prompt: value.to_owned(),
346                    context: None,
347                })
348            }
349
350            fn visit_map<A>(self, map: A) -> Result<Self::Value, A::Error>
351            where
352                A: serde::de::MapAccess<'de>,
353            {
354                // Default derivation logic manually implemented or use intermediate struct
355                // Using intermediate struct is easier to avoid massive boilerplate
356                #[derive(Deserialize)]
357                struct Helper {
358                    prompt: String,
359                    #[serde(default)]
360                    context: Option<Vec<String>>,
361                }
362                let helper =
363                    Helper::deserialize(serde::de::value::MapAccessDeserializer::new(map))?;
364                Ok(TestInput {
365                    prompt: helper.prompt,
366                    context: helper.context,
367                })
368            }
369        }
370
371        deserializer.deserialize_any(TestInputVisitor)
372    }
373}