Skip to main content

dre_protocol/
msg.rs

1//! Control messages. Every JSON frame is an object with a `type` field. From protocol 1, every
2//! message after the handshake also carries the `id` of the request it belongs to ([`Envelope`]).
3
4use serde::{Deserialize, Serialize};
5use serde_json::{Map, Value};
6
7pub use crate::options::OptionField;
8use crate::{Kind, PluginId};
9
10/// A message with the id of the request it belongs to. Core numbers its requests; every reply,
11/// and every message streamed as part of a request (`result_set_end`, `finish`, `result`,
12/// `result_end`, `log`, `progress`), carries that request's id. `hello` has none, and neither
13/// does anything a protocol 0 peer sends.
14#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
15// In the schema, `Request` and `Response` are each message with its envelope.
16#[schemars(rename = "{T}")]
17pub struct Envelope<T> {
18    #[serde(default, skip_serializing_if = "Option::is_none")]
19    pub id: Option<u64>,
20    #[serde(flatten)]
21    pub body: T,
22}
23
24impl<T> Envelope<T> {
25    pub fn new(id: Option<u64>, body: T) -> Envelope<T> {
26        Envelope { id, body }
27    }
28}
29
30/// Core → plugin.
31#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
32#[serde(tag = "type", rename_all = "snake_case")]
33pub enum Request {
34    /// Always first. The plugin picks a version in both ranges or replies `version_mismatch`.
35    Hello {
36        min_version: u32,
37        max_version: u32,
38        core_version: String,
39        /// The plugin core wants served, for an executable that provides several. A package
40        /// that doesn't provide it replies `error`; without it, the package serves its first.
41        #[serde(default, skip_serializing_if = "Option::is_none")]
42        plugin: Option<PluginId>,
43    },
44    /// Describe the connection fields a profile output for this plugin takes (used by `dre init`)
45    /// and the options a report's config block for it takes.
46    Describe {},
47    /// Check one config block of options (a format's `output:` keys, a destination entry's keys)
48    /// without doing anything. Replies `validated`.
49    Validate {
50        options: Map<String, Value>,
51        /// Format: what the plugin needs to check the output's template too. For the html and
52        /// pdf formats, `{"file": <absolute path or null>, "project_root", "queries": [...]}`.
53        /// Optional; older plugins ignore it.
54        #[serde(default, skip_serializing_if = "Option::is_none")]
55        template: Option<Value>,
56    },
57    /// Source or destination (protocol 1): check a profile entry's connection settings without
58    /// connecting: the declared fields' generic checks, then the plugin's own rules. Keys in
59    /// `unresolved` (an unset `env_var()`) arrive as `null` and count as set, unchecked. Replies
60    /// `validated`, with `warnings` for unknown keys.
61    ValidateConnection {
62        connection: Map<String, Value>,
63        #[serde(default, skip_serializing_if = "Vec::is_empty")]
64        unresolved: Vec<String>,
65    },
66    /// Source: open a session. All later `execute`/`check` requests run on it.
67    Open {
68        connection: Map<String, Value>,
69        read_only: bool,
70        /// Core's `dre query`: the session must not reach files, programs or the network
71        /// beyond the database (protocol 1, optional; older plugins ignore it).
72        #[serde(default, skip_serializing_if = "std::ops::Not::not")]
73        sandbox: bool,
74    },
75    /// Source: run one statement. Replies `result` (then Arrow frames, then `result_end`) or
76    /// `no_result`.
77    Execute {
78        sql: String,
79        #[serde(default, skip_serializing_if = "Option::is_none")]
80        row_limit: Option<u64>,
81    },
82    /// Source: verify one statement without executing it. Replies `ok` or `error`.
83    Check {
84        sql: String,
85    },
86    /// Format: write result sets to `path`. Followed, per result set, by Arrow frames (at least one,
87    /// carrying the schema) and a `result_set_end`, then `finish`. Replies `written`.
88    Write {
89        path: String,
90        format: String,
91        options: Map<String, Value>,
92        result_sets: Vec<ResultSetMeta>,
93        /// Core can answer `need_fonts` during this write. Absent on older cores.
94        #[serde(default, skip_serializing_if = "std::ops::Not::not")]
95        font_requests: bool,
96        #[serde(default, skip_serializing_if = "Option::is_none")]
97        template: Option<Value>,
98    },
99    /// Reply to a font request within a write; retains the write request id. `repeated` is set
100    /// when core already gave this font's `error` earlier in the run, so the person has been
101    /// told once and the format needn't warn again.
102    Fonts {
103        paths: Vec<String>,
104        error: Option<String>,
105        #[serde(default, skip_serializing_if = "std::ops::Not::not")]
106        repeated: bool,
107    },
108    ResultSetEnd {},
109    Finish {},
110    /// Destination: deliver a local file, or with `files` (only to a plugin advertising
111    /// `multi_file`) every file of one output at once. Exactly one of `local_path`/`files` is set,
112    /// unless `message` is: then `files` (possibly empty) holds the files to attach to it, and is
113    /// sent only to a plugin advertising `message`. `options` are the destination entry's plugin
114    /// options, rendered by core. Replies `delivered`.
115    Deliver {
116        #[serde(default, skip_serializing_if = "Option::is_none")]
117        local_path: Option<String>,
118        #[serde(default, skip_serializing_if = "Option::is_none")]
119        remote_path: Option<String>,
120        #[serde(default, skip_serializing_if = "Vec::is_empty")]
121        files: Vec<DeliveryFile>,
122        connection: Map<String, Value>,
123        #[serde(default)]
124        options: Map<String, Value>,
125        #[serde(default, skip_serializing_if = "Option::is_none")]
126        message: Option<Message>,
127    },
128    /// Source: load rows into a temporary table on the session, named after `name`. Followed by
129    /// Arrow frames (at least one, carrying the schema) and a `result_set_end`. Replies `loaded`.
130    /// Only sent to plugins advertising `load`.
131    Load {
132        name: String,
133    },
134    /// End the conversation; the plugin exits 0.
135    Close {},
136    /// Protocol 1: stop the request whose id the envelope carries. It has no reply of its own:
137    /// the request replies `error` with kind `cancelled`, or its usual reply if it finished
138    /// first. A cancel for a request that isn't running is ignored.
139    Cancel {},
140}
141
142/// Plugin → core.
143#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
144#[serde(tag = "type", rename_all = "snake_case")]
145pub enum Response {
146    Hello {
147        protocol_version: u32,
148        kind: Kind,
149        name: String,
150        version: String,
151        #[serde(default)]
152        capabilities: Vec<String>,
153        /// Every plugin the executable serves; empty for one that serves only `kind`/`name`.
154        #[serde(default, skip_serializing_if = "Vec::is_empty")]
155        provides: Vec<PluginId>,
156    },
157    VersionMismatch {
158        min_version: u32,
159        max_version: u32,
160    },
161    Describe {
162        connection_fields: Vec<ConnectionField>,
163        #[serde(default, skip_serializing_if = "Vec::is_empty")]
164        option_fields: Vec<OptionField>,
165        /// Source: the character the database quotes identifiers with (`"` for DuckDB and
166        /// Postgres, a backtick for Databricks). Core uses it to quote names a project asks to
167        /// quote (a source's `quoting:`). Every source sets it; other kinds leave it out.
168        #[serde(default, skip_serializing_if = "Option::is_none")]
169        identifier_quote: Option<String>,
170        /// Source: how core spells a string holding `'` or `\` when it writes one into SQL (an
171        /// inlined lookup): absent or `chr` for `chr(39)`/`chr(92)`, `backslash` for `\'`/`\\`
172        /// inside the quotes (a database without `chr()`, such as ClickHouse).
173        #[serde(default, skip_serializing_if = "Option::is_none")]
174        string_literal: Option<String>,
175        /// Destination advertising `message`: the most characters a message may have in the
176        /// service, after translation. Core shows it next to a message's length in a preview.
177        #[serde(default, skip_serializing_if = "Option::is_none")]
178        message_limit: Option<u64>,
179        /// Source: a connection field naming a file only one process may write at a time
180        /// (DuckDB's `path`). Core runs one Binding at a time on a connection that sets it,
181        /// whatever the entry's `threads:`.
182        #[serde(default, skip_serializing_if = "Option::is_none")]
183        single_writer: Option<SingleWriter>,
184    },
185    /// Every problem with the options, each a sentence naming the key; empty when they're fine.
186    Validated {
187        errors: Vec<String>,
188        /// Problems that don't stop a run (an unknown key); `validate_connection` only.
189        #[serde(default, skip_serializing_if = "Vec::is_empty")]
190        warnings: Vec<String>,
191    },
192    Ok {},
193    /// A result set follows as Arrow frames, ended by `result_end`.
194    Result {
195        columns: Vec<String>,
196    },
197    ResultEnd {
198        rows: u64,
199    },
200    NoResult {
201        #[serde(default, skip_serializing_if = "Option::is_none")]
202        rows_affected: Option<u64>,
203    },
204    /// A format needs a pinned registry font before completing its write.
205    NeedFonts {
206        font: FontRequest,
207    },
208    Written {
209        files: Vec<String>,
210        /// Things the person should know about the files (a value written as text, say).
211        #[serde(default, skip_serializing_if = "Vec::is_empty")]
212        warnings: Vec<String>,
213    },
214    Delivered {
215        location: String,
216        /// How many tries the delivery took, when it was more than one (protocol 1).
217        #[serde(default, skip_serializing_if = "Option::is_none")]
218        attempts: Option<u32>,
219    },
220    /// `relation` is what SQL uses to read the loaded rows. `warning`, when set, is shown to the
221    /// user (e.g. the database has no bulk path, so a load this size is slow).
222    Loaded {
223        relation: String,
224        rows: u64,
225        #[serde(default, skip_serializing_if = "Option::is_none")]
226        warning: Option<String>,
227    },
228    /// The request failed. `kind` is one of [`ERROR_KINDS`]; `code` names the problem,
229    /// namespaced by the plugin (`sftp/host-key-mismatch`). Both are optional (protocol 0 has
230    /// neither): without a `code`, core reports the failure under its own code for the step;
231    /// a `code` without a `kind` counts as `internal`.
232    Error {
233        message: String,
234        #[serde(default, skip_serializing_if = "Option::is_none")]
235        kind: Option<String>,
236        #[serde(default, skip_serializing_if = "Option::is_none")]
237        code: Option<String>,
238    },
239    /// Protocol 1: a log line, while a request runs (with its id) or between requests.
240    Log {
241        level: LogLevel,
242        message: String,
243        /// Structured context (`{"attempt": 2}`); never secrets.
244        #[serde(default, skip_serializing_if = "Map::is_empty")]
245        fields: Map<String, Value>,
246    },
247    /// Protocol 1, optional: how far a long request has got. Core shows it when present.
248    Progress {
249        #[serde(default, skip_serializing_if = "Option::is_none")]
250        message: Option<String>,
251        #[serde(default, skip_serializing_if = "Option::is_none")]
252        done: Option<u64>,
253        #[serde(default, skip_serializing_if = "Option::is_none")]
254        total: Option<u64>,
255    },
256}
257
258impl Response {
259    /// An `error` reply with only a message.
260    pub fn error(message: impl Into<String>) -> Response {
261        Response::Error {
262            message: message.into(),
263            kind: None,
264            code: None,
265        }
266    }
267}
268
269/// The kinds an `error` reply may give, from DRE's error-code registry.
270pub const ERROR_KINDS: &[&str] = &[
271    "config",
272    "plugin",
273    "refused",
274    "connection",
275    "auth",
276    "query",
277    "delivery",
278    "internal",
279    "cancelled",
280    "timed_out",
281];
282
283/// How important a `log` message is. Core shows `info` and above to the person, and keeps
284/// `debug` and `trace` for `-v` and the log file.
285#[derive(
286    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, schemars::JsonSchema,
287)]
288#[serde(rename_all = "lowercase")]
289pub enum LogLevel {
290    Error,
291    Warn,
292    Info,
293    Debug,
294    Trace,
295}
296
297/// A rendered message, as `deliver` carries it.
298#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
299pub struct Message {
300    /// One line, plain text.
301    pub title: String,
302    /// The portable Markdown subset ([`crate::markdown`]).
303    pub text: String,
304    /// An HTML body, when one was made; core leaves it out today. A plugin that can show HTML
305    /// (email) uses it when present, else converts `text`.
306    #[serde(default, skip_serializing_if = "Option::is_none")]
307    pub html: Option<String>,
308    /// The `.md` file core wrote: `# title`, then the text.
309    pub path: String,
310}
311
312/// One file of a multi-file `deliver`.
313#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
314pub struct DeliveryFile {
315    pub local_path: String,
316    #[serde(default, skip_serializing_if = "Option::is_none")]
317    pub remote_path: Option<String>,
318}
319
320/// Per result set, what a format plugin needs to lay it out.
321#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
322pub struct ResultSetMeta {
323    /// Sheet name (xlsx) or file suffix (single-table formats).
324    pub name: String,
325    /// The query (`.sql` basename) that produced it.
326    pub query: String,
327    /// 1-based index among that query's result sets.
328    pub result_index: usize,
329    #[serde(default, skip_serializing_if = "Option::is_none")]
330    pub anchor: Option<String>,
331    #[serde(default, skip_serializing_if = "Option::is_none")]
332    pub header: Option<bool>,
333    /// The query entry's `columns:` map: per result column, how to show it.
334    #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
335    pub columns: std::collections::BTreeMap<String, ColumnOptions>,
336    /// The query entry's `autofit` (xlsx): size this tab's columns from their content, over the
337    /// output's `autofit`.
338    #[serde(default, skip_serializing_if = "Option::is_none")]
339    pub autofit: Option<bool>,
340    /// The query entry's `style` (xlsx), over the output's.
341    #[serde(default, skip_serializing_if = "Option::is_none")]
342    pub style: Option<crate::style::SheetStyle>,
343}
344
345/// One entry of a `columns:` map.
346#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
347pub struct ColumnOptions {
348    /// An Excel number format code.
349    #[serde(default, skip_serializing_if = "Option::is_none")]
350    pub format: Option<String>,
351    /// A row formula (`={qty}*{price}`), written in place of the column's value, which becomes
352    /// its cached result.
353    #[serde(default, skip_serializing_if = "Option::is_none")]
354    pub formula: Option<String>,
355    /// A totals row entry under the data: `sum`, `average`, `count`, `min`, `max`, or a formula
356    /// over whole columns (`=SUM({amount:*})/COUNT({qty:*})`).
357    #[serde(default, skip_serializing_if = "Option::is_none")]
358    pub total: Option<String>,
359    /// The column's width (xlsx), over the tab's and output's `autofit`.
360    #[serde(default, skip_serializing_if = "Option::is_none")]
361    pub width: Option<ColumnWidth>,
362    /// How the column's data cells look (xlsx), over the tab's and output's `style`.
363    #[serde(default, skip_serializing_if = "Option::is_none")]
364    pub style: Option<crate::style::CellStyle>,
365}
366
367/// A column width: sized from the content, or a number of characters.
368#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
369#[serde(untagged)]
370pub enum ColumnWidth {
371    Chars(f64),
372    Auto(AutoWidth),
373}
374
375/// The word `auto`.
376#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
377#[serde(rename_all = "lowercase")]
378pub enum AutoWidth {
379    Auto,
380}
381
382/// A source's connection field naming a file only one process may write at a time.
383#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
384pub struct SingleWriter {
385    /// The connection field (`path`).
386    pub field: String,
387    /// Values of the field that name no file, so connections may share them (`:memory:`).
388    #[serde(default, skip_serializing_if = "Vec::is_empty")]
389    pub except: Vec<String>,
390}
391
392impl SingleWriter {
393    /// Whether `connection` sets the field to a file: a string other than the exceptions.
394    pub fn applies(&self, connection: &Map<String, Value>) -> bool {
395        connection
396            .get(&self.field)
397            .and_then(Value::as_str)
398            .is_some_and(|v| !self.except.iter().any(|e| e == v))
399    }
400}
401
402#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
403pub struct ConnectionField {
404    pub name: String,
405    #[serde(default)]
406    pub description: String,
407    #[serde(default)]
408    pub required: bool,
409    /// Offered as an `env_var()` reference by default in `dre init`.
410    #[serde(default)]
411    pub secret: bool,
412    #[serde(default, skip_serializing_if = "Option::is_none")]
413    pub default: Option<Value>,
414    /// A source type whose profile has the same field: `dre init` offers the value entered for
415    /// that source as this field's default (e.g. one Databricks host for source and destination).
416    #[serde(default, skip_serializing_if = "Option::is_none")]
417    pub same_as_source: Option<String>,
418    /// Set by hand in `profiles.yml`: `dre init` doesn't ask for it. For alternatives to a
419    /// prompted field (a key given as text instead of a file) and nested blocks (`ssh:`).
420    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
421    pub manual: bool,
422    /// What the value must be, checked by `validate_connection` (and `dre init` as you type).
423    #[serde(default, skip_serializing_if = "Option::is_none")]
424    pub kind: Option<FieldKind>,
425    /// The only values it may take (`dre init` offers them as a choice).
426    #[serde(default, skip_serializing_if = "Vec::is_empty")]
427    pub choices: Vec<String>,
428}
429
430/// What a connection field's value must be.
431#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
432#[serde(rename_all = "snake_case")]
433pub enum FieldKind {
434    String,
435    /// A whole number, or a string of one (`port: "5432"`).
436    Integer,
437    Boolean,
438    /// A file that must exist; a leading `~/` is the home directory.
439    Path,
440    /// `30s`, `2m`, `1h`, or seconds.
441    Duration,
442    /// A block of settings (`ssh:`), checked by the plugin's own rules.
443    Map,
444}
445
446impl ConnectionField {
447    pub fn new(name: &str, description: &str) -> Self {
448        ConnectionField {
449            name: name.into(),
450            description: description.into(),
451            required: false,
452            secret: false,
453            default: None,
454            same_as_source: None,
455            manual: false,
456            kind: None,
457            choices: Vec::new(),
458        }
459    }
460    #[must_use]
461    pub fn kind(mut self, kind: FieldKind) -> Self {
462        self.kind = Some(kind);
463        self
464    }
465    #[must_use]
466    pub fn choices(mut self, choices: &[&str]) -> Self {
467        self.choices = choices.iter().map(|c| c.to_string()).collect();
468        self.kind.get_or_insert(FieldKind::String);
469        self
470    }
471    #[must_use]
472    pub fn required(mut self) -> Self {
473        self.required = true;
474        self
475    }
476    #[must_use]
477    pub fn secret(mut self) -> Self {
478        self.secret = true;
479        self
480    }
481    #[must_use]
482    pub fn default(mut self, v: impl Into<Value>) -> Self {
483        self.default = Some(v.into());
484        self
485    }
486    #[must_use]
487    pub fn same_as_source(mut self, source_type: &str) -> Self {
488        self.same_as_source = Some(source_type.into());
489        self
490    }
491    #[must_use]
492    pub fn manual(mut self) -> Self {
493        self.manual = true;
494        self
495    }
496}
497
498#[cfg(test)]
499mod font_tests {
500    use super::*;
501
502    #[test]
503    fn old_write_requests_do_not_enable_font_requests() {
504        let request: Request = serde_json::from_value(serde_json::json!({
505            "type": "write", "path": "out.pdf", "format": "pdf", "options": {}, "result_sets": []
506        }))
507        .unwrap();
508        assert!(matches!(
509            request,
510            Request::Write {
511                font_requests: false,
512                ..
513            }
514        ));
515        assert!(
516            serde_json::to_value(request)
517                .unwrap()
518                .get("font_requests")
519                .is_none()
520        );
521    }
522}
523
524/// Why core couldn't supply a requested font: `message` for the person, and whether core already
525/// gave it for this font earlier in the run (`repeated`).
526#[derive(Debug, Clone, PartialEq, Eq)]
527pub struct FontError {
528    pub message: String,
529    pub repeated: bool,
530}
531
532impl From<String> for FontError {
533    fn from(message: String) -> FontError {
534        FontError {
535            message,
536            repeated: false,
537        }
538    }
539}
540
541impl From<&str> for FontError {
542    fn from(message: &str) -> FontError {
543        message.to_string().into()
544    }
545}
546
547impl std::fmt::Display for FontError {
548    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
549        f.write_str(&self.message)
550    }
551}
552
553/// One immutable font package requested by a format plugin.
554#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
555pub struct FontRequest {
556    pub name: String,
557    pub version: String,
558}