Skip to main content

agent_first_psql/
types.rs

1use agent_first_data::LogFilters;
2use serde::{Deserialize, Serialize};
3use serde_json::Value;
4use std::collections::HashMap;
5
6/// Serialize `LogFilters` as its plain string array for `Output::Config` and
7/// re-normalize on the (currently unused) deserialize path via afdata's parser,
8/// so afpsql never keeps a second log-filter representation.
9mod log_filters_serde {
10    use agent_first_data::{LogFilters, cli_parse_log_filters};
11    use serde::{Deserialize, Deserializer, Serialize, Serializer};
12
13    pub fn serialize<S: Serializer>(
14        filters: &LogFilters,
15        serializer: S,
16    ) -> Result<S::Ok, S::Error> {
17        filters.as_slice().serialize(serializer)
18    }
19
20    pub fn deserialize<'de, D: Deserializer<'de>>(deserializer: D) -> Result<LogFilters, D::Error> {
21        let raw = Vec::<String>::deserialize(deserializer)?;
22        Ok(cli_parse_log_filters(&raw))
23    }
24}
25
26#[derive(Debug, Deserialize)]
27#[serde(tag = "code", deny_unknown_fields)]
28pub enum Input {
29    #[serde(rename = "query")]
30    Query {
31        id: String,
32        #[serde(default)]
33        session: Option<String>,
34        sql: String,
35        #[serde(default)]
36        params: Vec<Value>,
37        #[serde(default)]
38        options: QueryOptions,
39    },
40    #[serde(rename = "config")]
41    Config(ConfigPatch),
42    #[serde(rename = "cancel")]
43    Cancel { id: String },
44    #[serde(rename = "ping")]
45    Ping,
46    #[serde(rename = "close")]
47    Close,
48    #[serde(rename = "session_info")]
49    SessionInfo {
50        #[serde(default)]
51        id: Option<String>,
52        #[serde(default)]
53        session: Option<String>,
54    },
55    /// Open an explicit transaction on the named session. Subsequent
56    /// `query` requests on that session run on the open transaction
57    /// (no implicit `BEGIN..COMMIT` wrap) until `commit` or `rollback`.
58    #[serde(rename = "begin")]
59    Begin {
60        #[serde(default)]
61        id: Option<String>,
62        #[serde(default)]
63        session: Option<String>,
64        /// When true, send `BEGIN READ ONLY`. Read-only is the default;
65        /// read-write transactions require an explicit false value and a
66        /// matching write permission.
67        #[serde(default = "default_true")]
68        read_only: bool,
69        /// Pass `--permission write` (or matching ssh-write/container-write)
70        /// to allow `BEGIN` on a session that defaults to read-only. Without
71        /// it, an implicit-read session rejects the begin.
72        #[serde(default)]
73        permission: Option<Permission>,
74    },
75    #[serde(rename = "commit")]
76    Commit {
77        #[serde(default)]
78        id: Option<String>,
79        #[serde(default)]
80        session: Option<String>,
81    },
82    #[serde(rename = "rollback")]
83    Rollback {
84        #[serde(default)]
85        id: Option<String>,
86        #[serde(default)]
87        session: Option<String>,
88    },
89}
90
91#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
92pub enum Permission {
93    #[serde(rename = "read")]
94    Read,
95    #[serde(rename = "write")]
96    Write,
97    #[serde(rename = "ssh-read")]
98    SshRead,
99    #[serde(rename = "ssh-write")]
100    SshWrite,
101    #[serde(rename = "container-read")]
102    ContainerRead,
103    #[serde(rename = "container-write")]
104    ContainerWrite,
105}
106
107impl Permission {
108    pub fn as_str(self) -> &'static str {
109        match self {
110            Self::Read => "read",
111            Self::Write => "write",
112            Self::SshRead => "ssh-read",
113            Self::SshWrite => "ssh-write",
114            Self::ContainerRead => "container-read",
115            Self::ContainerWrite => "container-write",
116        }
117    }
118
119    pub fn is_read_only(self) -> bool {
120        matches!(self, Self::Read | Self::SshRead | Self::ContainerRead)
121    }
122
123    pub fn allows_ssh(self) -> bool {
124        matches!(self, Self::SshRead | Self::SshWrite)
125    }
126
127    pub fn allows_container(self) -> bool {
128        matches!(self, Self::ContainerRead | Self::ContainerWrite)
129    }
130}
131
132fn default_true() -> bool {
133    true
134}
135
136impl std::str::FromStr for Permission {
137    type Err = String;
138
139    fn from_str(value: &str) -> Result<Self, Self::Err> {
140        match value {
141            "read" => Ok(Self::Read),
142            "write" => Ok(Self::Write),
143            "ssh-read" => Ok(Self::SshRead),
144            "ssh-write" => Ok(Self::SshWrite),
145            "container-read" => Ok(Self::ContainerRead),
146            "container-write" => Ok(Self::ContainerWrite),
147            _ => Err(format!(
148                "invalid permission `{value}`; expected read, write, ssh-read, ssh-write, container-read, or container-write"
149            )),
150        }
151    }
152}
153
154#[derive(Debug, Deserialize, Default, Clone)]
155#[serde(deny_unknown_fields)]
156pub struct QueryOptions {
157    #[serde(default)]
158    pub stream_rows: bool,
159    pub batch_rows: Option<usize>,
160    pub batch_bytes: Option<usize>,
161    pub statement_timeout_ms: Option<u64>,
162    pub lock_timeout_ms: Option<u64>,
163    pub permission: Option<Permission>,
164    pub inline_max_rows: Option<usize>,
165    pub inline_max_bytes: Option<usize>,
166}
167
168#[derive(Debug, Serialize)]
169#[serde(tag = "code")]
170pub enum Output {
171    #[serde(rename = "result")]
172    Result {
173        #[serde(skip_serializing_if = "Option::is_none")]
174        id: Option<String>,
175        #[serde(skip_serializing_if = "Option::is_none")]
176        session: Option<String>,
177        command_tag: String,
178        columns: Vec<ColumnInfo>,
179        rows: Vec<Value>,
180        row_count: usize,
181        /// True when `rows` is a prefix of the full result — emit when the
182        /// inline row or byte cap was hit. Default-false serializes elided.
183        #[serde(skip_serializing_if = "is_false", default)]
184        truncated: bool,
185        /// Inline-row cap if that's what fired.
186        #[serde(skip_serializing_if = "Option::is_none")]
187        truncated_at_rows: Option<usize>,
188        /// Inline-byte cap if that's what fired.
189        #[serde(skip_serializing_if = "Option::is_none")]
190        truncated_at_bytes: Option<usize>,
191        trace: Trace,
192    },
193    #[serde(rename = "result_start")]
194    ResultStart {
195        id: String,
196        #[serde(skip_serializing_if = "Option::is_none")]
197        session: Option<String>,
198        columns: Vec<ColumnInfo>,
199    },
200    #[serde(rename = "result_rows")]
201    ResultRows {
202        id: String,
203        rows: Vec<Value>,
204        rows_batch_count: usize,
205    },
206    #[serde(rename = "result_end")]
207    ResultEnd {
208        id: String,
209        #[serde(skip_serializing_if = "Option::is_none")]
210        session: Option<String>,
211        command_tag: String,
212        trace: Trace,
213    },
214    #[serde(rename = "sql_error")]
215    SqlError {
216        #[serde(skip_serializing_if = "Option::is_none")]
217        id: Option<String>,
218        #[serde(skip_serializing_if = "Option::is_none")]
219        session: Option<String>,
220        sqlstate: String,
221        message: String,
222        #[serde(skip_serializing_if = "Option::is_none")]
223        detail: Option<String>,
224        #[serde(skip_serializing_if = "Option::is_none")]
225        hint: Option<String>,
226        #[serde(skip_serializing_if = "Option::is_none")]
227        position: Option<String>,
228        retryable: bool,
229        trace: Trace,
230    },
231    #[serde(rename = "error")]
232    Error {
233        #[serde(skip_serializing_if = "Option::is_none")]
234        id: Option<String>,
235        error_code: String,
236        error: String,
237        #[serde(skip_serializing_if = "Option::is_none")]
238        sqlstate: Option<String>,
239        #[serde(skip_serializing_if = "Option::is_none")]
240        message: Option<String>,
241        #[serde(skip_serializing_if = "Option::is_none")]
242        detail: Option<String>,
243        #[serde(skip_serializing_if = "Option::is_none")]
244        hint: Option<String>,
245        retryable: bool,
246        trace: Trace,
247    },
248    #[serde(rename = "dry_run")]
249    DryRun {
250        #[serde(skip_serializing_if = "Option::is_none")]
251        id: Option<String>,
252        sql: String,
253        params: Vec<String>,
254        #[serde(skip_serializing_if = "Option::is_none")]
255        session: Option<String>,
256        /// Inferred PostgreSQL parameter types in placeholder order
257        /// (`$1`, `$2`, ...). Populated when the server-side PREPARE succeeds.
258        #[serde(skip_serializing_if = "Vec::is_empty", default)]
259        param_types: Vec<String>,
260        /// Output columns inferred from the prepared statement
261        /// (empty for non-SELECT statements).
262        #[serde(skip_serializing_if = "Vec::is_empty", default)]
263        columns: Vec<ColumnInfo>,
264        trace: Trace,
265    },
266    #[serde(rename = "config")]
267    Config(RuntimeConfig),
268    #[serde(rename = "pong")]
269    Pong { trace: PongTrace },
270    #[serde(rename = "close")]
271    Close { message: String, trace: CloseTrace },
272    #[serde(rename = "session_info")]
273    SessionInfo {
274        #[serde(skip_serializing_if = "Option::is_none")]
275        id: Option<String>,
276        session: String,
277        transport_kind: String,
278        permission_default: String,
279        stream_rows_default: bool,
280        batch_rows: usize,
281        batch_bytes: usize,
282        inline_max_rows: usize,
283        inline_max_bytes: usize,
284        statement_timeout_ms: u64,
285        lock_timeout_ms: u64,
286        #[serde(skip_serializing_if = "Option::is_none")]
287        database: Option<String>,
288        #[serde(skip_serializing_if = "Option::is_none")]
289        user: Option<String>,
290        #[serde(skip_serializing_if = "Option::is_none")]
291        host: Option<String>,
292        #[serde(skip_serializing_if = "Option::is_none")]
293        port: Option<u16>,
294        #[serde(skip_serializing_if = "Option::is_none")]
295        server_version: Option<String>,
296        trace: Trace,
297    },
298    #[serde(rename = "log")]
299    Log {
300        event: String,
301        #[serde(skip_serializing_if = "Option::is_none")]
302        request_id: Option<String>,
303        #[serde(skip_serializing_if = "Option::is_none")]
304        session: Option<String>,
305        #[serde(skip_serializing_if = "Option::is_none")]
306        error_code: Option<String>,
307        #[serde(skip_serializing_if = "Option::is_none")]
308        command_tag: Option<String>,
309        #[serde(skip_serializing_if = "Option::is_none")]
310        version: Option<String>,
311        #[serde(skip_serializing_if = "Option::is_none")]
312        config: Option<Value>,
313        #[serde(skip_serializing_if = "Option::is_none")]
314        args: Option<Value>,
315        #[serde(skip_serializing_if = "Option::is_none")]
316        env: Option<Value>,
317        #[serde(skip_serializing_if = "Option::is_none")]
318        chain: Option<String>,
319        trace: Trace,
320    },
321}
322
323fn is_false(b: &bool) -> bool {
324    !*b
325}
326
327#[derive(Debug, Serialize, Clone)]
328pub struct ColumnInfo {
329    pub name: String,
330    #[serde(rename = "type")]
331    pub type_name: String,
332}
333
334#[derive(Debug, Serialize, Clone)]
335pub struct Trace {
336    pub duration_ms: u64,
337    #[serde(skip_serializing_if = "Option::is_none")]
338    pub row_count: Option<usize>,
339    #[serde(skip_serializing_if = "Option::is_none")]
340    pub payload_bytes: Option<usize>,
341}
342
343impl Trace {
344    pub fn only_duration(duration_ms: u64) -> Self {
345        Self {
346            duration_ms,
347            row_count: None,
348            payload_bytes: None,
349        }
350    }
351}
352
353#[derive(Debug, Serialize)]
354pub struct PongTrace {
355    pub uptime_s: u64,
356    pub requests_total: u64,
357    pub in_flight: usize,
358}
359
360#[derive(Debug, Serialize)]
361pub struct CloseTrace {
362    pub uptime_s: u64,
363    pub requests_total: u64,
364}
365
366#[derive(Debug, Clone, Default)]
367pub struct SessionConfig {
368    pub dsn_secret: Option<String>,
369    pub conninfo_secret: Option<String>,
370    pub host: Option<String>,
371    pub port: Option<u16>,
372    pub user: Option<String>,
373    pub dbname: Option<String>,
374    pub password_secret: Option<String>,
375    pub ssh: SshConfig,
376    pub container: ContainerConfig,
377    /// Set only when an administrator-locked readonly profile supplied this
378    /// session, meaning the endpoint is the administrator's and environment
379    /// variables must not redirect it.
380    ///
381    /// Deliberately absent from `SessionConfigFlat`, so it can never be set by
382    /// profile JSON or by a pipe session patch — only by the code path that
383    /// loads a locked profile.
384    pub profile_pinned: bool,
385}
386
387#[derive(Serialize)]
388struct SafeSessionConfig<'a> {
389    #[serde(skip_serializing_if = "Option::is_none")]
390    dsn_secret: Option<&'static str>,
391    #[serde(skip_serializing_if = "Option::is_none")]
392    conninfo_secret: Option<&'static str>,
393    #[serde(skip_serializing_if = "Option::is_none")]
394    host: Option<&'a str>,
395    #[serde(skip_serializing_if = "Option::is_none")]
396    port: Option<u16>,
397    #[serde(skip_serializing_if = "Option::is_none")]
398    user: Option<&'a str>,
399    #[serde(skip_serializing_if = "Option::is_none")]
400    dbname: Option<&'a str>,
401    #[serde(skip_serializing_if = "Option::is_none")]
402    password_secret: Option<&'static str>,
403    #[serde(flatten)]
404    ssh: &'a SshConfig,
405    #[serde(flatten)]
406    container: &'a ContainerConfig,
407}
408
409impl Serialize for SessionConfig {
410    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
411    where
412        S: serde::Serializer,
413    {
414        SafeSessionConfig {
415            dsn_secret: self.dsn_secret.as_ref().map(|_| "***"),
416            conninfo_secret: self.conninfo_secret.as_ref().map(|_| "***"),
417            host: self.host.as_deref(),
418            port: self.port,
419            user: self.user.as_deref(),
420            dbname: self.dbname.as_deref(),
421            password_secret: self.password_secret.as_ref().map(|_| "***"),
422            ssh: &self.ssh,
423            container: &self.container,
424        }
425        .serialize(serializer)
426    }
427}
428
429#[derive(Debug, Serialize, Deserialize, Clone, Default)]
430pub struct SshConfig {
431    #[serde(rename = "ssh", skip_serializing_if = "Option::is_none")]
432    pub destination: Option<String>,
433    #[serde(rename = "ssh_via", default, skip_serializing_if = "Vec::is_empty")]
434    pub via: Vec<String>,
435    #[serde(rename = "ssh_options", default, skip_serializing_if = "Vec::is_empty")]
436    pub options: Vec<String>,
437    #[serde(rename = "ssh_local_host", skip_serializing_if = "Option::is_none")]
438    pub local_host: Option<String>,
439    #[serde(rename = "ssh_local_port", skip_serializing_if = "Option::is_none")]
440    pub local_port: Option<u16>,
441    #[serde(rename = "ssh_remote_socket", skip_serializing_if = "Option::is_none")]
442    pub remote_socket: Option<String>,
443    #[serde(rename = "ssh_sudo_user", skip_serializing_if = "Option::is_none")]
444    pub sudo_user: Option<String>,
445}
446
447impl SshConfig {
448    pub fn has_transport_fields(&self) -> bool {
449        self.destination.is_some()
450            || !self.via.is_empty()
451            || !self.options.is_empty()
452            || self.local_host.is_some()
453            || self.local_port.is_some()
454            || self.remote_socket.is_some()
455            || self.sudo_user.is_some()
456    }
457
458    pub fn has_tunnel_or_bridge_options(&self) -> bool {
459        self.local_host.is_some()
460            || self.local_port.is_some()
461            || !self.via.is_empty()
462            || self.remote_socket.is_some()
463            || self.sudo_user.is_some()
464    }
465}
466
467/// The five container exec drivers.
468///
469/// A driver is never named directly. It is inferred from which
470/// `--container-<driver>-*` flag family (or matching session field family) the
471/// caller used, so an option a driver cannot express has no spelling at all
472/// rather than a runtime rejection.
473#[derive(Debug, Clone, Copy, PartialEq, Eq)]
474pub enum ContainerDriver {
475    Docker,
476    Podman,
477    Nerdctl,
478    Compose,
479    Kubectl,
480}
481
482impl ContainerDriver {
483    pub fn as_str(self) -> &'static str {
484        match self {
485            Self::Docker => "docker",
486            Self::Podman => "podman",
487            Self::Nerdctl => "nerdctl",
488            Self::Compose => "compose",
489            Self::Kubectl => "kubectl",
490        }
491    }
492
493    /// The command a driver execs through when the caller sets no runtime.
494    pub fn default_runtime(self) -> &'static str {
495        match self {
496            Self::Docker | Self::Compose => "docker",
497            Self::Podman => "podman",
498            Self::Nerdctl => "nerdctl",
499            Self::Kubectl => "kubectl",
500        }
501    }
502
503    /// The flag that names this driver's exec target.
504    pub fn target_flag(self) -> &'static str {
505        match self {
506            Self::Docker => "--container-docker-name",
507            Self::Podman => "--container-podman-name",
508            Self::Nerdctl => "--container-nerdctl-name",
509            Self::Compose => "--container-compose-service",
510            Self::Kubectl => "--container-kubectl-pod",
511        }
512    }
513
514    /// How to spell this driver's whole flag family in a message.
515    pub fn flag_family(self) -> &'static str {
516        match self {
517            Self::Docker => "--container-docker-*",
518            Self::Podman => "--container-podman-*",
519            Self::Nerdctl => "--container-nerdctl-*",
520            Self::Compose => "--container-compose-*",
521            Self::Kubectl => "--container-kubectl-*",
522        }
523    }
524}
525
526/// One flag family per driver, one field per (driver, option) pair.
527///
528/// Which options a driver supports is expressed by the field names themselves:
529/// there is no `kubectl_user` because `kubectl exec` has no exec-as-user
530/// option, and no `podman_context` because Podman has no context selection.
531/// The only combination left to check is that a caller stayed inside one family.
532#[derive(Debug, Serialize, Deserialize, Clone, Default)]
533pub struct ContainerConfig {
534    #[serde(skip_serializing_if = "Option::is_none")]
535    pub docker_name: Option<String>,
536    #[serde(skip_serializing_if = "Option::is_none")]
537    pub docker_user: Option<String>,
538    #[serde(skip_serializing_if = "Option::is_none")]
539    pub docker_context: Option<String>,
540    #[serde(skip_serializing_if = "Option::is_none")]
541    pub docker_runtime: Option<String>,
542    #[serde(skip_serializing_if = "Option::is_none")]
543    pub podman_name: Option<String>,
544    #[serde(skip_serializing_if = "Option::is_none")]
545    pub podman_user: Option<String>,
546    #[serde(skip_serializing_if = "Option::is_none")]
547    pub podman_runtime: Option<String>,
548    #[serde(skip_serializing_if = "Option::is_none")]
549    pub nerdctl_name: Option<String>,
550    #[serde(skip_serializing_if = "Option::is_none")]
551    pub nerdctl_user: Option<String>,
552    #[serde(skip_serializing_if = "Option::is_none")]
553    pub nerdctl_runtime: Option<String>,
554    #[serde(skip_serializing_if = "Option::is_none")]
555    pub compose_service: Option<String>,
556    #[serde(skip_serializing_if = "Option::is_none")]
557    pub compose_user: Option<String>,
558    #[serde(default, skip_serializing_if = "Vec::is_empty")]
559    pub compose_files: Vec<String>,
560    #[serde(skip_serializing_if = "Option::is_none")]
561    pub compose_project: Option<String>,
562    #[serde(skip_serializing_if = "Option::is_none")]
563    pub compose_runtime: Option<String>,
564    #[serde(skip_serializing_if = "Option::is_none")]
565    pub kubectl_pod: Option<String>,
566    #[serde(skip_serializing_if = "Option::is_none")]
567    pub kubectl_container: Option<String>,
568    #[serde(skip_serializing_if = "Option::is_none")]
569    pub kubectl_namespace: Option<String>,
570    #[serde(skip_serializing_if = "Option::is_none")]
571    pub kubectl_context: Option<String>,
572    #[serde(skip_serializing_if = "Option::is_none")]
573    pub kubectl_runtime: Option<String>,
574}
575
576impl ContainerConfig {
577    pub fn has_transport_fields(&self) -> bool {
578        self.family_flags().iter().any(|(_, flag)| flag.is_some())
579    }
580
581    /// Which driver family the caller used, or `None` for no container
582    /// transport at all.
583    ///
584    /// Two families at once is the one combination this surface still has to
585    /// reject, and the error names both offending flags.
586    pub fn selected_driver(&self) -> Result<Option<ContainerDriver>, String> {
587        let mut selected: Option<(ContainerDriver, &'static str)> = None;
588        for (driver, flag) in self.family_flags() {
589            let Some(flag) = flag else { continue };
590            match selected {
591                None => selected = Some((driver, flag)),
592                Some((_, first)) => {
593                    return Err(format!(
594                        "{first} cannot be combined with {flag}; each container driver has its own flag family"
595                    ));
596                }
597            }
598        }
599        Ok(selected.map(|(driver, _)| driver))
600    }
601
602    /// The container, service, or pod this session execs into, whichever family
603    /// named it.
604    pub fn target_name(&self) -> Option<&str> {
605        self.docker_name
606            .as_deref()
607            .or(self.podman_name.as_deref())
608            .or(self.nerdctl_name.as_deref())
609            .or(self.compose_service.as_deref())
610            .or(self.kubectl_pod.as_deref())
611    }
612
613    /// The runtime command override, whichever family named it.
614    pub fn runtime_override(&self) -> Option<&str> {
615        self.docker_runtime
616            .as_deref()
617            .or(self.podman_runtime.as_deref())
618            .or(self.nerdctl_runtime.as_deref())
619            .or(self.compose_runtime.as_deref())
620            .or(self.kubectl_runtime.as_deref())
621    }
622
623    /// For each driver, the first flag of its family the caller set.
624    fn family_flags(&self) -> [(ContainerDriver, Option<&'static str>); 5] {
625        [
626            (
627                ContainerDriver::Docker,
628                first_present(&[
629                    ("--container-docker-name", self.docker_name.is_some()),
630                    ("--container-docker-user", self.docker_user.is_some()),
631                    ("--container-docker-context", self.docker_context.is_some()),
632                    ("--container-docker-runtime", self.docker_runtime.is_some()),
633                ]),
634            ),
635            (
636                ContainerDriver::Podman,
637                first_present(&[
638                    ("--container-podman-name", self.podman_name.is_some()),
639                    ("--container-podman-user", self.podman_user.is_some()),
640                    ("--container-podman-runtime", self.podman_runtime.is_some()),
641                ]),
642            ),
643            (
644                ContainerDriver::Nerdctl,
645                first_present(&[
646                    ("--container-nerdctl-name", self.nerdctl_name.is_some()),
647                    ("--container-nerdctl-user", self.nerdctl_user.is_some()),
648                    (
649                        "--container-nerdctl-runtime",
650                        self.nerdctl_runtime.is_some(),
651                    ),
652                ]),
653            ),
654            (
655                ContainerDriver::Compose,
656                first_present(&[
657                    (
658                        "--container-compose-service",
659                        self.compose_service.is_some(),
660                    ),
661                    ("--container-compose-user", self.compose_user.is_some()),
662                    ("--container-compose-file", !self.compose_files.is_empty()),
663                    (
664                        "--container-compose-project",
665                        self.compose_project.is_some(),
666                    ),
667                    (
668                        "--container-compose-runtime",
669                        self.compose_runtime.is_some(),
670                    ),
671                ]),
672            ),
673            (
674                ContainerDriver::Kubectl,
675                first_present(&[
676                    ("--container-kubectl-pod", self.kubectl_pod.is_some()),
677                    (
678                        "--container-kubectl-container",
679                        self.kubectl_container.is_some(),
680                    ),
681                    (
682                        "--container-kubectl-namespace",
683                        self.kubectl_namespace.is_some(),
684                    ),
685                    (
686                        "--container-kubectl-context",
687                        self.kubectl_context.is_some(),
688                    ),
689                    (
690                        "--container-kubectl-runtime",
691                        self.kubectl_runtime.is_some(),
692                    ),
693                ]),
694            ),
695        ]
696    }
697}
698
699fn first_present(fields: &[(&'static str, bool)]) -> Option<&'static str> {
700    fields
701        .iter()
702        .find(|(_, present)| *present)
703        .map(|(flag, _)| *flag)
704}
705
706#[derive(Debug, Deserialize, Default)]
707#[serde(deny_unknown_fields)]
708struct SessionConfigFlat {
709    #[serde(default)]
710    dsn_secret: Option<String>,
711    #[serde(default)]
712    conninfo_secret: Option<String>,
713    #[serde(default)]
714    host: Option<String>,
715    #[serde(default)]
716    port: Option<u16>,
717    #[serde(default)]
718    user: Option<String>,
719    #[serde(default)]
720    dbname: Option<String>,
721    #[serde(default)]
722    password_secret: Option<String>,
723    #[serde(default)]
724    ssh: Option<String>,
725    #[serde(default)]
726    ssh_via: Vec<String>,
727    #[serde(default)]
728    ssh_options: Vec<String>,
729    #[serde(default)]
730    ssh_local_host: Option<String>,
731    #[serde(default)]
732    ssh_local_port: Option<u16>,
733    #[serde(default)]
734    ssh_remote_socket: Option<String>,
735    #[serde(default)]
736    ssh_sudo_user: Option<String>,
737    #[serde(default)]
738    docker_name: Option<String>,
739    #[serde(default)]
740    docker_user: Option<String>,
741    #[serde(default)]
742    docker_context: Option<String>,
743    #[serde(default)]
744    docker_runtime: Option<String>,
745    #[serde(default)]
746    podman_name: Option<String>,
747    #[serde(default)]
748    podman_user: Option<String>,
749    #[serde(default)]
750    podman_runtime: Option<String>,
751    #[serde(default)]
752    nerdctl_name: Option<String>,
753    #[serde(default)]
754    nerdctl_user: Option<String>,
755    #[serde(default)]
756    nerdctl_runtime: Option<String>,
757    #[serde(default)]
758    compose_service: Option<String>,
759    #[serde(default)]
760    compose_user: Option<String>,
761    #[serde(default)]
762    compose_files: Vec<String>,
763    #[serde(default)]
764    compose_project: Option<String>,
765    #[serde(default)]
766    compose_runtime: Option<String>,
767    #[serde(default)]
768    kubectl_pod: Option<String>,
769    #[serde(default)]
770    kubectl_container: Option<String>,
771    #[serde(default)]
772    kubectl_namespace: Option<String>,
773    #[serde(default)]
774    kubectl_context: Option<String>,
775    #[serde(default)]
776    kubectl_runtime: Option<String>,
777}
778
779impl From<SessionConfigFlat> for SessionConfig {
780    fn from(flat: SessionConfigFlat) -> Self {
781        Self {
782            profile_pinned: false,
783            dsn_secret: flat.dsn_secret,
784            conninfo_secret: flat.conninfo_secret,
785            host: flat.host,
786            port: flat.port,
787            user: flat.user,
788            dbname: flat.dbname,
789            password_secret: flat.password_secret,
790            ssh: SshConfig {
791                destination: flat.ssh,
792                via: flat.ssh_via,
793                options: flat.ssh_options,
794                local_host: flat.ssh_local_host,
795                local_port: flat.ssh_local_port,
796                remote_socket: flat.ssh_remote_socket,
797                sudo_user: flat.ssh_sudo_user,
798            },
799            container: ContainerConfig {
800                docker_name: flat.docker_name,
801                docker_user: flat.docker_user,
802                docker_context: flat.docker_context,
803                docker_runtime: flat.docker_runtime,
804                podman_name: flat.podman_name,
805                podman_user: flat.podman_user,
806                podman_runtime: flat.podman_runtime,
807                nerdctl_name: flat.nerdctl_name,
808                nerdctl_user: flat.nerdctl_user,
809                nerdctl_runtime: flat.nerdctl_runtime,
810                compose_service: flat.compose_service,
811                compose_user: flat.compose_user,
812                compose_files: flat.compose_files,
813                compose_project: flat.compose_project,
814                compose_runtime: flat.compose_runtime,
815                kubectl_pod: flat.kubectl_pod,
816                kubectl_container: flat.kubectl_container,
817                kubectl_namespace: flat.kubectl_namespace,
818                kubectl_context: flat.kubectl_context,
819                kubectl_runtime: flat.kubectl_runtime,
820            },
821        }
822    }
823}
824
825impl<'de> Deserialize<'de> for SessionConfig {
826    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
827    where
828        D: serde::Deserializer<'de>,
829    {
830        SessionConfigFlat::deserialize(deserializer).map(Self::from)
831    }
832}
833
834impl SessionConfig {
835    pub fn uses_ssh_transport(&self) -> bool {
836        self.ssh.has_transport_fields()
837    }
838
839    pub fn uses_container_transport(&self) -> bool {
840        self.container.has_transport_fields()
841    }
842
843    pub fn transport_kind(&self) -> Result<TransportKind, String> {
844        // Two driver families name two drivers, so the session has no container
845        // transport to select until one of them goes. Rejected here so a pipe
846        // session fails on the request rather than at connect time.
847        self.container.selected_driver()?;
848        let uses_ssh = self.uses_ssh_transport();
849        let uses_container = self.uses_container_transport();
850        match (uses_ssh, uses_container) {
851            (false, false) => Ok(TransportKind::Direct),
852            (true, false) => Ok(TransportKind::Ssh),
853            (false, true) => Ok(TransportKind::Container),
854            // --ssh plus a container driver family means "run container exec on
855            // that remote host". The PostgreSQL connection still crosses the
856            // container boundary.
857            (true, true) => Ok(TransportKind::Container),
858        }
859    }
860}
861
862#[derive(Debug, Clone, Copy, PartialEq, Eq)]
863pub enum TransportKind {
864    Direct,
865    Ssh,
866    Container,
867}
868
869impl TransportKind {
870    pub fn as_str(self) -> &'static str {
871        match self {
872            Self::Direct => "direct",
873            Self::Ssh => "ssh",
874            Self::Container => "container",
875        }
876    }
877}
878
879#[derive(Debug, Serialize, Deserialize, Clone)]
880pub struct RuntimeConfig {
881    pub default_session: String,
882    #[serde(default)]
883    pub sessions: HashMap<String, SessionConfig>,
884    pub inline_max_rows: usize,
885    pub inline_max_bytes: usize,
886    pub statement_timeout_ms: u64,
887    pub lock_timeout_ms: u64,
888    #[serde(default, with = "log_filters_serde")]
889    pub log: LogFilters,
890}
891
892impl Default for RuntimeConfig {
893    fn default() -> Self {
894        let mut sessions = HashMap::new();
895        sessions.insert("default".to_string(), SessionConfig::default());
896        Self {
897            default_session: "default".to_string(),
898            sessions,
899            inline_max_rows: 1000,
900            inline_max_bytes: 1_048_576,
901            statement_timeout_ms: 30_000,
902            lock_timeout_ms: 5_000,
903            log: LogFilters::default(),
904        }
905    }
906}
907
908#[derive(Debug, Deserialize, Default)]
909#[serde(deny_unknown_fields)]
910pub struct ConfigPatch {
911    pub default_session: Option<String>,
912    pub sessions: Option<HashMap<String, SessionConfigPatch>>,
913    pub inline_max_rows: Option<usize>,
914    pub inline_max_bytes: Option<usize>,
915    pub statement_timeout_ms: Option<u64>,
916    pub lock_timeout_ms: Option<u64>,
917    pub log: Option<Vec<String>>,
918}
919
920#[derive(Debug, Default)]
921pub enum PatchField<T> {
922    #[default]
923    Missing,
924    Null,
925    Value(T),
926}
927
928impl<'de, T> Deserialize<'de> for PatchField<T>
929where
930    T: Deserialize<'de>,
931{
932    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
933    where
934        D: serde::Deserializer<'de>,
935    {
936        let value = Option::<T>::deserialize(deserializer)?;
937        match value {
938            Some(value) => Ok(Self::Value(value)),
939            None => Ok(Self::Null),
940        }
941    }
942}
943
944impl<T> PatchField<T> {
945    pub fn into_update(self) -> Option<Option<T>> {
946        match self {
947            Self::Missing => None,
948            Self::Null => Some(None),
949            Self::Value(value) => Some(Some(value)),
950        }
951    }
952}
953
954#[derive(Debug, Default)]
955pub struct SessionConfigPatch {
956    pub dsn_secret: PatchField<String>,
957    pub conninfo_secret: PatchField<String>,
958    pub host: PatchField<String>,
959    pub port: PatchField<u16>,
960    pub user: PatchField<String>,
961    pub dbname: PatchField<String>,
962    pub password_secret: PatchField<String>,
963    pub ssh: SshConfigPatch,
964    pub container: ContainerConfigPatch,
965}
966
967#[derive(Debug, Default)]
968pub struct SshConfigPatch {
969    pub destination: PatchField<String>,
970    pub via: PatchField<Vec<String>>,
971    pub options: PatchField<Vec<String>>,
972    pub local_host: PatchField<String>,
973    pub local_port: PatchField<u16>,
974    pub remote_socket: PatchField<String>,
975    pub sudo_user: PatchField<String>,
976}
977
978#[derive(Debug, Default)]
979pub struct ContainerConfigPatch {
980    pub docker_name: PatchField<String>,
981    pub docker_user: PatchField<String>,
982    pub docker_context: PatchField<String>,
983    pub docker_runtime: PatchField<String>,
984    pub podman_name: PatchField<String>,
985    pub podman_user: PatchField<String>,
986    pub podman_runtime: PatchField<String>,
987    pub nerdctl_name: PatchField<String>,
988    pub nerdctl_user: PatchField<String>,
989    pub nerdctl_runtime: PatchField<String>,
990    pub compose_service: PatchField<String>,
991    pub compose_user: PatchField<String>,
992    pub compose_files: PatchField<Vec<String>>,
993    pub compose_project: PatchField<String>,
994    pub compose_runtime: PatchField<String>,
995    pub kubectl_pod: PatchField<String>,
996    pub kubectl_container: PatchField<String>,
997    pub kubectl_namespace: PatchField<String>,
998    pub kubectl_context: PatchField<String>,
999    pub kubectl_runtime: PatchField<String>,
1000}
1001
1002#[derive(Debug, Deserialize, Default)]
1003#[serde(deny_unknown_fields)]
1004struct SessionConfigPatchFlat {
1005    #[serde(default)]
1006    dsn_secret: PatchField<String>,
1007    #[serde(default)]
1008    conninfo_secret: PatchField<String>,
1009    #[serde(default)]
1010    host: PatchField<String>,
1011    #[serde(default)]
1012    port: PatchField<u16>,
1013    #[serde(default)]
1014    user: PatchField<String>,
1015    #[serde(default)]
1016    dbname: PatchField<String>,
1017    #[serde(default)]
1018    password_secret: PatchField<String>,
1019    #[serde(default)]
1020    ssh: PatchField<String>,
1021    #[serde(default)]
1022    ssh_via: PatchField<Vec<String>>,
1023    #[serde(default)]
1024    ssh_options: PatchField<Vec<String>>,
1025    #[serde(default)]
1026    ssh_local_host: PatchField<String>,
1027    #[serde(default)]
1028    ssh_local_port: PatchField<u16>,
1029    #[serde(default)]
1030    ssh_remote_socket: PatchField<String>,
1031    #[serde(default)]
1032    ssh_sudo_user: PatchField<String>,
1033    #[serde(default)]
1034    docker_name: PatchField<String>,
1035    #[serde(default)]
1036    docker_user: PatchField<String>,
1037    #[serde(default)]
1038    docker_context: PatchField<String>,
1039    #[serde(default)]
1040    docker_runtime: PatchField<String>,
1041    #[serde(default)]
1042    podman_name: PatchField<String>,
1043    #[serde(default)]
1044    podman_user: PatchField<String>,
1045    #[serde(default)]
1046    podman_runtime: PatchField<String>,
1047    #[serde(default)]
1048    nerdctl_name: PatchField<String>,
1049    #[serde(default)]
1050    nerdctl_user: PatchField<String>,
1051    #[serde(default)]
1052    nerdctl_runtime: PatchField<String>,
1053    #[serde(default)]
1054    compose_service: PatchField<String>,
1055    #[serde(default)]
1056    compose_user: PatchField<String>,
1057    #[serde(default)]
1058    compose_files: PatchField<Vec<String>>,
1059    #[serde(default)]
1060    compose_project: PatchField<String>,
1061    #[serde(default)]
1062    compose_runtime: PatchField<String>,
1063    #[serde(default)]
1064    kubectl_pod: PatchField<String>,
1065    #[serde(default)]
1066    kubectl_container: PatchField<String>,
1067    #[serde(default)]
1068    kubectl_namespace: PatchField<String>,
1069    #[serde(default)]
1070    kubectl_context: PatchField<String>,
1071    #[serde(default)]
1072    kubectl_runtime: PatchField<String>,
1073}
1074
1075impl From<SessionConfigPatchFlat> for SessionConfigPatch {
1076    fn from(flat: SessionConfigPatchFlat) -> Self {
1077        Self {
1078            dsn_secret: flat.dsn_secret,
1079            conninfo_secret: flat.conninfo_secret,
1080            host: flat.host,
1081            port: flat.port,
1082            user: flat.user,
1083            dbname: flat.dbname,
1084            password_secret: flat.password_secret,
1085            ssh: SshConfigPatch {
1086                destination: flat.ssh,
1087                via: flat.ssh_via,
1088                options: flat.ssh_options,
1089                local_host: flat.ssh_local_host,
1090                local_port: flat.ssh_local_port,
1091                remote_socket: flat.ssh_remote_socket,
1092                sudo_user: flat.ssh_sudo_user,
1093            },
1094            container: ContainerConfigPatch {
1095                docker_name: flat.docker_name,
1096                docker_user: flat.docker_user,
1097                docker_context: flat.docker_context,
1098                docker_runtime: flat.docker_runtime,
1099                podman_name: flat.podman_name,
1100                podman_user: flat.podman_user,
1101                podman_runtime: flat.podman_runtime,
1102                nerdctl_name: flat.nerdctl_name,
1103                nerdctl_user: flat.nerdctl_user,
1104                nerdctl_runtime: flat.nerdctl_runtime,
1105                compose_service: flat.compose_service,
1106                compose_user: flat.compose_user,
1107                compose_files: flat.compose_files,
1108                compose_project: flat.compose_project,
1109                compose_runtime: flat.compose_runtime,
1110                kubectl_pod: flat.kubectl_pod,
1111                kubectl_container: flat.kubectl_container,
1112                kubectl_namespace: flat.kubectl_namespace,
1113                kubectl_context: flat.kubectl_context,
1114                kubectl_runtime: flat.kubectl_runtime,
1115            },
1116        }
1117    }
1118}
1119
1120impl<'de> Deserialize<'de> for SessionConfigPatch {
1121    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
1122    where
1123        D: serde::Deserializer<'de>,
1124    {
1125        SessionConfigPatchFlat::deserialize(deserializer).map(Self::from)
1126    }
1127}
1128
1129#[derive(Debug, Clone)]
1130pub struct ResolvedOptions {
1131    pub stream_rows: bool,
1132    pub batch_rows: usize,
1133    pub batch_bytes: usize,
1134    pub statement_timeout_ms: u64,
1135    pub lock_timeout_ms: u64,
1136    pub read_only: bool,
1137    pub inline_max_rows: usize,
1138    pub inline_max_bytes: usize,
1139}
1140
1141#[cfg(test)]
1142#[path = "../tests/support/unit_types.rs"]
1143mod tests;