Skip to main content

dre_protocol/
conformance.rs

1//! The protocol conformance suite. The cases are data, in `conformance/protocol.json` (a request
2//! sequence and the properties its replies must have), so a runner in any language can check a
3//! plugin against them; this module is the Rust runner, driving a plugin binary over stdin and
4//! stdout. `conformance/delivery.json` holds the delivery rules both SDKs share, checked by
5//! [`delivery`]. Every first-party plugin runs the suite in its tests. A package executable
6//! (`dre-plugin-<package>`) is checked once per plugin it provides.
7//!
8//! Every message a plugin sends is also checked against the message schema ([`crate::schema`]).
9//! A case that doesn't apply to a plugin (another kind, a capability it doesn't advertise, a
10//! session the caller didn't describe) is listed as skipped, with the reason.
11
12pub mod delivery;
13
14use std::collections::BTreeMap;
15use std::path::{Path, PathBuf};
16use std::sync::Arc;
17use std::time::Duration;
18
19use serde::Deserialize;
20use serde_json::{Map, Value, json};
21
22use crate::frame::{self, Frame};
23use crate::host::{LogSink, PluginProcess};
24use crate::msg::{ERROR_KINDS, Envelope, Response};
25use crate::{
26    CAPABILITIES, Kind, MAX_VERSION, MIN_VERSION, PluginId, parse_executable_name,
27    parse_package_executable_name,
28};
29
30/// The protocol cases, as published.
31pub const PROTOCOL_CASES: &str = include_str!("../conformance/protocol.json");
32
33/// How long a plugin may take over one reply.
34const TIMEOUT: Duration = Duration::from_secs(20);
35
36/// One case's outcome for one plugin.
37#[derive(Debug)]
38pub struct Check {
39    /// The plugin checked (`<kind>/<name>`), or the package for checks of the package itself.
40    pub plugin: String,
41    /// The case's name, from `conformance/protocol.json`.
42    pub name: String,
43    /// True for a case that passed or didn't apply.
44    pub passed: bool,
45    /// Why the case failed; empty otherwise.
46    pub detail: String,
47    /// Why the case didn't apply to this plugin; `None` when it ran.
48    pub skipped: Option<String>,
49}
50
51impl Check {
52    /// A case that ran, passing or failing with the reason.
53    fn ran(plugin: &str, name: &str, r: Result<(), String>) -> Check {
54        Check {
55            plugin: plugin.to_string(),
56            name: name.to_string(),
57            passed: r.is_ok(),
58            detail: r.err().unwrap_or_default(),
59            skipped: None,
60        }
61    }
62
63    /// A case that doesn't apply to the plugin.
64    fn skipped(plugin: &str, name: &str, why: String) -> Check {
65        Check {
66            plugin: plugin.to_string(),
67            name: name.to_string(),
68            passed: true,
69            detail: String::new(),
70            skipped: Some(why),
71        }
72    }
73}
74
75/// What the session cases run: a connection that opens, and statements by role. The roles the
76/// cases use are `rows` (a result set of at least 3 rows), `empty` (a result set of no rows),
77/// `no_result` (a statement without a result set), `error` (a statement that fails) and `slow`
78/// (a statement that runs for at least a few seconds, until cancelled).
79#[derive(Debug, Clone, Default)]
80pub struct Session {
81    connection: Map<String, Value>,
82    statements: BTreeMap<String, String>,
83}
84
85impl Session {
86    /// A session opened with `connection` (a profile target's fields), with no statements yet.
87    #[must_use]
88    pub fn new(connection: Map<String, Value>) -> Session {
89        Session {
90            connection,
91            statements: BTreeMap::new(),
92        }
93    }
94
95    /// The statement for `role` (`rows`, `empty`, `no_result`, `error`, `slow`).
96    #[must_use]
97    pub fn statement(mut self, role: &str, sql: &str) -> Session {
98        self.statements.insert(role.into(), sql.into());
99        self
100    }
101}
102
103/// How to run the suite.
104#[derive(Debug, Clone, Default)]
105pub struct Options {
106    env: Vec<(String, String)>,
107    session: Option<Session>,
108}
109
110impl Options {
111    /// Extra environment variables for every plugin process (for plugins whose capabilities
112    /// depend on their environment).
113    #[must_use]
114    pub fn env(mut self, env: &[(&str, &str)]) -> Options {
115        self.env
116            .extend(env.iter().map(|(k, v)| (k.to_string(), v.to_string())));
117        self
118    }
119
120    /// Run the session cases (sources) with this session.
121    #[must_use]
122    pub fn session(mut self, session: Session) -> Options {
123        self.session = Some(session);
124        self
125    }
126}
127
128#[derive(Debug, Deserialize)]
129struct Cases {
130    cases: Vec<Case>,
131}
132
133#[derive(Debug, Deserialize)]
134struct Case {
135    name: String,
136    /// `package`: run once per package executable, not per plugin.
137    #[serde(default)]
138    scope: Option<String>,
139    #[serde(default)]
140    kinds: Vec<Kind>,
141    #[serde(default)]
142    requires: Vec<String>,
143    #[serde(default)]
144    session: Vec<String>,
145    steps: Vec<Map<String, Value>>,
146}
147
148/// The parsed protocol cases.
149///
150/// # Panics
151///
152/// Panics if the published cases don't parse (a test keeps them parsing).
153fn cases() -> Vec<Case> {
154    serde_json::from_str::<Cases>(PROTOCOL_CASES)
155        .expect("conformance/protocol.json parses")
156        .cases
157}
158
159/// Run every case against the plugin executable at `path`.
160#[must_use]
161pub fn run(path: &Path) -> Vec<Check> {
162    run_with(path, &Options::default())
163}
164
165/// Like [`run`], with extra environment variables for every plugin process.
166#[must_use]
167pub fn run_with_env(path: &Path, env: &[(&str, &str)]) -> Vec<Check> {
168    run_with(path, &Options::default().env(env))
169}
170
171/// Run every case against the plugin executable at `path`, as `options` say.
172#[must_use]
173pub fn run_with(path: &Path, options: &Options) -> Vec<Check> {
174    let file = path
175        .file_name()
176        .map_or_else(|| path.display().to_string(), |f| f.to_string_lossy().to_string());
177    let cases = cases();
178    if let Some((kind, name)) = parse_executable_name(&file) {
179        let id = PluginId::new(kind, name);
180        return run_plugin(path, &id, false, &cases, options);
181    }
182    let fail = |detail: String| {
183        vec![Check::ran(
184            &file,
185            "the executable is named dre-plugin-<package> or dre-<kind>-<name>",
186            Err(detail),
187        )]
188    };
189    let Some(package) = parse_package_executable_name(&file) else {
190        return fail(format!("`{file}` is neither"));
191    };
192    let provides = match greet(path, None, options) {
193        // A package serving one plugin may leave `provides` out: it serves `kind`/`name`.
194        Ok(hello) => match hello
195            .get("provides")
196            .cloned()
197            .map(serde_json::from_value::<Vec<PluginId>>)
198        {
199            Some(Ok(p)) if !p.is_empty() => p,
200            Some(Err(e)) => return fail(format!("`provides` isn't a list of plugins: {e}")),
201            _ => match serde_json::from_value::<PluginId>(json!(format!(
202                "{}/{}",
203                hello["kind"].as_str().unwrap_or_default(),
204                hello["name"].as_str().unwrap_or_default()
205            ))) {
206                Ok(id) => vec![id],
207                Err(e) => return fail(format!("the hello reply has no valid kind and name: {e}")),
208            },
209        },
210        Err(e) => return fail(format!("the handshake failed: {e}")),
211    };
212    let mut out = Vec::new();
213    for case in cases.iter().filter(|c| c.scope.as_deref() == Some("package")) {
214        let vars = Vars::new(None, options);
215        out.push(finish(&package, case, run_case(path, None, case, &vars, options)));
216    }
217    for id in &provides {
218        out.extend(run_plugin(path, id, true, &cases, options));
219    }
220    out
221}
222
223/// Every case for one plugin. `ask`: name it in the handshake (a package's plugins).
224fn run_plugin(path: &Path, id: &PluginId, ask: bool, cases: &[Case], options: &Options) -> Vec<Check> {
225    let asked = ask.then_some(id);
226    let capabilities: Vec<String> = match greet(path, asked, options) {
227        Ok(h) => match h.get("capabilities").cloned().map(serde_json::from_value) {
228            Some(Ok(c)) => c,
229            None => Vec::new(),
230            Some(Err(e)) => {
231                let why = format!("`capabilities` isn't a list of names: {e}");
232                return vec![Check::ran(
233                    &id.to_string(),
234                    "the plugin starts and answers hello",
235                    Err(why),
236                )];
237            }
238        },
239        // Without a handshake, which cases apply is unknown: fail, rather than skip them.
240        Err(e) => {
241            return vec![Check::ran(
242                &id.to_string(),
243                "the plugin starts and answers hello",
244                Err(e),
245            )];
246        }
247    };
248    let mut out = Vec::new();
249    for case in cases.iter().filter(|c| c.scope.is_none()) {
250        if let Some(reason) = not_applicable(case, id.kind, &capabilities, options) {
251            out.push(Check::skipped(&id.to_string(), &case.name, reason));
252            continue;
253        }
254        let vars = Vars::new(Some(id), options);
255        out.push(finish(
256            &id.to_string(),
257            case,
258            run_case(path, asked, case, &vars, options),
259        ));
260    }
261    out
262}
263
264fn finish(plugin: &str, case: &Case, r: Result<(), String>) -> Check {
265    Check::ran(plugin, &case.name, r)
266}
267
268/// Why `case` doesn't apply, or `None` when it does.
269fn not_applicable(case: &Case, kind: Kind, capabilities: &[String], options: &Options) -> Option<String> {
270    if !case.kinds.is_empty() && !case.kinds.contains(&kind) {
271        return Some(format!("for {:?} plugins", case.kinds));
272    }
273    if let Some(c) = case.requires.iter().find(|c| !capabilities.contains(c)) {
274        return Some(format!("the plugin doesn't advertise `{c}`"));
275    }
276    if case.session.is_empty() {
277        return None;
278    }
279    let Some(session) = &options.session else {
280        return Some("needs a session (conformance::Options::session)".into());
281    };
282    case.session
283        .iter()
284        .find(|s| !session.statements.contains_key(*s))
285        .map(|s| format!("the session has no `{s}` statement"))
286}
287
288/// Start the plugin and return its hello reply (for its capabilities and `provides`).
289fn greet(path: &Path, asked: Option<&PluginId>, options: &Options) -> Result<Map<String, Value>, String> {
290    let mut d = Driver::spawn(path, options)?;
291    d.write_json(&hello(asked, &Map::new()))?;
292    let reply = d.reply()?;
293    if reply["type"] != "hello" {
294        return Err(format!("expected a hello reply, got {reply}"));
295    }
296    Ok(reply.as_object().cloned().unwrap_or_default())
297}
298
299/// The `hello` request: the SDK's version range unless `with` overrides it.
300fn hello(asked: Option<&PluginId>, with: &Map<String, Value>) -> Value {
301    let mut h = json!({
302        "type": "hello",
303        "min_version": MIN_VERSION,
304        "max_version": MAX_VERSION,
305        "core_version": "conformance",
306    });
307    if let Some(id) = asked {
308        h["plugin"] = json!(id.to_string());
309    }
310    for (k, v) in with {
311        h[k] = v.clone();
312    }
313    h
314}
315
316/// The values a case's `${...}` placeholders stand for.
317struct Vars {
318    values: BTreeMap<String, Value>,
319    tmp: PathBuf,
320}
321
322impl Vars {
323    fn new(id: Option<&PluginId>, options: &Options) -> Vars {
324        static NEXT: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0);
325        let n = NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
326        let tmp = std::env::temp_dir().join(format!("dre-conformance-{}-{n}", std::process::id()));
327        let mut values = BTreeMap::new();
328        if let Some(id) = id {
329            values.insert("kind".into(), json!(id.kind.as_str()));
330            values.insert("name".into(), json!(id.name));
331            values.insert("plugin".into(), json!(id.to_string()));
332        }
333        values.insert("tmp".into(), json!(tmp.to_string_lossy()));
334        values.insert("min_version".into(), json!(MIN_VERSION));
335        values.insert("max_version".into(), json!(MAX_VERSION));
336        values.insert("far_version".into(), json!(MAX_VERSION + 1000));
337        values.insert("capabilities".into(), json!(CAPABILITIES));
338        values.insert("error_kinds".into(), json!(ERROR_KINDS));
339        let session = options.session.clone().unwrap_or_default();
340        values.insert("connection".into(), Value::Object(session.connection));
341        for (role, sql) in session.statements {
342            values.insert(format!("sql.{role}"), json!(sql));
343        }
344        Vars { values, tmp }
345    }
346}
347
348/// `v` with every `${name}` replaced: a string that is only a placeholder becomes its value,
349/// whatever its type; elsewhere the value is spliced in as text.
350fn substitute(v: &Value, vars: &Vars) -> Result<Value, String> {
351    Ok(match v {
352        Value::String(s) => {
353            if let Some(name) = s.strip_prefix("${").and_then(|r| r.strip_suffix('}'))
354                && !name.contains('}')
355            {
356                return vars
357                    .values
358                    .get(name)
359                    .cloned()
360                    .ok_or_else(|| format!("unknown placeholder `{s}`"));
361            }
362            let mut out = String::new();
363            let mut rest = s.as_str();
364            while let Some(start) = rest.find("${") {
365                let end = rest[start..]
366                    .find('}')
367                    .ok_or_else(|| format!("unclosed placeholder in `{s}`"))?;
368                let name = &rest[start + 2..start + end];
369                let value = vars
370                    .values
371                    .get(name)
372                    .ok_or_else(|| format!("unknown placeholder `${{{name}}}`"))?;
373                out.push_str(&rest[..start]);
374                match value {
375                    Value::String(t) => out.push_str(t),
376                    other => out.push_str(&other.to_string()),
377                }
378                rest = &rest[start + end + 1..];
379            }
380            out.push_str(rest);
381            Value::String(out)
382        }
383        Value::Array(a) => Value::Array(a.iter().map(|x| substitute(x, vars)).collect::<Result<_, _>>()?),
384        Value::Object(o) => Value::Object(
385            o.iter()
386                .map(|(k, x)| Ok((k.clone(), substitute(x, vars)?)))
387                .collect::<Result<_, String>>()?,
388        ),
389        other => other.clone(),
390    })
391}
392
393/// Whether `value` (`None`: missing) has the properties `m` describes. See
394/// `docs/protocol.md` for the matcher language.
395pub(crate) fn matches(value: Option<&Value>, m: &Value) -> Result<(), String> {
396    let show = || value.map_or_else(|| "nothing".to_string(), |v| v.to_string());
397    match m {
398        Value::Object(ops) if ops.keys().any(|k| k.starts_with('$')) => {
399            for (op, arg) in ops {
400                operator(value, op, arg).map_err(|e| format!("{e} (got {})", show()))?;
401            }
402            Ok(())
403        }
404        Value::Object(fields) => {
405            let Some(Value::Object(o)) = value else {
406                return Err(format!("expected an object, got {}", show()));
407            };
408            for (k, sub) in fields {
409                matches(o.get(k), sub).map_err(|e| format!("`{k}`: {e}"))?;
410            }
411            Ok(())
412        }
413        Value::Array(items) => {
414            let Some(Value::Array(a)) = value else {
415                return Err(format!("expected an array, got {}", show()));
416            };
417            if a.len() != items.len() {
418                return Err(format!("expected {} items, got {}", items.len(), show()));
419            }
420            for (i, (x, sub)) in a.iter().zip(items).enumerate() {
421                matches(Some(x), sub).map_err(|e| format!("[{i}]: {e}"))?;
422            }
423            Ok(())
424        }
425        scalar => match value {
426            Some(v) if same(v, scalar) => Ok(()),
427            _ => Err(format!("expected {scalar}, got {}", show())),
428        },
429    }
430}
431
432/// JSON equality, with numbers compared by value (`1` and `1.0`).
433fn same(a: &Value, b: &Value) -> bool {
434    match (a.as_f64(), b.as_f64()) {
435        (Some(x), Some(y)) => x == y,
436        _ => a == b,
437    }
438}
439
440fn operator(value: Option<&Value>, op: &str, arg: &Value) -> Result<(), String> {
441    let present = value.filter(|v| !v.is_null());
442    let number = |what: &str| -> Result<(f64, f64), String> {
443        let v = present.and_then(Value::as_f64).ok_or("expected a number")?;
444        let a = arg.as_f64().ok_or_else(|| format!("`{what}` takes a number"))?;
445        Ok((v, a))
446    };
447    match op {
448        "$absent" => match (present, arg.as_bool()) {
449            (None, Some(true)) | (Some(_), Some(false)) => Ok(()),
450            _ => Err(format!(
451                "expected {}",
452                if arg == &json!(true) {
453                    "no value"
454                } else {
455                    "a value"
456                }
457            )),
458        },
459        "$any" => value.map(|_| ()).ok_or_else(|| "expected a value".into()),
460        "$one_of" => {
461            let alts = arg.as_array().ok_or("`$one_of` takes a list")?;
462            let mut why = Vec::new();
463            for alt in alts {
464                match matches(value, alt) {
465                    Ok(()) => return Ok(()),
466                    Err(e) => why.push(e),
467                }
468            }
469            Err(format!("matches none of: {}", why.join("; ")))
470        }
471        "$type" => {
472            let want = arg.as_str().ok_or("`$type` takes a name")?;
473            let ok = match (want, present) {
474                ("string", Some(v)) => v.is_string(),
475                ("number", Some(v)) => v.is_number(),
476                ("integer", Some(v)) => v.is_u64() || v.is_i64(),
477                ("boolean", Some(v)) => v.is_boolean(),
478                ("array", Some(v)) => v.is_array(),
479                ("object", Some(v)) => v.is_object(),
480                _ => false,
481            };
482            if ok {
483                Ok(())
484            } else {
485                Err(format!("expected a {want}"))
486            }
487        }
488        "$nonempty" => match present {
489            Some(Value::String(s)) if !s.is_empty() => Ok(()),
490            Some(Value::Array(a)) if !a.is_empty() => Ok(()),
491            _ => Err("expected a non-empty string or list".into()),
492        },
493        "$len" => {
494            let n = match present {
495                Some(Value::String(s)) => s.chars().count(),
496                Some(Value::Array(a)) => a.len(),
497                _ => return Err("expected a string or list".into()),
498            };
499            match arg.as_u64() {
500                Some(want) if want as usize == n => Ok(()),
501                _ => Err(format!("expected length {arg}, got {n}")),
502            }
503        }
504        "$contains" => match present {
505            Some(Value::String(s)) => match arg.as_str() {
506                Some(sub) if s.contains(sub) => Ok(()),
507                _ => Err(format!("expected text containing {arg}")),
508            },
509            Some(Value::Array(a)) => {
510                if a.iter().any(|x| matches(Some(x), arg).is_ok()) {
511                    Ok(())
512                } else {
513                    Err(format!("expected an item matching {arg}"))
514                }
515            }
516            _ => Err("expected a string or list".into()),
517        },
518        "$every" => match present {
519            Some(Value::Array(a)) => {
520                for (i, x) in a.iter().enumerate() {
521                    matches(Some(x), arg).map_err(|e| format!("[{i}]: {e}"))?;
522                }
523                Ok(())
524            }
525            _ => Err("expected a list".into()),
526        },
527        "$in" => {
528            let set = arg.as_array().ok_or("`$in` takes a list")?;
529            match present {
530                Some(v) if set.iter().any(|x| same(x, v)) => Ok(()),
531                _ => Err(format!("expected one of {arg}")),
532            }
533        }
534        "$min" => {
535            let (v, a) = number("$min")?;
536            if v >= a {
537                Ok(())
538            } else {
539                Err(format!("expected at least {a}"))
540            }
541        }
542        "$lt" => {
543            let (v, a) = number("$lt")?;
544            if v < a {
545                Ok(())
546            } else {
547                Err(format!("expected less than {a}"))
548            }
549        }
550        "$file_exists" => match present.and_then(Value::as_str) {
551            Some(p) if Path::new(p).is_file() == (arg == &json!(true)) => Ok(()),
552            _ => Err(format!(
553                "expected a path that {} a file",
554                if arg == &json!(true) { "is" } else { "isn't" }
555            )),
556        },
557        other => Err(format!("unknown matcher `{other}`")),
558    }
559}
560
561/// One plugin process, driven frame by frame.
562struct Driver {
563    p: PluginProcess,
564    /// The last reply read, for a `result_set` step's columns.
565    last: Value,
566}
567
568impl Driver {
569    fn spawn(path: &Path, options: &Options) -> Result<Driver, String> {
570        let env: Vec<(&str, &str)> = options
571            .env
572            .iter()
573            .map(|(k, v)| (k.as_str(), v.as_str()))
574            .collect();
575        let quiet: LogSink = Arc::new(|_, _| {});
576        let p = PluginProcess::spawn_env(path, quiet, &env).map_err(|e| e.to_string())?;
577        Ok(Driver { p, last: Value::Null })
578    }
579
580    fn write_json(&mut self, v: &Value) -> Result<(), String> {
581        let mut bytes = Vec::new();
582        frame::write_json(&mut bytes, v).map_err(|e| e.to_string())?;
583        self.p.write_raw(&bytes).map_err(|e| e.to_string())
584    }
585
586    /// The next message that isn't a `log` or `progress`, checked against the schema.
587    fn reply(&mut self) -> Result<Value, String> {
588        loop {
589            match self.p.recv_frame(TIMEOUT).map_err(|e| e.to_string())? {
590                Frame::Arrow(_) => return Err("Arrow data where a reply was expected".into()),
591                Frame::Json(v) => {
592                    conforms(&v)?;
593                    if !is_chatter(&v) {
594                        self.last = v.clone();
595                        return Ok(v);
596                    }
597                }
598            }
599        }
600    }
601}
602
603/// A `log` or `progress` message: a plugin may send one at any time, before any reply.
604fn is_chatter(v: &Value) -> bool {
605    v["type"] == "log" || v["type"] == "progress"
606}
607
608/// Whether a message a plugin sent matches the message schema.
609fn conforms(v: &Value) -> Result<(), String> {
610    serde_json::from_value::<Envelope<Response>>(v.clone())
611        .map(|_| ())
612        .map_err(|e| format!("{v} doesn't match the message schema: {e}"))
613}
614
615fn run_case(
616    path: &Path,
617    asked: Option<&PluginId>,
618    case: &Case,
619    vars: &Vars,
620    options: &Options,
621) -> Result<(), String> {
622    std::fs::create_dir_all(&vars.tmp).map_err(|e| e.to_string())?;
623    let r = run_steps(path, asked, case, vars, options);
624    let _ = std::fs::remove_dir_all(&vars.tmp);
625    r
626}
627
628fn run_steps(
629    path: &Path,
630    asked: Option<&PluginId>,
631    case: &Case,
632    vars: &Vars,
633    options: &Options,
634) -> Result<(), String> {
635    let mut d = Driver::spawn(path, options)?;
636    if !case.steps.first().is_some_and(|s| s.contains_key("hello")) {
637        d.write_json(&hello(asked, &Map::new()))?;
638        let r = d.reply().map_err(|e| format!("handshake: {e}"))?;
639        if r["type"] != "hello" {
640            return Err(format!("handshake: expected a hello reply, got {r}"));
641        }
642    }
643    for (i, step) in case.steps.iter().enumerate() {
644        let Value::Object(step) = substitute(&Value::Object(step.clone()), vars)? else {
645            unreachable!("substitute keeps an object an object");
646        };
647        run_step(&mut d, asked, &step).map_err(|e| format!("step {}: {e}", i + 1))?;
648    }
649    Ok(())
650}
651
652fn run_step(d: &mut Driver, asked: Option<&PluginId>, step: &Map<String, Value>) -> Result<(), String> {
653    if let Some(h) = step.get("hello") {
654        d.write_json(&hello(asked, h.as_object().unwrap_or(&Map::new())))?;
655    } else if let Some(v) = step.get("send") {
656        d.write_json(v)?;
657    } else if let Some(hex) = step.get("send_raw") {
658        let bytes = decode_hex(hex.as_str().unwrap_or_default())?;
659        // A plugin may end on a malformed frame before reading all of it.
660        let _ = d.p.write_raw(&bytes);
661    } else if let Some(spec) = step.get("send_arrow") {
662        let ipc = arrow_frame(spec)?;
663        let mut bytes = Vec::new();
664        frame::write_arrow(&mut bytes, &ipc).map_err(|e| e.to_string())?;
665        d.p.write_raw(&bytes).map_err(|e| e.to_string())?;
666    } else if step.contains_key("close_input") {
667        d.p.close_input();
668    } else if let Some(spec) = step.get("result_set") {
669        return result_set(d, spec);
670    } else if let Some(m) = step.get("exit") {
671        let status =
672            d.p.wait_exit(TIMEOUT)
673                .ok_or("still running after it should have exited")?;
674        let code = status.code().map_or(Value::Null, Value::from);
675        return matches(Some(&code), m).map_err(|e| format!("exit code: {e}"));
676    } else {
677        return Err(format!("unknown step {}", Value::Object(step.clone())));
678    }
679    let Some(m) = step.get("expect") else {
680        return Ok(());
681    };
682    let or_exit = step.get("or_exit") == Some(&json!(true));
683    match d.reply() {
684        Ok(v) => matches(Some(&v), m).map_err(|e| format!("reply {v}: {e}")),
685        Err(_) if or_exit && d.p.wait_exit(Duration::from_secs(5)).is_some() => Ok(()),
686        Err(e) => Err(e),
687    }
688}
689
690/// Read one result set: Arrow frames (at least one, with the `result` reply's columns), then a
691/// `result_end` whose `rows` is the rows sent.
692fn result_set(d: &mut Driver, spec: &Value) -> Result<(), String> {
693    let columns = d.last["columns"].clone();
694    let mut frames = 0;
695    let mut rows: u64 = 0;
696    loop {
697        match d.p.recv_frame(TIMEOUT).map_err(|e| e.to_string())? {
698            Frame::Arrow(ipc) => {
699                let (schema, batches) = frame::decode_batches(&ipc).map_err(|e| e.to_string())?;
700                let names: Vec<&str> = schema.fields().iter().map(|f| f.name().as_str()).collect();
701                if json!(names) != columns {
702                    return Err(format!(
703                        "the Arrow schema has columns {names:?}, the result reply {columns}"
704                    ));
705                }
706                frames += 1;
707                rows += batches.iter().map(|b| b.num_rows() as u64).sum::<u64>();
708            }
709            Frame::Json(v) => {
710                conforms(&v)?;
711                if is_chatter(&v) {
712                    continue;
713                }
714                if frames == 0 {
715                    return Err(format!("no Arrow frame (carrying the schema) before {v}"));
716                }
717                matches(Some(&v), &json!({"type": "result_end", "id": spec["id"]}))?;
718                if v["rows"].as_u64() != Some(rows) {
719                    return Err(format!("`result_end` says {} rows, {rows} were sent", v["rows"]));
720                }
721                return matches(Some(&json!(rows)), &spec["rows"]).map_err(|e| format!("rows: {e}"));
722            }
723        }
724    }
725}
726
727/// An Arrow IPC stream of `rows` null rows with the given columns.
728fn arrow_frame(spec: &Value) -> Result<Vec<u8>, String> {
729    use arrow::datatypes::{DataType, Field, Schema};
730    let mut fields = Vec::new();
731    for c in spec["columns"].as_array().ok_or("`send_arrow` needs `columns`")? {
732        let t = match c["type"].as_str() {
733            Some("int64") => DataType::Int64,
734            Some("float64") => DataType::Float64,
735            Some("utf8") => DataType::Utf8,
736            Some("boolean") => DataType::Boolean,
737            other => return Err(format!("unknown column type {other:?}")),
738        };
739        fields.push(Field::new(c["name"].as_str().unwrap_or_default(), t, true));
740    }
741    let schema = Arc::new(Schema::new(fields));
742    let rows = spec["rows"].as_u64().unwrap_or(0) as usize;
743    let mut columns: Vec<arrow::array::ArrayRef> = Vec::new();
744    for (f, c) in schema
745        .fields()
746        .iter()
747        .zip(spec["columns"].as_array().into_iter().flatten())
748    {
749        columns.push(match c["values"].as_array() {
750            None => arrow::array::new_null_array(f.data_type(), rows),
751            Some(v) if v.len() != rows => {
752                return Err(format!("`{}` has {} values for {rows} rows", f.name(), v.len()));
753            }
754            Some(v) => match f.data_type() {
755                DataType::Int64 => Arc::new(arrow::array::Int64Array::from_iter(v.iter().map(Value::as_i64))),
756                DataType::Float64 => {
757                    Arc::new(arrow::array::Float64Array::from_iter(v.iter().map(Value::as_f64)))
758                }
759                DataType::Boolean => Arc::new(arrow::array::BooleanArray::from_iter(
760                    v.iter().map(Value::as_bool),
761                )),
762                _ => Arc::new(arrow::array::StringArray::from_iter(v.iter().map(Value::as_str))),
763            },
764        });
765    }
766    let batch = arrow::array::RecordBatch::try_new(schema, columns).map_err(|e| e.to_string())?;
767    frame::encode_batch(&batch).map_err(|e| e.to_string())
768}
769
770fn decode_hex(s: &str) -> Result<Vec<u8>, String> {
771    (0..s.len())
772        .step_by(2)
773        .map(|i| u8::from_str_radix(s.get(i..i + 2).unwrap_or("?"), 16).map_err(|e| e.to_string()))
774        .collect()
775}
776
777/// Panic with a readable report unless every check passes. For use in plugin tests.
778///
779/// # Panics
780///
781/// Panics when a check fails: that is how it fails the test.
782pub fn assert_conforms(path: &Path) {
783    assert_conforms_with(path, &Options::default());
784}
785
786/// [`assert_conforms`], run as `options` say.
787///
788/// # Panics
789///
790/// Panics when a check fails: that is how it fails the test.
791pub fn assert_conforms_with(path: &Path, options: &Options) {
792    let checks = run_with(path, options);
793    let failed: Vec<String> = checks
794        .iter()
795        .filter(|c| !c.passed)
796        .map(|c| format!("  ✗ {}: {}: {}", c.plugin, c.name, c.detail))
797        .collect();
798    assert!(
799        failed.is_empty(),
800        "{} fails protocol conformance:\n{}",
801        path.display(),
802        failed.join("\n")
803    );
804}
805
806#[cfg(test)]
807mod tests {
808    use super::*;
809
810    #[test]
811    fn the_published_cases_parse_and_name_known_capabilities() {
812        let cases = cases();
813        assert!(cases.len() > 20);
814        for c in &cases {
815            assert!(
816                matches!(c.scope.as_deref(), None | Some("package")),
817                "{}: scope",
818                c.name
819            );
820            for r in &c.requires {
821                assert!(CAPABILITIES.contains(&r.as_str()), "{}: {r}", c.name);
822            }
823        }
824    }
825
826    #[test]
827    fn matchers_check_properties_not_whole_messages() {
828        let reply = json!({"type": "error", "id": 4, "message": "no", "kind": "query", "extra": 1});
829        let ok = |m: Value| matches(Some(&reply), &m);
830        assert!(ok(json!({"type": "error", "id": 4})).is_ok());
831        assert!(ok(json!({"code": {"$absent": true}})).is_ok());
832        assert!(ok(json!({"kind": {"$in": ["query", "auth"]}})).is_ok());
833        assert!(ok(json!({"kind": {"$in": ["auth"]}})).is_err());
834        assert!(ok(json!({"message": {"$len": 2}, "id": {"$min": 4, "$lt": 5}})).is_ok());
835        assert!(ok(json!({"$one_of": [{"type": "ok"}, {"type": "error"}]})).is_ok());
836        assert!(ok(json!({"type": "ok"})).is_err());
837        let list = json!(["a/b", "c"]);
838        assert!(matches(Some(&list), &json!({"$contains": {"$contains": "/"}})).is_ok());
839        assert!(matches(Some(&list), &json!({"$every": {"$contains": "/"}})).is_err());
840    }
841
842    #[test]
843    fn placeholders_take_their_values_or_splice_as_text() {
844        let session = Session::new(Map::new()).statement("rows", "rows 3");
845        let vars = Vars::new(
846            Some(&PluginId::new(Kind::Format, "csv")),
847            &Options::default().session(session),
848        );
849        let v = substitute(
850            &json!({"v": "${max_version}", "p": "${tmp}/x.${name}", "sql": "${sql.rows}"}),
851            &vars,
852        )
853        .unwrap();
854        assert_eq!(v["v"], json!(MAX_VERSION));
855        assert!(v["p"].as_str().unwrap().ends_with("/x.csv"));
856        assert_eq!(v["sql"], "rows 3");
857        assert!(substitute(&json!("${nope}"), &vars).is_err());
858    }
859}