Skip to main content

agent_first_psql/
cli.rs

1use std::io::Read;
2
3use crate::limits::{MAX_PARAMS, MAX_SQL_BYTES};
4use crate::secret_config::{SecretConfigRef, resolve_config_secret};
5use crate::types::{ContainerConfig, Permission, QueryOptions, SessionConfig, SshConfig};
6use agent_first_data::{
7    ArgSpec, BuiltCliSpec, CliOutcome, CliSpec, CliSpecError, CliValue, Combination, CommandSpec,
8    LogFilters, OutputFormat, OutputPlan, OutputSpec, OutputTo, ResolvedInvocation,
9    build_afdata_cli, cli_parse_log_filters, cli_parse_output,
10};
11use serde_json::{Value, json};
12use std::collections::{BTreeMap, btree_map::Entry};
13
14const STARTUP_ENV_KEYS: &[&str] = &[
15    "AFPSQL_DSN_SECRET",
16    "AFPSQL_CONNINFO_SECRET",
17    "AFPSQL_HOST",
18    "AFPSQL_PORT",
19    "AFPSQL_USER",
20    "AFPSQL_DBNAME",
21    "AFPSQL_PASSWORD_SECRET",
22    "AFPSQL_SSH",
23    "AFPSQL_SSH_VIA",
24    "AFPSQL_SSH_REMOTE_SOCKET",
25    "AFPSQL_SSH_SUDO_USER",
26    "AFPSQL_CONTAINER_DOCKER_NAME",
27    "AFPSQL_CONTAINER_DOCKER_USER",
28    "AFPSQL_CONTAINER_DOCKER_CONTEXT",
29    "AFPSQL_CONTAINER_DOCKER_RUNTIME",
30    "AFPSQL_CONTAINER_PODMAN_NAME",
31    "AFPSQL_CONTAINER_PODMAN_USER",
32    "AFPSQL_CONTAINER_PODMAN_RUNTIME",
33    "AFPSQL_CONTAINER_NERDCTL_NAME",
34    "AFPSQL_CONTAINER_NERDCTL_USER",
35    "AFPSQL_CONTAINER_NERDCTL_RUNTIME",
36    "AFPSQL_CONTAINER_COMPOSE_SERVICE",
37    "AFPSQL_CONTAINER_COMPOSE_USER",
38    "AFPSQL_CONTAINER_COMPOSE_FILE",
39    "AFPSQL_CONTAINER_COMPOSE_PROJECT",
40    "AFPSQL_CONTAINER_COMPOSE_RUNTIME",
41    "AFPSQL_CONTAINER_KUBECTL_POD",
42    "AFPSQL_CONTAINER_KUBECTL_CONTAINER",
43    "AFPSQL_CONTAINER_KUBECTL_NAMESPACE",
44    "AFPSQL_CONTAINER_KUBECTL_CONTEXT",
45    "AFPSQL_CONTAINER_KUBECTL_RUNTIME",
46    "PGHOST",
47    "PGPORT",
48    "PGUSER",
49    "PGDATABASE",
50    "PGPASSWORD",
51    "PGSSLMODE",
52];
53
54pub enum Mode {
55    Cli(CliRequest),
56    Pipe(PipeInit),
57    PsqlAdmin(PsqlAdminRequest),
58    SkillAdmin(SkillAdminRequest),
59    PsqlUnsupported(PsqlUnsupportedRequest),
60}
61
62pub struct PipeInit {
63    pub output: OutputFormat,
64    pub session: SessionConfig,
65    pub log: LogFilters,
66    pub startup_args: Value,
67    pub startup_env: Value,
68    pub startup_requested: bool,
69}
70
71#[derive(Debug, Clone)]
72pub struct PsqlAdminRequest {
73    pub action: PsqlAdminAction,
74    pub output: OutputFormat,
75}
76
77#[derive(Debug, Clone)]
78pub enum PsqlAdminAction {
79    Status { bin_dir: Option<String> },
80    Install { bin_dir: Option<String> },
81    Uninstall { bin_dir: Option<String> },
82}
83
84#[derive(Debug, Clone)]
85pub struct SkillAdminRequest {
86    pub action: SkillAdminAction,
87    pub output: OutputFormat,
88}
89
90#[derive(Debug, Clone)]
91pub enum SkillAdminAction {
92    Status(SkillAdminOptions),
93    Install(SkillAdminOptions),
94    Uninstall(SkillAdminOptions),
95}
96
97#[derive(Debug, Clone)]
98pub struct SkillAdminOptions {
99    pub agent: SkillAgentSelection,
100    pub scope: SkillScope,
101    pub skills_dir: Option<String>,
102    pub force: bool,
103}
104
105#[derive(Clone, Copy, Debug, PartialEq, Eq)]
106pub enum SkillAgentSelection {
107    /// Manage every agent that supports the requested scope.
108    All,
109    /// Manage the Codex local skill under $CODEX_HOME/skills.
110    Codex,
111    /// Manage the Claude Code skill under ~/.claude/skills or .claude/skills.
112    ClaudeCode,
113    /// Manage the opencode skill under ~/.config/opencode/skills or .opencode/skills.
114    Opencode,
115    /// Manage the Hermes skill under $HERMES_HOME/skills or ~/.hermes/skills.
116    Hermes,
117}
118
119#[derive(Clone, Copy, Debug, PartialEq, Eq)]
120pub enum SkillScope {
121    /// Install under the user-level skills directory.
122    Personal,
123    /// Install under the current workspace's skills directory.
124    Workspace,
125}
126
127pub struct CliRequest {
128    pub sql: String,
129    pub params: Vec<Value>,
130    pub options: QueryOptions,
131    pub session: SessionConfig,
132    pub output: OutputFormat,
133    pub log: LogFilters,
134    pub startup_args: Value,
135    pub startup_env: Value,
136    pub startup_requested: bool,
137    pub dry_run: bool,
138    pub psql_mode: bool,
139}
140
141pub struct PsqlUnsupportedRequest {
142    pub reason: String,
143}
144
145/// Schema-discovery request, already narrowed to the shape that matched.
146pub enum InspectAction {
147    Databases(InspectDatabasesArgs),
148    Database,
149    Schemas,
150    Schema(InspectSchemaArgs),
151    Snapshot(InspectSchemaArgs),
152    Tables(InspectTablesArgs),
153    Views(InspectViewsArgs),
154    Indexes(InspectIndexesArgs),
155    Table(InspectTableArgs),
156}
157
158pub struct InspectDatabasesArgs {
159    pub all: bool,
160}
161
162pub struct InspectTablesArgs {
163    pub schema: String,
164    pub like: Option<String>,
165}
166
167pub struct InspectSchemaArgs {
168    pub schema: String,
169    pub like: Option<String>,
170}
171
172pub struct InspectViewsArgs {
173    pub schema: String,
174    pub like: Option<String>,
175}
176
177pub struct InspectIndexesArgs {
178    pub schema: String,
179    pub table: Option<String>,
180    pub stats: bool,
181}
182
183pub struct InspectTableArgs {
184    pub name: String,
185    pub full: bool,
186}
187
188/// One structural or argument-shaped rejection, decided before anything ran.
189///
190/// `code` is the classification an agent branches on: the parser's own
191/// `cli_*` codes when the registry decided, and `invalid_request` when the
192/// failure is about the world the arguments name (an unreadable file, an unset
193/// variable) rather than the arguments themselves.
194#[derive(Debug, Clone, PartialEq, Eq)]
195pub struct ParseError {
196    pub code: String,
197    pub message: String,
198    pub hint: Option<String>,
199}
200
201impl ParseError {
202    pub fn new(code: &str, message: impl Into<String>) -> Self {
203        Self {
204            code: code.to_string(),
205            message: message.into(),
206            hint: None,
207        }
208    }
209
210    pub fn hint(mut self, hint: impl Into<String>) -> Self {
211        self.hint = Some(hint.into());
212        self
213    }
214
215    /// A failure about the named world, not about the argv shape.
216    fn invalid_request(message: impl Into<String>) -> Self {
217        Self::new(crate::protocol::error_code::INVALID_REQUEST, message)
218    }
219
220    /// The parser's own classification for a value it could not accept. Reused
221    /// rather than duplicated: the registry has no single-character, unsigned,
222    /// or `N=value` type, so those checks land here and must not invent a
223    /// second spelling for what is the same rejection.
224    fn invalid_value(message: impl Into<String>) -> Self {
225        Self::new("cli_invalid_argument_value", message)
226    }
227}
228
229impl From<String> for ParseError {
230    fn from(message: String) -> Self {
231        Self::invalid_request(message)
232    }
233}
234
235/// What `parse_args` resolved, plus the stream redirection it installed.
236///
237/// The guard belongs to the caller: dropping it restores the process fds, so
238/// it has to outlive every event the selected mode is about to write.
239pub struct Parsed {
240    pub mode: Mode,
241    pub redirect: Option<agent_first_data::stream_redirect::InstalledStreamRedirect>,
242}
243
244const PERMISSIONS: [&str; 6] = [
245    "read",
246    "write",
247    "ssh-read",
248    "ssh-write",
249    "container-read",
250    "container-write",
251];
252
253/// Every argument that can open a PostgreSQL session, plus `--log`.
254///
255/// A closed registry has no global arguments: an argument belongs to the
256/// command that accepts it, which is what makes one `<command> --help` a
257/// complete answer instead of a pointer to a parent. This is that one list,
258/// applied to each command that can open a session.
259const CONNECTION_IDS: [&str; 33] = [
260    "dsn",
261    "conninfo",
262    "host",
263    "port",
264    "user",
265    "dbname",
266    "password",
267    "ssh",
268    "ssh_via",
269    "ssh_option",
270    "ssh_remote_socket",
271    "ssh_sudo_user",
272    "container_docker_name",
273    "container_docker_user",
274    "container_docker_context",
275    "container_docker_runtime",
276    "container_podman_name",
277    "container_podman_user",
278    "container_podman_runtime",
279    "container_nerdctl_name",
280    "container_nerdctl_user",
281    "container_nerdctl_runtime",
282    "container_compose_service",
283    "container_compose_user",
284    "container_compose_file",
285    "container_compose_project",
286    "container_compose_runtime",
287    "container_kubectl_pod",
288    "container_kubectl_container",
289    "container_kubectl_namespace",
290    "container_kubectl_context",
291    "container_kubectl_runtime",
292    "log",
293];
294
295/// A finite call: at most one terminal event, so results and diagnostics can
296/// safely take different streams.
297fn finite_output() -> OutputSpec {
298    OutputSpec::protocol_finite(
299        ["json", "yaml", "plain"],
300        ["split", "stdout", "stderr"],
301        "json",
302        "split",
303    )
304    .file_sinks(["stdout", "stderr"])
305}
306
307/// An ordered event stream, which must stay on one stream: splitting it across
308/// stdout and stderr loses the ordering that makes it a stream. `split` is
309/// therefore absent from the contract rather than rejected once the process is
310/// already running.
311fn stream_output() -> OutputSpec {
312    OutputSpec::protocol_stream(
313        ["json", "yaml", "plain"],
314        ["stdout", "stderr"],
315        "json",
316        "stdout",
317    )
318    .file_sinks(["stdout", "stderr"])
319}
320
321/// The whole canonical CLI: one registry that is the single source for argv
322/// parsing, typed values, which argument mixes are legal, each mix's output
323/// contract, `--help`, and `docs/cli.md`.
324pub fn build_cli(bin_name: &str) -> Result<BuiltCliSpec, CliSpecError> {
325    let mut spec =
326        CliSpec::new(bin_name, env!("CARGO_PKG_VERSION"))
327            .about(env!("CARGO_PKG_DESCRIPTION"))
328            .display_name(env!("DISPLAY_NAME"))
329            .lifecycle_output(finite_output())
330            .command(root_command())
331            .command(CommandSpec::new(["inspect"]).about(
332                "Schema discovery: databases, schemas, tables, views, indexes, or a full snapshot.",
333            ))
334            .command(inspect_databases_command())
335            .command(inspect_simple_command(
336                "database",
337                "Summarize the connected database: schema/table/view/sequence counts and size.",
338                "inspect_database",
339            ))
340            .command(inspect_simple_command(
341                "schemas",
342                "List user-visible schemas with owner, object counts, and size.",
343                "inspect_schemas",
344            ))
345            .command(inspect_like_command(
346                "schema",
347                "Export full schema metadata for one schema.",
348                "inspect_schema",
349                "Optional `LIKE` pattern matched against relation names (`%` is the wildcard)",
350            ))
351            .command(inspect_like_command(
352                "snapshot",
353                "Export a stable full-schema snapshot for machine consumption.",
354                "inspect_snapshot",
355                "Optional `LIKE` pattern matched against relation names (`%` is the wildcard)",
356            ))
357            .command(inspect_like_command(
358                "tables",
359                "List tables in a schema with owner, estimated rows, and size.",
360                "inspect_tables",
361                "Optional `LIKE` pattern matched against the table name (`%` is the wildcard)",
362            ))
363            .command(inspect_like_command(
364                "views",
365                "List views (regular and materialized) in a schema with owner.",
366                "inspect_views",
367                "Optional `LIKE` pattern matched against the view name (`%` is the wildcard)",
368            ))
369            .command(inspect_indexes_command())
370            .command(inspect_table_command())
371            .command(
372                CommandSpec::new(["psql"])
373                    .about("Manage the local psql wrapper that forwards to `--mode psql`."),
374            )
375            .command(psql_admin_command(
376                "status",
377                "Show whether the afpsql-managed psql wrapper is installed and active.",
378            ))
379            .command(psql_admin_command(
380                "install",
381                "Install an afpsql-managed psql wrapper.",
382            ))
383            .command(psql_admin_command(
384                "uninstall",
385                "Remove an afpsql-managed psql wrapper.",
386            ))
387            .command(CommandSpec::new(["skill"]).about(
388                "Manage Agent-First PSQL skills for Codex, Claude Code, opencode, and Hermes.",
389            ))
390            .command(skill_command(
391                "status",
392                "Show whether the Agent-First PSQL skill is installed, valid, and up to date.",
393                false,
394            ))
395            .command(skill_command(
396                "install",
397                "Install the Agent-First PSQL skill.",
398                true,
399            ))
400            .command(skill_command(
401                "uninstall",
402                "Remove an afpsql-managed Agent-First PSQL skill.",
403                true,
404            ));
405    // Absent from a source tarball with no reachable .git, and the version
406    // payload omits it rather than reporting the literal "unknown".
407    if let Some(build) = Some(env!("GIT_SHA")).filter(|sha| *sha != "unknown") {
408        spec = spec.build_id(build);
409    }
410    build_afdata_cli(spec)
411}
412
413/// Arguments that any connecting command accepts, declared once.
414fn with_connection_args(command: CommandSpec) -> CommandSpec {
415    command
416        .arg(ArgSpec::option("--dsn", "SOURCE").about(
417            "PostgreSQL DSN source: literal value, env:NAME, file:PATH#DOT_PATH, or \
418                 literal:VALUE for a literal starting with a source prefix",
419        ))
420        .arg(ArgSpec::option("--conninfo", "SOURCE").about(
421            "libpq conninfo source: literal value, env:NAME, file:PATH#DOT_PATH, or \
422                 literal:VALUE for a literal starting with a source prefix",
423        ))
424        .arg(ArgSpec::option("--host", "HOST").about("PostgreSQL host"))
425        .arg(ArgSpec::option_i64("--port", "PORT").about("PostgreSQL port"))
426        .arg(ArgSpec::option("--user", "USER").about("PostgreSQL user name"))
427        .arg(ArgSpec::option("--dbname", "DBNAME").about("PostgreSQL database name"))
428        .arg(ArgSpec::option("--password", "SOURCE").about(
429            "PostgreSQL password source: literal value, env:NAME, file:PATH#DOT_PATH, or \
430                 literal:VALUE for a literal starting with a source prefix",
431        ))
432        .arg(
433            ArgSpec::option("--ssh", "USER@HOST")
434                .about("Open an SSH transport to USER@HOST before connecting to PostgreSQL"),
435        )
436        .arg(
437            ArgSpec::option("--ssh-via", "USER@HOST")
438                .repeatable()
439                .about("SSH hop to reach before the final --ssh destination; repeat for more hops"),
440        )
441        .arg(
442            ArgSpec::option("--ssh-option", "OPTION")
443                .repeatable()
444                .about("Additional OpenSSH -o option; repeat for more options"),
445        )
446        .arg(
447            ArgSpec::option("--ssh-remote-socket", "PATH")
448                .about("Explicit remote PostgreSQL Unix socket path for SSH forwarding"),
449        )
450        .arg(
451            ArgSpec::option("--ssh-sudo-user", "USER").about(
452                "Remote OS user for the sudo -n Unix-socket bridge; needs an explicit socket",
453            ),
454        )
455        .arg(
456            ArgSpec::option("--container-docker-name", "NAME")
457                .about("Run a docker exec stdio bridge in this container before connecting"),
458        )
459        .arg(
460            ArgSpec::option("--container-docker-user", "USER")
461                .about("Container OS user to run the docker exec bridge as"),
462        )
463        .arg(
464            ArgSpec::option("--container-docker-context", "CONTEXT")
465                .about("Docker context to run the exec against"),
466        )
467        .arg(
468            ArgSpec::option("--container-docker-runtime", "COMMAND")
469                .about("Docker runtime command; defaults to docker"),
470        )
471        .arg(
472            ArgSpec::option("--container-podman-name", "NAME")
473                .about("Run a podman exec stdio bridge in this container before connecting"),
474        )
475        .arg(
476            ArgSpec::option("--container-podman-user", "USER")
477                .about("Container OS user to run the podman exec bridge as"),
478        )
479        .arg(
480            ArgSpec::option("--container-podman-runtime", "COMMAND")
481                .about("Podman runtime command; defaults to podman"),
482        )
483        .arg(
484            ArgSpec::option("--container-nerdctl-name", "NAME")
485                .about("Run a nerdctl exec stdio bridge in this container before connecting"),
486        )
487        .arg(
488            ArgSpec::option("--container-nerdctl-user", "USER")
489                .about("Container OS user to run the nerdctl exec bridge as"),
490        )
491        .arg(
492            ArgSpec::option("--container-nerdctl-runtime", "COMMAND")
493                .about("Nerdctl runtime command; defaults to nerdctl"),
494        )
495        .arg(
496            ArgSpec::option("--container-compose-service", "NAME")
497                .about("Run a compose exec stdio bridge in this service before connecting"),
498        )
499        .arg(
500            ArgSpec::option("--container-compose-user", "USER")
501                .about("Container OS user to run the compose exec bridge as"),
502        )
503        .arg(
504            ArgSpec::option("--container-compose-file", "FILE")
505                .repeatable()
506                .about("Compose file passed before compose exec; repeat for more files"),
507        )
508        .arg(
509            ArgSpec::option("--container-compose-project", "NAME")
510                .about("Compose project name passed before compose exec"),
511        )
512        .arg(
513            ArgSpec::option("--container-compose-runtime", "COMMAND")
514                .about("Compose runtime command; defaults to docker, use docker-compose for v1"),
515        )
516        .arg(
517            ArgSpec::option("--container-kubectl-pod", "NAME")
518                .about("Run a kubectl exec stdio bridge in this pod before connecting"),
519        )
520        .arg(
521            ArgSpec::option("--container-kubectl-container", "NAME")
522                .about("Container within a multi-container pod to exec into"),
523        )
524        .arg(
525            ArgSpec::option("--container-kubectl-namespace", "NAMESPACE")
526                .about("Kubernetes namespace to run the exec in"),
527        )
528        .arg(
529            ArgSpec::option("--container-kubectl-context", "CONTEXT")
530                .about("Kubernetes context to run the exec against"),
531        )
532        .arg(
533            ArgSpec::option("--container-kubectl-runtime", "COMMAND")
534                .about("Kubectl runtime command; defaults to kubectl"),
535        )
536        .arg(ArgSpec::option("--log", "FILTER").repeatable().about(
537            "Diagnostic log filter: startup, connect, query, transport, mode, an exact \
538                     event such as query.error, or all. Comma-separated or repeated",
539        ))
540}
541
542/// Arguments every SQL query shape shares, whatever its source or lifecycle.
543fn query_shared_optional() -> Vec<&'static str> {
544    vec![
545        "param",
546        "permission",
547        "statement_timeout_ms",
548        "lock_timeout_ms",
549        "explain",
550    ]
551}
552
553fn query_optional(extra: &[&'static str]) -> Vec<&'static str> {
554    let mut ids = query_shared_optional();
555    ids.extend_from_slice(extra);
556    ids.extend_from_slice(&CONNECTION_IDS);
557    ids
558}
559
560fn root_command() -> CommandSpec {
561    let command = CommandSpec::root()
562        .about("Run one SQL action per process, or open a long-lived pipe session.")
563        .arg(ArgSpec::option("--sql", "SQL").about("Inline SQL to execute"))
564        .arg(
565            ArgSpec::option("--sql-file", "PATH")
566                .about("File to read SQL from; `-` reads it from stdin"),
567        )
568        .arg(ArgSpec::option("--param", "N=VALUE").repeatable().about(
569            "Positional bind parameter in N=value form; repeat for more parameters. \
570                     Bare null/true/false bind as JSON null/booleans; prefix with `text:` to \
571                     bind any value as a literal string",
572        ))
573        .arg(
574            ArgSpec::flag("--stream-rows")
575                .about("Stream the result as ordered result_rows batches instead of one payload"),
576        )
577        .arg(ArgSpec::option_i64("--batch-rows", "N").about("Maximum rows per streamed batch"))
578        .arg(ArgSpec::option_i64("--batch-bytes", "N").about("Soft byte target per streamed batch"))
579        .arg(
580            ArgSpec::option_i64("--statement-timeout-ms", "MS")
581                .about("Per-query statement timeout in milliseconds"),
582        )
583        .arg(
584            ArgSpec::option_i64("--lock-timeout-ms", "MS")
585                .about("Per-query lock timeout in milliseconds"),
586        )
587        .arg(
588            ArgSpec::option_i64("--inline-max-rows", "N")
589                .about("Maximum inline rows before returning a truncated result"),
590        )
591        .arg(
592            ArgSpec::option_i64("--inline-max-bytes", "N")
593                .about("Maximum inline payload bytes before returning a truncated result"),
594        )
595        .arg(
596            ArgSpec::option_enum("--permission", PERMISSIONS)
597                .value_name("PERMISSION")
598                .about(
599                    "Query permission policy; defaults to read, ssh-read with --ssh, or \
600                     container-read with a --container-<driver>-* flag",
601                ),
602        )
603        .arg(
604            ArgSpec::flag("--dry-run")
605                .about("Prepare the query and report its shape without running it"),
606        )
607        .arg(
608            ArgSpec::option_enum("--explain", ["plan", "analyze"])
609                .value_name("EXPLAIN")
610                .about(
611                    "Return the plan instead of the rows: `plan` wraps the SQL in EXPLAIN \
612                     (FORMAT JSON); `analyze` runs it and buffers metrics",
613                ),
614        )
615        .arg(
616            ArgSpec::option_enum("--mode", ["cli", "pipe", "psql"])
617                .value_name("MODE")
618                .default("cli")
619                .about(
620                    "Runtime mode: one SQL action, a long-lived JSONL session, or psql \
621                     argument translation",
622                ),
623        );
624
625    with_connection_args(command)
626        .combination(
627            Combination::new("query-inline")
628                .action("query")
629                .about("Run inline --sql and return one bounded result")
630                .fixed("mode", "cli")
631                .required(["sql"])
632                .optional(query_optional(&[
633                    "dry_run",
634                    "inline_max_rows",
635                    "inline_max_bytes",
636                ]))
637                .output(finite_output()),
638        )
639        .combination(
640            Combination::new("query-file")
641                .action("query")
642                .about("Run SQL read from --sql-file and return one bounded result")
643                .fixed("mode", "cli")
644                .required(["sql_file"])
645                .optional(query_optional(&[
646                    "dry_run",
647                    "inline_max_rows",
648                    "inline_max_bytes",
649                ]))
650                .output(finite_output()),
651        )
652        .combination(
653            Combination::new("query-inline-stream")
654                .action("query")
655                .about("Stream inline --sql as ordered row batches on one stream")
656                .fixed("mode", "cli")
657                .required(["sql", "stream_rows"])
658                .optional(query_optional(&["batch_rows", "batch_bytes"]))
659                .output(stream_output()),
660        )
661        .combination(
662            Combination::new("query-file-stream")
663                .action("query")
664                .about("Stream SQL read from --sql-file as ordered row batches on one stream")
665                .fixed("mode", "cli")
666                .required(["sql_file", "stream_rows"])
667                .optional(query_optional(&["batch_rows", "batch_bytes"]))
668                .output(stream_output()),
669        )
670        .combination(
671            Combination::new("pipe")
672                .action("pipe")
673                .about("Open a long-lived JSONL session that reads requests from stdin")
674                .fixed("mode", "pipe")
675                .optional(CONNECTION_IDS)
676                .output(stream_output()),
677        )
678        .combination(
679            Combination::new("psql-translation")
680                .action("psql_mode")
681                .about(
682                    "Translate a psql command line: every remaining argument is psql's own \
683                     (-c, -f, -l, -h, -p, -U, -d, -v, DBNAME USERNAME), parsed by the \
684                     compatibility layer rather than by this registry",
685                )
686                .fixed("mode", "psql")
687                .output(finite_output()),
688        )
689}
690
691fn inspect_command(name: &'static str, about: &'static str) -> CommandSpec {
692    with_connection_args(CommandSpec::new(["inspect", name]).about(about))
693}
694
695fn inspect_combination(id: &'static str, extra: &[&'static str]) -> Combination {
696    let mut optional: Vec<&str> = extra.to_vec();
697    optional.extend_from_slice(&CONNECTION_IDS);
698    Combination::new(id)
699        .action(id)
700        .optional(optional)
701        .output(finite_output())
702}
703
704fn inspect_simple_command(
705    name: &'static str,
706    about: &'static str,
707    id: &'static str,
708) -> CommandSpec {
709    inspect_command(name, about).combination(inspect_combination(id, &[]))
710}
711
712fn inspect_databases_command() -> CommandSpec {
713    inspect_command(
714        "databases",
715        "List databases on the connected server with size, encoding, and connection facts.",
716    )
717    .arg(ArgSpec::flag("--all").about("Include template databases (template0/template1)"))
718    .combination(inspect_combination("inspect_databases", &["all"]))
719}
720
721fn inspect_like_command(
722    name: &'static str,
723    about: &'static str,
724    id: &'static str,
725    like_about: &'static str,
726) -> CommandSpec {
727    inspect_command(name, about)
728        .arg(
729            ArgSpec::option("--schema", "SCHEMA")
730                .default("public")
731                .about("Schema to inspect"),
732        )
733        .arg(ArgSpec::option("--like", "PATTERN").about(like_about))
734        .combination(inspect_combination(id, &["schema", "like"]))
735}
736
737fn inspect_indexes_command() -> CommandSpec {
738    inspect_command(
739        "indexes",
740        "List indexes with definitions, size, validity, and optional usage stats.",
741    )
742    .arg(
743        ArgSpec::option("--schema", "SCHEMA")
744            .default("public")
745            .about("Schema to filter on"),
746    )
747    .arg(
748        ArgSpec::option("--table", "TABLE")
749            .about("Table to filter on; `schema.table` overrides --schema"),
750    )
751    .arg(
752        ArgSpec::flag("--stats")
753            .about("Include PostgreSQL's built-in pg_stat_user_indexes usage counters"),
754    )
755    .combination(inspect_combination(
756        "inspect_indexes",
757        &["schema", "table", "stats"],
758    ))
759}
760
761fn inspect_table_command() -> CommandSpec {
762    // Declared before the connection arguments so the usage line reads the way
763    // the call is written: the table name comes first.
764    let command = CommandSpec::new(["inspect", "table"])
765        .about("Describe a table's columns: types, nullability, defaults, primary key, comments.")
766        .arg(
767            ArgSpec::positional("name", 0, "NAME")
768                .about("Table name; `schema.table` overrides the default `public` schema"),
769        )
770        .arg(
771            ArgSpec::flag("--full")
772                .about("Also return constraints, indexes, triggers, and sequence/default metadata"),
773        );
774    with_connection_args(command).combination({
775        let mut optional: Vec<&str> = vec!["full"];
776        optional.extend_from_slice(&CONNECTION_IDS);
777        Combination::new("inspect_table")
778            .action("inspect_table")
779            .required(["name"])
780            .optional(optional)
781            .output(finite_output())
782    })
783}
784
785fn psql_admin_command(verb: &'static str, about: &'static str) -> CommandSpec {
786    CommandSpec::new(["psql", verb])
787        .about(about)
788        .arg(ArgSpec::option("--bin-dir", "DIR").about(
789            "Directory that holds the psql wrapper; defaults to the afpsql executable directory",
790        ))
791        .combination(
792            Combination::new(format!("psql-{verb}"))
793                .action(format!("psql_{verb}"))
794                .optional(["bin_dir"])
795                .output(finite_output()),
796        )
797}
798
799/// One skill verb, as two shapes.
800///
801/// `--skills-dir` names a single directory, so it is meaningless when the verb
802/// fans out across every agent. Registering that as two shapes rather than one
803/// shape plus a runtime check means the illegal mix is rejected by the parser,
804/// and both legal mixes are visible in one `--help`.
805fn skill_command(verb: &'static str, about: &'static str, force: bool) -> CommandSpec {
806    let mut command = CommandSpec::new(["skill", verb])
807        .about(about)
808        .arg(
809            ArgSpec::option_enum(
810                "--agent",
811                ["all", "codex", "claude-code", "opencode", "hermes"],
812            )
813            .value_name("AGENT")
814            .default("all")
815            .about("Agent to manage"),
816        )
817        .arg(
818            ArgSpec::option_enum("--scope", ["personal", "workspace"])
819                .value_name("SCOPE")
820                .default("personal")
821                .about("Skill scope"),
822        )
823        .arg(ArgSpec::option("--skills-dir", "DIR").about("Directory that contains skill folders"));
824
825    let mut every: Vec<&str> = vec!["scope"];
826    let mut named: Vec<&str> = vec!["scope", "skills_dir"];
827    if force {
828        command =
829            command.arg(ArgSpec::flag("--force").about(
830                "Overwrite or remove an unmanaged Agent-First PSQL skill at the target path",
831            ));
832        every.push("force");
833        named.push("force");
834    }
835
836    command
837        .combination(
838            Combination::new(format!("skill-{verb}-every-agent"))
839                .action(format!("skill_{verb}"))
840                .about("Target every agent that supports the scope")
841                .fixed("agent", "all")
842                .optional(every)
843                .output(finite_output()),
844        )
845        .combination(
846            Combination::new(format!("skill-{verb}-one-agent"))
847                .action(format!("skill_{verb}"))
848                .about("Target one named agent; only this shape accepts --skills-dir")
849                .fixed_one_of("agent", ["codex", "claude-code", "opencode", "hermes"])
850                .optional(named)
851                .output(finite_output()),
852        )
853}
854
855type ActionResult = Result<Mode, ParseError>;
856type ActionHandler = fn(&ResolvedInvocation) -> ActionResult;
857
858fn actions() -> Vec<(&'static str, ActionHandler)> {
859    vec![
860        ("query", run_query as ActionHandler),
861        ("pipe", run_pipe),
862        ("psql_mode", run_psql_mode),
863        ("inspect_databases", run_inspect_databases),
864        ("inspect_database", run_inspect_database),
865        ("inspect_schemas", run_inspect_schemas),
866        ("inspect_schema", run_inspect_schema),
867        ("inspect_snapshot", run_inspect_snapshot),
868        ("inspect_tables", run_inspect_tables),
869        ("inspect_views", run_inspect_views),
870        ("inspect_indexes", run_inspect_indexes),
871        ("inspect_table", run_inspect_table),
872        ("psql_status", run_psql_status),
873        ("psql_install", run_psql_install),
874        ("psql_uninstall", run_psql_uninstall),
875        ("skill_status", run_skill_status),
876        ("skill_install", run_skill_install),
877        ("skill_uninstall", run_skill_uninstall),
878    ]
879}
880
881/// Resolve argv into one mode, or answer `--help`/`--version`/`--docs`.
882pub fn parse_args(bin_name: &str) -> Result<Parsed, ParseError> {
883    let cli = build_cli(bin_name)
884        .map_err(|error| ParseError::new("cli_spec_invalid", error.to_string()))?;
885    let raw: Vec<String> = std::env::args().collect();
886    // The one invocation this registry cannot parse: psql's option grammar has
887    // clustered shorts and positional dbname/username, so it is translated
888    // rather than registered. The registered `psql-translation` shape and this
889    // branch agree on what `--mode psql` alone means.
890    if is_psql_mode_requested(&raw) {
891        let (mode, routing) = parse_psql_mode_full(&raw)?;
892        let redirect = install_redirect(routing.stdout_file, routing.stderr_file)?;
893        crate::emit::set_output_to(routing.output_to);
894        return Ok(Parsed { mode, redirect });
895    }
896
897    let app = cli
898        .bind_actions(actions())
899        .map_err(|error| ParseError::new("cli_actions_invalid", error.to_string()))?;
900    let outcome = app
901        .resolve_from(std::env::args_os())
902        .map_err(|error| ParseError {
903            code: error.rule.code().to_string(),
904            message: error.message.clone(),
905            hint: Some(error.hint.clone()),
906        })?;
907
908    match outcome {
909        CliOutcome::Run(invocation) => {
910            let redirect = redirect_for(invocation.output_plan())?;
911            crate::emit::set_output_to(destination_of(invocation.output_plan()));
912            let mode = app.execute(&invocation)?;
913            Ok(Parsed { mode, redirect })
914        }
915        // `--docs` renders the whole registry as Markdown, so it carries no
916        // format of its own and never becomes a protocol event.
917        CliOutcome::Docs(docs) => {
918            let _redirect = redirect_for(docs.output_plan())?;
919            crate::emit::set_output_to(OutputTo::Stdout);
920            let rendered = agent_first_data::render_cli_reference(&cli).replace(
921                "| 2 | The invocation was rejected before anything ran. `error.code` is one of the `cli_*` codes below. |",
922                "| 2 | The invocation was rejected before anything ran. `error.code` is one of the `cli_*` codes below. |\n| 4 | A terminal event could not be written; the requested outcome is unknown to the caller. |",
923            );
924            let _ = crate::emit::write_result_text(&rendered);
925            std::process::exit(0);
926        }
927        CliOutcome::Help(help) => {
928            let _redirect = redirect_for(help.output_plan())?;
929            let format = format_of_plan(help.output_plan())?;
930            crate::emit::set_output_to(destination_of(help.output_plan()));
931            if format == OutputFormat::Plain {
932                let _ = crate::emit::write_result_text(&help.plain());
933            } else {
934                let _ = crate::emit::emit_event(agent_first_data::cli_help_event(&help), format);
935            }
936            std::process::exit(0);
937        }
938        CliOutcome::Version(version) => {
939            let _redirect = redirect_for(version.output_plan())?;
940            let format = format_of_plan(version.output_plan())?;
941            crate::emit::set_output_to(destination_of(version.output_plan()));
942            let _ = crate::emit::emit_event(agent_first_data::cli_version_event(&version), format);
943            std::process::exit(0);
944        }
945    }
946}
947
948fn redirect_for(
949    plan: &OutputPlan,
950) -> Result<Option<agent_first_data::stream_redirect::InstalledStreamRedirect>, ParseError> {
951    install_redirect(
952        plan.stdout_file().map(std::path::Path::to_path_buf),
953        plan.stderr_file().map(std::path::Path::to_path_buf),
954    )
955}
956
957fn install_redirect(
958    stdout_file: Option<std::path::PathBuf>,
959    stderr_file: Option<std::path::PathBuf>,
960) -> Result<Option<agent_first_data::stream_redirect::InstalledStreamRedirect>, ParseError> {
961    let config =
962        agent_first_data::stream_redirect::StreamRedirectConfig::new(stdout_file, stderr_file)
963            .map_err(|error| ParseError::invalid_request(error.to_string()))?;
964    config
965        .as_ref()
966        .map(agent_first_data::stream_redirect::install)
967        .transpose()
968        .map_err(|error| ParseError::invalid_request(error.to_string()))
969}
970
971fn destination_of(plan: &OutputPlan) -> OutputTo {
972    plan.destination()
973        .and_then(|destination| OutputTo::parse(destination).ok())
974        .unwrap_or(OutputTo::Split)
975}
976
977fn format_of_plan(plan: &OutputPlan) -> Result<OutputFormat, ParseError> {
978    match plan.format() {
979        None => Ok(OutputFormat::Json),
980        Some(format) => cli_parse_output(format).map_err(ParseError::invalid_value),
981    }
982}
983
984fn format_of(invocation: &ResolvedInvocation) -> Result<OutputFormat, ParseError> {
985    format_of_plan(invocation.output_plan())
986}
987
988fn optional_string(invocation: &ResolvedInvocation, id: &str) -> Option<String> {
989    invocation
990        .optional(id)
991        .and_then(CliValue::as_str)
992        .map(str::to_string)
993}
994
995fn required_string(invocation: &ResolvedInvocation, id: &str) -> String {
996    invocation
997        .required(id)
998        .as_str()
999        .unwrap_or_default()
1000        .to_string()
1001}
1002
1003fn flag(invocation: &ResolvedInvocation, id: &str) -> bool {
1004    invocation
1005        .optional(id)
1006        .and_then(CliValue::as_bool)
1007        .unwrap_or(false)
1008}
1009
1010fn repeated_strings(invocation: &ResolvedInvocation, id: &str) -> Vec<String> {
1011    invocation
1012        .repeated(id)
1013        .iter()
1014        .filter_map(CliValue::as_str)
1015        .map(str::to_string)
1016        .collect()
1017}
1018
1019/// A count the registry can only type as `i64`, because it has no unsigned
1020/// type. The check reports the parser's own classification.
1021fn count_of(
1022    invocation: &ResolvedInvocation,
1023    id: &str,
1024    flag_name: &str,
1025) -> Result<Option<usize>, ParseError> {
1026    match invocation.optional(id).and_then(CliValue::as_i64) {
1027        None => Ok(None),
1028        Some(value) => usize::try_from(value).map(Some).map_err(|_| {
1029            ParseError::invalid_value(format!("{flag_name} must not be negative"))
1030                .hint(format!("pass zero or a positive count to {flag_name}"))
1031        }),
1032    }
1033}
1034
1035fn millis_of(
1036    invocation: &ResolvedInvocation,
1037    id: &str,
1038    flag_name: &str,
1039) -> Result<Option<u64>, ParseError> {
1040    match invocation.optional(id).and_then(CliValue::as_i64) {
1041        None => Ok(None),
1042        Some(value) => u64::try_from(value).map(Some).map_err(|_| {
1043            ParseError::invalid_value(format!("{flag_name} must not be negative"))
1044                .hint(format!("pass zero or a positive duration to {flag_name}"))
1045        }),
1046    }
1047}
1048
1049fn port_of(
1050    invocation: &ResolvedInvocation,
1051    id: &str,
1052    flag_name: &str,
1053) -> Result<Option<u16>, ParseError> {
1054    match invocation.optional(id).and_then(CliValue::as_i64) {
1055        None => Ok(None),
1056        Some(value) => u16::try_from(value).map(Some).map_err(|_| {
1057            ParseError::invalid_value(format!("{flag_name} must be a port between 0 and 65535"))
1058                .hint(format!("pass a TCP port in 0..=65535 to {flag_name}"))
1059        }),
1060    }
1061}
1062
1063/// The `--log` filters, accepting both repetition and comma-separated lists.
1064fn log_entries(invocation: &ResolvedInvocation) -> Vec<String> {
1065    split_log_entries(&repeated_strings(invocation, "log"))
1066}
1067
1068fn split_log_entries(values: &[String]) -> Vec<String> {
1069    values
1070        .iter()
1071        .flat_map(|value| value.split(','))
1072        .map(str::trim)
1073        .filter(|entry| !entry.is_empty())
1074        .map(std::string::ToString::to_string)
1075        .collect()
1076}
1077
1078/// Whether the caller asked for the startup log line.
1079fn startup_requested(entries: &[String]) -> bool {
1080    entries.iter().any(|entry| {
1081        matches!(
1082            entry.trim().to_ascii_lowercase().as_str(),
1083            "startup" | "all" | "*"
1084        )
1085    })
1086}
1087
1088/// One resolved connection, plus the source metadata the startup log reports.
1089struct Connection {
1090    session: SessionConfig,
1091    sources: Value,
1092}
1093
1094enum TypedSecretSource {
1095    Literal(String),
1096    Env(String),
1097    File(SecretConfigRef),
1098}
1099
1100impl TypedSecretSource {
1101    fn parse(flag: &str, raw: Option<String>) -> Result<Option<Self>, ParseError> {
1102        let Some(raw) = raw else {
1103            return Ok(None);
1104        };
1105        // Escape hatch for a literal secret that itself starts with a source
1106        // prefix; without it such a value would be unrepresentable.
1107        if let Some(value) = raw.strip_prefix("literal:") {
1108            return Ok(Some(Self::Literal(value.to_string())));
1109        }
1110        if let Some(name) = raw.strip_prefix("env:") {
1111            if name.is_empty() {
1112                return Err(ParseError::invalid_value(format!(
1113                    "{flag} env source requires a variable name"
1114                )));
1115            }
1116            return Ok(Some(Self::Env(name.to_string())));
1117        }
1118        if let Some(file_source) = raw.strip_prefix("file:") {
1119            let Some((file, path)) = file_source.rsplit_once('#') else {
1120                return Err(ParseError::invalid_value(format!(
1121                    "{flag} file source must be file:PATH#DOT_PATH"
1122                )));
1123            };
1124            if file.is_empty() || path.is_empty() {
1125                return Err(ParseError::invalid_value(format!(
1126                    "{flag} file source requires both PATH and DOT_PATH"
1127                )));
1128            }
1129            return Ok(Some(Self::File(SecretConfigRef {
1130                file: std::path::PathBuf::from(file),
1131                path: path.to_string(),
1132            })));
1133        }
1134        Ok(Some(Self::Literal(raw)))
1135    }
1136
1137    fn resolve(&self, flag: &str) -> Result<String, ParseError> {
1138        match self {
1139            Self::Literal(value) => Ok(value.clone()),
1140            Self::Env(name) => std::env::var(name).map_err(|_| {
1141                ParseError::invalid_value(format!(
1142                    "{flag} references an unset environment variable"
1143                ))
1144            }),
1145            Self::File(reference) => {
1146                resolve_config_secret(flag, reference).map_err(ParseError::invalid_value)
1147            }
1148        }
1149    }
1150
1151    fn metadata(&self) -> Value {
1152        match self {
1153            Self::Literal(_) => json!({"kind": "direct"}),
1154            Self::Env(name) => json!({"kind": "env", "name": name}),
1155            Self::File(reference) => reference.safe_metadata(),
1156        }
1157    }
1158}
1159
1160fn connection_from(invocation: &ResolvedInvocation) -> Result<Connection, ParseError> {
1161    let dsn = TypedSecretSource::parse("--dsn", optional_string(invocation, "dsn"))?;
1162    let conninfo = TypedSecretSource::parse("--conninfo", optional_string(invocation, "conninfo"))?;
1163    let password = TypedSecretSource::parse("--password", optional_string(invocation, "password"))?;
1164    let mut source_fields = serde_json::Map::new();
1165    for (name, source) in [
1166        ("dsn", dsn.as_ref()),
1167        ("conninfo", conninfo.as_ref()),
1168        ("password", password.as_ref()),
1169    ] {
1170        if let Some(source) = source {
1171            source_fields.insert(name.to_string(), source.metadata());
1172        }
1173    }
1174    let sources = Value::Object(source_fields);
1175
1176    let session = SessionConfig {
1177        // Caller-supplied, so the ordinary environment fallbacks still apply;
1178        // only a locked administrator profile pins the endpoint.
1179        profile_pinned: false,
1180        dsn_secret: dsn
1181            .as_ref()
1182            .map(|source| source.resolve("--dsn"))
1183            .transpose()?,
1184        conninfo_secret: conninfo
1185            .as_ref()
1186            .map(|source| source.resolve("--conninfo"))
1187            .transpose()?,
1188        host: optional_string(invocation, "host"),
1189        port: port_of(invocation, "port", "--port")?,
1190        user: optional_string(invocation, "user"),
1191        dbname: optional_string(invocation, "dbname"),
1192        password_secret: password
1193            .as_ref()
1194            .map(|source| source.resolve("--password"))
1195            .transpose()?,
1196        ssh: SshConfig {
1197            destination: optional_string(invocation, "ssh")
1198                .or_else(|| crate::runtime_env::nonempty("AFPSQL_SSH")),
1199            via: non_empty_or_env(repeated_strings(invocation, "ssh_via"), "AFPSQL_SSH_VIA"),
1200            options: repeated_strings(invocation, "ssh_option"),
1201            local_host: None,
1202            local_port: None,
1203            remote_socket: optional_string(invocation, "ssh_remote_socket")
1204                .or_else(|| crate::runtime_env::nonempty("AFPSQL_SSH_REMOTE_SOCKET")),
1205            sudo_user: optional_string(invocation, "ssh_sudo_user")
1206                .or_else(|| crate::runtime_env::nonempty("AFPSQL_SSH_SUDO_USER")),
1207        },
1208        container: cli_container_config(ContainerConfig {
1209            docker_name: optional_string(invocation, "container_docker_name"),
1210            docker_user: optional_string(invocation, "container_docker_user"),
1211            docker_context: optional_string(invocation, "container_docker_context"),
1212            docker_runtime: optional_string(invocation, "container_docker_runtime"),
1213            podman_name: optional_string(invocation, "container_podman_name"),
1214            podman_user: optional_string(invocation, "container_podman_user"),
1215            podman_runtime: optional_string(invocation, "container_podman_runtime"),
1216            nerdctl_name: optional_string(invocation, "container_nerdctl_name"),
1217            nerdctl_user: optional_string(invocation, "container_nerdctl_user"),
1218            nerdctl_runtime: optional_string(invocation, "container_nerdctl_runtime"),
1219            compose_service: optional_string(invocation, "container_compose_service"),
1220            compose_user: optional_string(invocation, "container_compose_user"),
1221            compose_files: repeated_strings(invocation, "container_compose_file"),
1222            compose_project: optional_string(invocation, "container_compose_project"),
1223            compose_runtime: optional_string(invocation, "container_compose_runtime"),
1224            kubectl_pod: optional_string(invocation, "container_kubectl_pod"),
1225            kubectl_container: optional_string(invocation, "container_kubectl_container"),
1226            kubectl_namespace: optional_string(invocation, "container_kubectl_namespace"),
1227            kubectl_context: optional_string(invocation, "container_kubectl_context"),
1228            kubectl_runtime: optional_string(invocation, "container_kubectl_runtime"),
1229        }),
1230    };
1231    // Which driver runs is inferred from the flag family used, so mixing two
1232    // families names no driver. Rejected here rather than at connect time: it
1233    // is a fact about the arguments, not about the world they point at.
1234    session
1235        .container
1236        .selected_driver()
1237        .map_err(ParseError::invalid_value)?;
1238    Ok(Connection { session, sources })
1239}
1240
1241/// Fill unset container fields from the environment.
1242///
1243/// CLI mode resolves the environment while parsing so `startup` reports the
1244/// session it will actually connect with; a pinned readonly profile replaces
1245/// this whole session later and reads no environment at all.
1246fn cli_container_config(container: ContainerConfig) -> ContainerConfig {
1247    crate::container_transport::container_config_with_env(&container, false)
1248}
1249
1250fn non_empty_or_env(values: Vec<String>, key: &str) -> Vec<String> {
1251    if values.is_empty() {
1252        parse_csv_env(key)
1253    } else {
1254        values
1255    }
1256}
1257
1258fn run_query(invocation: &ResolvedInvocation) -> ActionResult {
1259    let output = format_of(invocation)?;
1260    let entries = log_entries(invocation);
1261    let connection = connection_from(invocation)?;
1262    let sql_file = optional_string(invocation, "sql_file");
1263    let user_sql = load_sql(optional_string(invocation, "sql"), sql_file.clone())?;
1264    let params =
1265        parse_params(&repeated_strings(invocation, "param")).map_err(ParseError::invalid_value)?;
1266    let sql = match optional_string(invocation, "explain").as_deref() {
1267        Some("analyze") => wrap_explain_sql(&user_sql, true),
1268        Some(_) => wrap_explain_sql(&user_sql, false),
1269        None => user_sql,
1270    };
1271    let startup_args = with_connection_sources(
1272        startup_args("cli", Some(&sql), sql_file.as_deref(), params.len()),
1273        &connection.sources,
1274    );
1275
1276    Ok(Mode::Cli(CliRequest {
1277        sql,
1278        params,
1279        options: QueryOptions {
1280            stream_rows: flag(invocation, "stream_rows"),
1281            batch_rows: count_of(invocation, "batch_rows", "--batch-rows")?,
1282            batch_bytes: count_of(invocation, "batch_bytes", "--batch-bytes")?,
1283            statement_timeout_ms: millis_of(
1284                invocation,
1285                "statement_timeout_ms",
1286                "--statement-timeout-ms",
1287            )?,
1288            lock_timeout_ms: millis_of(invocation, "lock_timeout_ms", "--lock-timeout-ms")?,
1289            permission: permission_of(invocation),
1290            inline_max_rows: count_of(invocation, "inline_max_rows", "--inline-max-rows")?,
1291            inline_max_bytes: count_of(invocation, "inline_max_bytes", "--inline-max-bytes")?,
1292        },
1293        session: connection.session,
1294        output,
1295        log: parse_log_categories(&entries),
1296        startup_args,
1297        startup_env: startup_env_snapshot(),
1298        startup_requested: startup_requested(&entries),
1299        dry_run: flag(invocation, "dry_run"),
1300        psql_mode: false,
1301    }))
1302}
1303
1304fn permission_of(invocation: &ResolvedInvocation) -> Option<Permission> {
1305    optional_string(invocation, "permission")
1306        .as_deref()
1307        .and_then(|value| value.parse().ok())
1308}
1309
1310fn run_pipe(invocation: &ResolvedInvocation) -> ActionResult {
1311    let output = format_of(invocation)?;
1312    let entries = log_entries(invocation);
1313    let connection = connection_from(invocation)?;
1314    Ok(Mode::Pipe(PipeInit {
1315        output,
1316        session: connection.session,
1317        log: parse_log_categories(&entries),
1318        startup_args: with_connection_sources(
1319            startup_args("pipe", None, None, 0),
1320            &connection.sources,
1321        ),
1322        startup_env: startup_env_snapshot(),
1323        startup_requested: startup_requested(&entries),
1324    }))
1325}
1326
1327/// Both routes into psql translation end here.
1328///
1329/// `parse_args` short-circuits to the translator whenever argv asks for psql
1330/// mode, because psql's option grammar is not expressible in this registry. The
1331/// registered shape binds to this handler, which runs that same translator on
1332/// that same argv — so the registry's account of `--mode psql` and what the
1333/// process actually does cannot drift apart.
1334fn run_psql_mode(_invocation: &ResolvedInvocation) -> ActionResult {
1335    let raw: Vec<String> = std::env::args().collect();
1336    Ok(parse_psql_mode_full(&raw).map(|(mode, _)| mode)?)
1337}
1338
1339fn run_inspect(invocation: &ResolvedInvocation, action: InspectAction) -> ActionResult {
1340    let output = format_of(invocation)?;
1341    let entries = log_entries(invocation);
1342    let connection = connection_from(invocation)?;
1343    let (sql, params) = build_inspect_sql(action);
1344    let startup_args = with_connection_sources(
1345        startup_args("cli", Some(&sql), None, params.len()),
1346        &connection.sources,
1347    );
1348    Ok(Mode::Cli(CliRequest {
1349        sql,
1350        params,
1351        options: QueryOptions::default(),
1352        session: connection.session,
1353        output,
1354        log: parse_log_categories(&entries),
1355        startup_args,
1356        startup_env: startup_env_snapshot(),
1357        startup_requested: startup_requested(&entries),
1358        dry_run: false,
1359        psql_mode: false,
1360    }))
1361}
1362
1363fn schema_of(invocation: &ResolvedInvocation) -> String {
1364    optional_string(invocation, "schema").unwrap_or_else(|| "public".to_string())
1365}
1366
1367fn run_inspect_databases(invocation: &ResolvedInvocation) -> ActionResult {
1368    run_inspect(
1369        invocation,
1370        InspectAction::Databases(InspectDatabasesArgs {
1371            all: flag(invocation, "all"),
1372        }),
1373    )
1374}
1375
1376fn run_inspect_database(invocation: &ResolvedInvocation) -> ActionResult {
1377    run_inspect(invocation, InspectAction::Database)
1378}
1379
1380fn run_inspect_schemas(invocation: &ResolvedInvocation) -> ActionResult {
1381    run_inspect(invocation, InspectAction::Schemas)
1382}
1383
1384fn run_inspect_schema(invocation: &ResolvedInvocation) -> ActionResult {
1385    run_inspect(
1386        invocation,
1387        InspectAction::Schema(InspectSchemaArgs {
1388            schema: schema_of(invocation),
1389            like: optional_string(invocation, "like"),
1390        }),
1391    )
1392}
1393
1394fn run_inspect_snapshot(invocation: &ResolvedInvocation) -> ActionResult {
1395    run_inspect(
1396        invocation,
1397        InspectAction::Snapshot(InspectSchemaArgs {
1398            schema: schema_of(invocation),
1399            like: optional_string(invocation, "like"),
1400        }),
1401    )
1402}
1403
1404fn run_inspect_tables(invocation: &ResolvedInvocation) -> ActionResult {
1405    run_inspect(
1406        invocation,
1407        InspectAction::Tables(InspectTablesArgs {
1408            schema: schema_of(invocation),
1409            like: optional_string(invocation, "like"),
1410        }),
1411    )
1412}
1413
1414fn run_inspect_views(invocation: &ResolvedInvocation) -> ActionResult {
1415    run_inspect(
1416        invocation,
1417        InspectAction::Views(InspectViewsArgs {
1418            schema: schema_of(invocation),
1419            like: optional_string(invocation, "like"),
1420        }),
1421    )
1422}
1423
1424fn run_inspect_indexes(invocation: &ResolvedInvocation) -> ActionResult {
1425    run_inspect(
1426        invocation,
1427        InspectAction::Indexes(InspectIndexesArgs {
1428            schema: schema_of(invocation),
1429            table: optional_string(invocation, "table"),
1430            stats: flag(invocation, "stats"),
1431        }),
1432    )
1433}
1434
1435fn run_inspect_table(invocation: &ResolvedInvocation) -> ActionResult {
1436    run_inspect(
1437        invocation,
1438        InspectAction::Table(InspectTableArgs {
1439            name: required_string(invocation, "name"),
1440            full: flag(invocation, "full"),
1441        }),
1442    )
1443}
1444
1445fn psql_admin_request(
1446    invocation: &ResolvedInvocation,
1447    action: fn(Option<String>) -> PsqlAdminAction,
1448) -> ActionResult {
1449    Ok(Mode::PsqlAdmin(PsqlAdminRequest {
1450        action: action(optional_string(invocation, "bin_dir")),
1451        output: format_of(invocation)?,
1452    }))
1453}
1454
1455fn run_psql_status(invocation: &ResolvedInvocation) -> ActionResult {
1456    psql_admin_request(invocation, |bin_dir| PsqlAdminAction::Status { bin_dir })
1457}
1458
1459fn run_psql_install(invocation: &ResolvedInvocation) -> ActionResult {
1460    psql_admin_request(invocation, |bin_dir| PsqlAdminAction::Install { bin_dir })
1461}
1462
1463fn run_psql_uninstall(invocation: &ResolvedInvocation) -> ActionResult {
1464    psql_admin_request(invocation, |bin_dir| PsqlAdminAction::Uninstall { bin_dir })
1465}
1466
1467fn skill_request(
1468    invocation: &ResolvedInvocation,
1469    action: fn(SkillAdminOptions) -> SkillAdminAction,
1470) -> ActionResult {
1471    let options = SkillAdminOptions {
1472        agent: match optional_string(invocation, "agent").as_deref() {
1473            Some("codex") => SkillAgentSelection::Codex,
1474            Some("claude-code") => SkillAgentSelection::ClaudeCode,
1475            Some("opencode") => SkillAgentSelection::Opencode,
1476            Some("hermes") => SkillAgentSelection::Hermes,
1477            _ => SkillAgentSelection::All,
1478        },
1479        scope: match optional_string(invocation, "scope").as_deref() {
1480            Some("workspace") => SkillScope::Workspace,
1481            _ => SkillScope::Personal,
1482        },
1483        skills_dir: optional_string(invocation, "skills_dir"),
1484        force: flag(invocation, "force"),
1485    };
1486    Ok(Mode::SkillAdmin(SkillAdminRequest {
1487        action: action(options),
1488        output: format_of(invocation)?,
1489    }))
1490}
1491
1492fn run_skill_status(invocation: &ResolvedInvocation) -> ActionResult {
1493    skill_request(invocation, SkillAdminAction::Status)
1494}
1495
1496fn run_skill_install(invocation: &ResolvedInvocation) -> ActionResult {
1497    skill_request(invocation, SkillAdminAction::Install)
1498}
1499
1500fn run_skill_uninstall(invocation: &ResolvedInvocation) -> ActionResult {
1501    skill_request(invocation, SkillAdminAction::Uninstall)
1502}
1503
1504/// Where a translated psql invocation writes, decided by the translator rather
1505/// than by the registry's output contract.
1506struct PsqlRouting {
1507    output_to: OutputTo,
1508    stdout_file: Option<std::path::PathBuf>,
1509    stderr_file: Option<std::path::PathBuf>,
1510}
1511
1512fn parse_psql_mode_full(raw: &[String]) -> Result<(Mode, PsqlRouting), String> {
1513    let mut state = PsqlModeState::default();
1514
1515    let mut i = 1usize;
1516    while i < raw.len() {
1517        let arg = raw[i].as_str();
1518        if arg == "--" {
1519            i += 1;
1520            while i < raw.len() {
1521                state.positionals.push(raw[i].clone());
1522                i += 1;
1523            }
1524            break;
1525        }
1526        if arg.starts_with("--") {
1527            parse_psql_long_arg(raw, &mut i, &mut state)?;
1528            continue;
1529        }
1530        if arg.starts_with('-') && arg.len() > 1 {
1531            parse_psql_short_arg(raw, &mut i, &mut state)?;
1532            continue;
1533        }
1534        state.positionals.push(raw[i].clone());
1535        i += 1;
1536    }
1537
1538    let routing = PsqlRouting {
1539        output_to: state.output_to.unwrap_or(OutputTo::Split),
1540        stdout_file: state.stdout_file.clone().map(std::path::PathBuf::from),
1541        stderr_file: state.stderr_file.clone().map(std::path::PathBuf::from),
1542    };
1543    let startup_requested = startup_requested(&state.log_entries);
1544
1545    if let Some(reason) = state.interactive_reason {
1546        return Ok((
1547            Mode::PsqlUnsupported(PsqlUnsupportedRequest { reason }),
1548            routing,
1549        ));
1550    }
1551
1552    apply_psql_positionals(&mut state)?;
1553    if state.list_databases {
1554        state.sql = Some(psql_list_databases_sql());
1555        state.sql_file = None;
1556    }
1557    if state.sql.is_none() && state.sql_file.is_none() {
1558        return Ok((
1559            Mode::PsqlUnsupported(PsqlUnsupportedRequest {
1560                reason: "no -c/--command, -f/--file, or -l/--list was provided".to_string(),
1561            }),
1562            routing,
1563        ));
1564    }
1565
1566    let dsn = TypedSecretSource::parse("--dsn", state.dsn_secret).map_err(|error| error.message)?;
1567    let conninfo = TypedSecretSource::parse("--conninfo", state.conninfo_secret)
1568        .map_err(|error| error.message)?;
1569    let password = TypedSecretSource::parse("--password", state.password_secret)
1570        .map_err(|error| error.message)?;
1571    let mut source_fields = serde_json::Map::new();
1572    for (name, source) in [
1573        ("dsn", dsn.as_ref()),
1574        ("conninfo", conninfo.as_ref()),
1575        ("password", password.as_ref()),
1576    ] {
1577        if let Some(source) = source {
1578            source_fields.insert(name.to_string(), source.metadata());
1579        }
1580    }
1581    let connection_sources = Value::Object(source_fields);
1582    let dsn_secret = dsn
1583        .as_ref()
1584        .map(|source| source.resolve("--dsn").map_err(|error| error.message))
1585        .transpose()?;
1586    let conninfo_secret = conninfo
1587        .as_ref()
1588        .map(|source| source.resolve("--conninfo").map_err(|error| error.message))
1589        .transpose()?;
1590    let password_secret = password
1591        .as_ref()
1592        .map(|source| source.resolve("--password").map_err(|error| error.message))
1593        .transpose()?;
1594    let session = SessionConfig {
1595        // Caller-supplied, so the ordinary environment fallbacks still apply;
1596        // only a locked administrator profile pins the endpoint.
1597        profile_pinned: false,
1598        dsn_secret,
1599        conninfo_secret,
1600        host: state.host,
1601        port: state.port,
1602        user: state.user,
1603        dbname: state.dbname,
1604        password_secret,
1605        ssh: SshConfig::default(),
1606        container: cli_container_config(state.container),
1607    };
1608    session.container.selected_driver()?;
1609
1610    let startup_sql_file = state.sql_file.clone();
1611    let sql = load_sql(state.sql, state.sql_file)?;
1612    let params = parse_params(&state.params_kv)?;
1613    let startup_args = with_connection_sources(
1614        psql_startup_args(PsqlStartupArgs {
1615            mode: "psql",
1616            sql: Some(&sql),
1617            sql_file: startup_sql_file,
1618            param_count: params.len(),
1619        }),
1620        &connection_sources,
1621    );
1622    Ok((
1623        Mode::Cli(CliRequest {
1624            sql,
1625            params,
1626            options: QueryOptions {
1627                permission: Some(if session.uses_container_transport() {
1628                    Permission::ContainerWrite
1629                } else {
1630                    Permission::Write
1631                }),
1632                ..Default::default()
1633            },
1634            session,
1635            output: state.output,
1636            log: parse_log_categories(&state.log_entries),
1637            startup_args,
1638            startup_env: startup_env_snapshot(),
1639            startup_requested,
1640            dry_run: false,
1641            psql_mode: true,
1642        }),
1643        routing,
1644    ))
1645}
1646
1647struct PsqlModeState {
1648    sql: Option<String>,
1649    sql_file: Option<String>,
1650    host: Option<String>,
1651    port: Option<u16>,
1652    user: Option<String>,
1653    dbname: Option<String>,
1654    dsn_secret: Option<String>,
1655    conninfo_secret: Option<String>,
1656    password_secret: Option<String>,
1657    container: ContainerConfig,
1658    params_kv: Vec<String>,
1659    output: OutputFormat,
1660    output_to: Option<OutputTo>,
1661    stdout_file: Option<String>,
1662    stderr_file: Option<String>,
1663    log_entries: Vec<String>,
1664    list_databases: bool,
1665    positionals: Vec<String>,
1666    interactive_reason: Option<String>,
1667}
1668
1669impl Default for PsqlModeState {
1670    fn default() -> Self {
1671        Self {
1672            sql: None,
1673            sql_file: None,
1674            host: None,
1675            port: None,
1676            user: None,
1677            dbname: None,
1678            dsn_secret: None,
1679            conninfo_secret: None,
1680            password_secret: None,
1681            container: ContainerConfig::default(),
1682            params_kv: vec![],
1683            output: OutputFormat::Json,
1684            output_to: None,
1685            stdout_file: None,
1686            stderr_file: None,
1687            log_entries: vec![],
1688            list_databases: false,
1689            positionals: vec![],
1690            interactive_reason: None,
1691        }
1692    }
1693}
1694
1695impl PsqlModeState {
1696    fn set_sql(&mut self, sql: String, flag: &str) -> Result<(), String> {
1697        if self.sql.is_some() || self.sql_file.is_some() {
1698            return Err(format!(
1699                "psql mode currently supports only one -c/--command or -f/--file source; repeated source at {flag}"
1700            ));
1701        }
1702        self.sql = Some(sql);
1703        Ok(())
1704    }
1705
1706    fn set_sql_file(&mut self, path: String, flag: &str) -> Result<(), String> {
1707        if self.sql.is_some() || self.sql_file.is_some() {
1708            return Err(format!(
1709                "psql mode currently supports only one -c/--command or -f/--file source; repeated source at {flag}"
1710            ));
1711        }
1712        self.sql_file = Some(path);
1713        Ok(())
1714    }
1715}
1716
1717fn parse_psql_long_arg(
1718    raw: &[String],
1719    i: &mut usize,
1720    state: &mut PsqlModeState,
1721) -> Result<(), String> {
1722    let arg = raw[*i].as_str();
1723    if arg == "--mode" {
1724        let value = take_arg_value(raw, i, "--mode")?;
1725        if value != "psql" {
1726            return Err(format!(
1727                "unsupported psql-mode argument: --mode {value}; only --mode psql is allowed with psql translation"
1728            ));
1729        }
1730        return Ok(());
1731    }
1732    if let Some(value) = arg.strip_prefix("--mode=") {
1733        if value != "psql" {
1734            return Err(format!(
1735                "unsupported psql-mode argument: {arg}; only --mode=psql is allowed with psql translation"
1736            ));
1737        }
1738        *i += 1;
1739        return Ok(());
1740    }
1741
1742    if arg == "--help" || arg.starts_with("--help=") {
1743        emit_psql_mode_help();
1744        std::process::exit(0);
1745    }
1746    if arg == "--version" {
1747        emit_psql_mode_version();
1748        std::process::exit(0);
1749    }
1750    if let Some(source) = arg.strip_prefix("--password=") {
1751        if source.is_empty() {
1752            return Err("--password=SOURCE requires a non-empty source".to_string());
1753        }
1754        state.password_secret = Some(source.to_string());
1755        *i += 1;
1756        return Ok(());
1757    }
1758
1759    match long_name(arg) {
1760        "--command" => {
1761            let value = take_long_arg_value(raw, i, "--command")?;
1762            state.set_sql(value, "--command")
1763        }
1764        "--file" => {
1765            let value = take_long_arg_value(raw, i, "--file")?;
1766            state.set_sql_file(value, "--file")
1767        }
1768        "--host" => {
1769            state.host = Some(take_long_arg_value(raw, i, "--host")?);
1770            Ok(())
1771        }
1772        "--port" => {
1773            state.port = Some(parse_port(
1774                &take_long_arg_value(raw, i, "--port")?,
1775                "--port",
1776            )?);
1777            Ok(())
1778        }
1779        "--username" | "--user" => {
1780            state.user = Some(take_long_arg_value(raw, i, long_name(arg))?);
1781            Ok(())
1782        }
1783        "--dbname" => {
1784            apply_dbname_value(state, take_long_arg_value(raw, i, "--dbname")?);
1785            Ok(())
1786        }
1787        "--set" | "--variable" => {
1788            let value = take_long_arg_value(raw, i, long_name(arg))?;
1789            add_psql_variable(state, value)
1790        }
1791        "--list" => {
1792            state.list_databases = true;
1793            *i += 1;
1794            Ok(())
1795        }
1796        "--no-password"
1797        | "--no-psqlrc"
1798        | "--no-readline"
1799        | "--quiet"
1800        | "--echo-all"
1801        | "--echo-errors"
1802        | "--echo-queries"
1803        | "--echo-hidden"
1804        | "--no-align"
1805        | "--csv"
1806        | "--html"
1807        | "--tuples-only"
1808        | "--expanded"
1809        | "--field-separator-zero"
1810        | "--record-separator-zero"
1811        | "--single-transaction" => {
1812            *i += 1;
1813            Ok(())
1814        }
1815        "--field-separator" | "--record-separator" | "--pset" | "--table-attr" => {
1816            let _ = take_long_arg_value(raw, i, long_name(arg))?;
1817            Ok(())
1818        }
1819        "--password" => {
1820            state.interactive_reason =
1821                Some("--password/-W requests an interactive password prompt".to_string());
1822            *i += 1;
1823            Ok(())
1824        }
1825        "--single-step" => {
1826            state.interactive_reason =
1827                Some("--single-step/-s requires interactive command confirmation".to_string());
1828            *i += 1;
1829            Ok(())
1830        }
1831        "--single-line" => {
1832            state.interactive_reason =
1833                Some("--single-line/-S is a human-interactive input mode".to_string());
1834            *i += 1;
1835            Ok(())
1836        }
1837        "--dsn" => {
1838            state.dsn_secret = Some(take_long_arg_value(raw, i, "--dsn")?);
1839            Ok(())
1840        }
1841        "--conninfo" => {
1842            state.conninfo_secret = Some(take_long_arg_value(raw, i, "--conninfo")?);
1843            Ok(())
1844        }
1845        "--container-docker-name" => {
1846            state.container.docker_name =
1847                Some(take_long_arg_value(raw, i, "--container-docker-name")?);
1848            Ok(())
1849        }
1850        "--container-docker-user" => {
1851            state.container.docker_user =
1852                Some(take_long_arg_value(raw, i, "--container-docker-user")?);
1853            Ok(())
1854        }
1855        "--container-docker-context" => {
1856            state.container.docker_context =
1857                Some(take_long_arg_value(raw, i, "--container-docker-context")?);
1858            Ok(())
1859        }
1860        "--container-docker-runtime" => {
1861            state.container.docker_runtime =
1862                Some(take_long_arg_value(raw, i, "--container-docker-runtime")?);
1863            Ok(())
1864        }
1865        "--container-podman-name" => {
1866            state.container.podman_name =
1867                Some(take_long_arg_value(raw, i, "--container-podman-name")?);
1868            Ok(())
1869        }
1870        "--container-podman-user" => {
1871            state.container.podman_user =
1872                Some(take_long_arg_value(raw, i, "--container-podman-user")?);
1873            Ok(())
1874        }
1875        "--container-podman-runtime" => {
1876            state.container.podman_runtime =
1877                Some(take_long_arg_value(raw, i, "--container-podman-runtime")?);
1878            Ok(())
1879        }
1880        "--container-nerdctl-name" => {
1881            state.container.nerdctl_name =
1882                Some(take_long_arg_value(raw, i, "--container-nerdctl-name")?);
1883            Ok(())
1884        }
1885        "--container-nerdctl-user" => {
1886            state.container.nerdctl_user =
1887                Some(take_long_arg_value(raw, i, "--container-nerdctl-user")?);
1888            Ok(())
1889        }
1890        "--container-nerdctl-runtime" => {
1891            state.container.nerdctl_runtime =
1892                Some(take_long_arg_value(raw, i, "--container-nerdctl-runtime")?);
1893            Ok(())
1894        }
1895        "--container-compose-service" => {
1896            state.container.compose_service =
1897                Some(take_long_arg_value(raw, i, "--container-compose-service")?);
1898            Ok(())
1899        }
1900        "--container-compose-user" => {
1901            state.container.compose_user =
1902                Some(take_long_arg_value(raw, i, "--container-compose-user")?);
1903            Ok(())
1904        }
1905        "--container-compose-file" => {
1906            state.container.compose_files.push(take_long_arg_value(
1907                raw,
1908                i,
1909                "--container-compose-file",
1910            )?);
1911            Ok(())
1912        }
1913        "--container-compose-project" => {
1914            state.container.compose_project =
1915                Some(take_long_arg_value(raw, i, "--container-compose-project")?);
1916            Ok(())
1917        }
1918        "--container-compose-runtime" => {
1919            state.container.compose_runtime =
1920                Some(take_long_arg_value(raw, i, "--container-compose-runtime")?);
1921            Ok(())
1922        }
1923        "--container-kubectl-pod" => {
1924            state.container.kubectl_pod =
1925                Some(take_long_arg_value(raw, i, "--container-kubectl-pod")?);
1926            Ok(())
1927        }
1928        "--container-kubectl-container" => {
1929            state.container.kubectl_container = Some(take_long_arg_value(
1930                raw,
1931                i,
1932                "--container-kubectl-container",
1933            )?);
1934            Ok(())
1935        }
1936        "--container-kubectl-namespace" => {
1937            state.container.kubectl_namespace = Some(take_long_arg_value(
1938                raw,
1939                i,
1940                "--container-kubectl-namespace",
1941            )?);
1942            Ok(())
1943        }
1944        "--container-kubectl-context" => {
1945            state.container.kubectl_context =
1946                Some(take_long_arg_value(raw, i, "--container-kubectl-context")?);
1947            Ok(())
1948        }
1949        "--container-kubectl-runtime" => {
1950            state.container.kubectl_runtime =
1951                Some(take_long_arg_value(raw, i, "--container-kubectl-runtime")?);
1952            Ok(())
1953        }
1954        "--stdout-file" => {
1955            state.stdout_file = Some(take_long_arg_value(raw, i, "--stdout-file")?);
1956            Ok(())
1957        }
1958        "--stderr-file" => {
1959            state.stderr_file = Some(take_long_arg_value(raw, i, "--stderr-file")?);
1960            Ok(())
1961        }
1962        "--output-to" => {
1963            let value = take_long_arg_value(raw, i, "--output-to")?;
1964            state.output_to = Some(OutputTo::parse(&value)?);
1965            Ok(())
1966        }
1967        "--log" => {
1968            let values = take_long_arg_value(raw, i, "--log")?;
1969            add_log_entries(state, &values);
1970            Ok(())
1971        }
1972        _ => Err(format!("unsupported psql-mode argument: {arg}")),
1973    }
1974}
1975
1976fn parse_psql_short_arg(
1977    raw: &[String],
1978    i: &mut usize,
1979    state: &mut PsqlModeState,
1980) -> Result<(), String> {
1981    let arg = raw[*i].as_str();
1982    let mut offset = 1usize;
1983    while offset < arg.len() {
1984        let flag = arg.as_bytes()[offset] as char;
1985        offset += 1;
1986        match flag {
1987            '?' => {
1988                emit_psql_mode_help();
1989                std::process::exit(0);
1990            }
1991            'V' => {
1992                emit_psql_mode_version();
1993                std::process::exit(0);
1994            }
1995            'c' => {
1996                let value = take_short_arg_value(raw, i, arg, offset, "-c")?;
1997                return state.set_sql(value, "-c");
1998            }
1999            'f' => {
2000                let value = take_short_arg_value(raw, i, arg, offset, "-f")?;
2001                return state.set_sql_file(value, "-f");
2002            }
2003            'h' => {
2004                state.host = Some(take_short_arg_value(raw, i, arg, offset, "-h")?);
2005                return Ok(());
2006            }
2007            'p' => {
2008                let value = take_short_arg_value(raw, i, arg, offset, "-p")?;
2009                state.port = Some(parse_port(&value, "-p")?);
2010                return Ok(());
2011            }
2012            'U' => {
2013                state.user = Some(take_short_arg_value(raw, i, arg, offset, "-U")?);
2014                return Ok(());
2015            }
2016            'd' => {
2017                apply_dbname_value(state, take_short_arg_value(raw, i, arg, offset, "-d")?);
2018                return Ok(());
2019            }
2020            'v' => {
2021                let value = take_short_arg_value(raw, i, arg, offset, "-v")?;
2022                return add_psql_variable(state, value);
2023            }
2024            'F' | 'P' | 'R' | 'T' => {
2025                let _ = take_short_arg_value(raw, i, arg, offset, &format!("-{flag}"))?;
2026                return Ok(());
2027            }
2028            'l' => state.list_databases = true,
2029            'W' => {
2030                state.interactive_reason =
2031                    Some("--password/-W requests an interactive password prompt".to_string());
2032            }
2033            's' => {
2034                state.interactive_reason =
2035                    Some("--single-step/-s requires interactive command confirmation".to_string());
2036            }
2037            'S' => {
2038                state.interactive_reason =
2039                    Some("--single-line/-S is a human-interactive input mode".to_string());
2040            }
2041            'a' | 'A' | 'b' | 'e' | 'E' | 'H' | 'n' | 'q' | 't' | 'w' | 'x' | 'X' | 'z' | '0'
2042            | '1' => {}
2043            _ => return Err(format!("unsupported psql-mode argument: -{flag}")),
2044        }
2045    }
2046    *i += 1;
2047    Ok(())
2048}
2049
2050fn long_name(arg: &str) -> &str {
2051    arg.split_once('=').map(|(name, _)| name).unwrap_or(arg)
2052}
2053
2054fn take_arg_value(raw: &[String], i: &mut usize, flag: &str) -> Result<String, String> {
2055    *i += 1;
2056    let value = raw
2057        .get(*i)
2058        .ok_or_else(|| format!("{flag} requires value"))?
2059        .clone();
2060    *i += 1;
2061    Ok(value)
2062}
2063
2064fn take_long_arg_value(raw: &[String], i: &mut usize, flag: &str) -> Result<String, String> {
2065    let arg = raw[*i].as_str();
2066    if let Some((_, value)) = arg.split_once('=') {
2067        *i += 1;
2068        return Ok(value.to_string());
2069    }
2070    take_arg_value(raw, i, flag)
2071}
2072
2073fn take_short_arg_value(
2074    raw: &[String],
2075    i: &mut usize,
2076    arg: &str,
2077    offset: usize,
2078    flag: &str,
2079) -> Result<String, String> {
2080    if offset < arg.len() {
2081        let value = arg[offset..].to_string();
2082        *i += 1;
2083        return Ok(value);
2084    }
2085    take_arg_value(raw, i, flag)
2086}
2087
2088fn parse_port(value: &str, flag: &str) -> Result<u16, String> {
2089    value.parse().map_err(|_| format!("invalid {flag} port"))
2090}
2091
2092fn add_log_entries(state: &mut PsqlModeState, values: &str) {
2093    for part in values.split(',') {
2094        let trimmed = part.trim();
2095        if !trimmed.is_empty() {
2096            state.log_entries.push(trimmed.to_string());
2097        }
2098    }
2099}
2100
2101fn add_psql_variable(state: &mut PsqlModeState, value: String) -> Result<(), String> {
2102    let name = value
2103        .split_once('=')
2104        .map(|(name, _)| name)
2105        .unwrap_or(value.as_str());
2106    if name.parse::<usize>().is_ok() {
2107        if value.contains('=') {
2108            state.params_kv.push(value);
2109            return Ok(());
2110        }
2111        return Err(format!("invalid param '{value}', expected N=value"));
2112    }
2113    if is_psql_behavior_variable(name) {
2114        return Ok(());
2115    }
2116    Err(format!(
2117        "invalid or unsupported psql variable '{name}'; afpsql supports numeric -v N=value bind parameters, not client-side :name interpolation"
2118    ))
2119}
2120
2121fn is_psql_behavior_variable(name: &str) -> bool {
2122    matches!(
2123        name.to_ascii_uppercase().as_str(),
2124        "ON_ERROR_STOP"
2125            | "ON_ERROR_ROLLBACK"
2126            | "QUIET"
2127            | "ECHO"
2128            | "ECHO_HIDDEN"
2129            | "FETCH_COUNT"
2130            | "VERBOSITY"
2131            | "SHOW_CONTEXT"
2132            | "HISTCONTROL"
2133            | "HISTFILE"
2134            | "HISTSIZE"
2135            | "IGNOREEOF"
2136            | "PAGER"
2137            | "COLUMNS"
2138    )
2139}
2140
2141fn apply_psql_positionals(state: &mut PsqlModeState) -> Result<(), String> {
2142    let positionals = std::mem::take(&mut state.positionals);
2143    for value in positionals {
2144        if is_postgres_uri(&value) {
2145            state.dsn_secret = Some(value);
2146            continue;
2147        }
2148        if looks_like_conninfo(&value) {
2149            state.conninfo_secret = Some(value);
2150            continue;
2151        }
2152        if state.dbname.is_none() {
2153            state.dbname = Some(value);
2154            continue;
2155        }
2156        if state.user.is_none() {
2157            state.user = Some(value);
2158            continue;
2159        }
2160        return Err(format!("too many positional psql arguments: {value}"));
2161    }
2162    Ok(())
2163}
2164
2165fn apply_dbname_value(state: &mut PsqlModeState, value: String) {
2166    if is_postgres_uri(&value) {
2167        state.dsn_secret = Some(value);
2168    } else if looks_like_conninfo(&value) {
2169        state.conninfo_secret = Some(value);
2170    } else {
2171        state.dbname = Some(value);
2172    }
2173}
2174
2175fn is_postgres_uri(value: &str) -> bool {
2176    value.starts_with("postgresql://") || value.starts_with("postgres://")
2177}
2178
2179fn looks_like_conninfo(value: &str) -> bool {
2180    value.contains('=')
2181}
2182
2183fn psql_list_databases_sql() -> String {
2184    "select datname as name from pg_catalog.pg_database where datallowconn order by datname"
2185        .to_string()
2186}
2187
2188fn emit_psql_mode_version() {
2189    let _ = crate::emit::write_result_text(&format!(
2190        "psql (afpsql wrapper) {}\n",
2191        env!("CARGO_PKG_VERSION")
2192    ));
2193}
2194
2195fn emit_psql_mode_help() {
2196    let _ = crate::emit::write_result_text(&format!(
2197        "psql (afpsql wrapper) {}\n\
2198Usage:\n  psql [OPTION]... [DBNAME [USERNAME]]\n\n\
2199Supported non-interactive forms:\n  -c, --command=SQL\n  -f, --file=FILE\n  -l, --list\n  -h/-p/-U/-d and --host/--port/--username/--dbname\n  -v N=value, --set N=value for positional bind parameters\n\n\
2200Output:\n  --stdout-file=FILE redirects stdout bytes to FILE\n  --stderr-file=FILE redirects stderr bytes to FILE\n  --output-to=split|stdout|stderr selects AFDATA event routing\n\n\
2201Human-interactive psql modes and psql meta-commands are not supported by this wrapper.",
2202        env!("CARGO_PKG_VERSION")
2203    ));
2204}
2205
2206/// Whether this argv asks for psql translation rather than the registry.
2207///
2208/// The scan skips each option's value using the registry's own knowledge of
2209/// which long arguments take one, so this decision and the parser can never
2210/// disagree about which token is a flag — `--sql --mode` names a value, not a
2211/// mode. It stops at the first subcommand token, because `--mode` is a
2212/// root-only argument.
2213fn is_psql_mode_requested(raw: &[String]) -> bool {
2214    let takes_value = root_value_arguments();
2215    let mut i = 1usize;
2216    while i < raw.len() {
2217        let arg = raw[i].as_str();
2218        if arg == "--" {
2219            break;
2220        }
2221        if arg == "--mode" {
2222            return raw.get(i + 1).is_some_and(|value| value == "psql");
2223        }
2224        if arg == "--mode=psql" {
2225            return true;
2226        }
2227        if arg.starts_with("--") {
2228            let name = long_name(arg);
2229            i += if arg.contains('=') || !takes_value.contains(name) {
2230                1
2231            } else {
2232                2
2233            };
2234            continue;
2235        }
2236        if arg.starts_with('-') {
2237            i += 1;
2238            continue;
2239        }
2240        break;
2241    }
2242    false
2243}
2244
2245/// Every root-level long argument that consumes a following token.
2246///
2247/// Derived from the registry plus the four output arguments AFDATA injects
2248/// into it, so adding an argument cannot leave this scanner behind.
2249fn root_value_arguments() -> std::collections::BTreeSet<String> {
2250    let mut names: std::collections::BTreeSet<String> = root_command()
2251        .arguments
2252        .iter()
2253        .filter(|argument| argument.value_type != agent_first_data::ArgValueType::Flag)
2254        .filter_map(|argument| match &argument.syntax {
2255            agent_first_data::ArgSyntax::Long { name } => Some(name.clone()),
2256            agent_first_data::ArgSyntax::Positional { .. } => None,
2257        })
2258        .collect();
2259    for injected in ["--output", "--output-to", "--stdout-file", "--stderr-file"] {
2260        names.insert(injected.to_string());
2261    }
2262    names
2263}
2264
2265fn load_sql(sql: Option<String>, sql_file: Option<String>) -> Result<String, String> {
2266    match (sql, sql_file) {
2267        (Some(s), None) => validate_sql_size(s),
2268        (None, Some(path)) if path == "-" => {
2269            let stdin = std::io::stdin();
2270            read_limited_sql(stdin.lock(), "read --sql-file -")
2271        }
2272        (None, Some(path)) => {
2273            let metadata =
2274                std::fs::metadata(&path).map_err(|e| format!("read --sql-file failed: {e}"))?;
2275            if metadata.is_file() && metadata.len() > MAX_SQL_BYTES as u64 {
2276                return Err(sql_size_error());
2277            }
2278            let file =
2279                std::fs::File::open(&path).map_err(|e| format!("read --sql-file failed: {e}"))?;
2280            read_limited_sql(file, "read --sql-file")
2281        }
2282        (Some(_), Some(_)) => Err("--sql and --sql-file are mutually exclusive".to_string()),
2283        (None, None) => Err("one of --sql or --sql-file is required".to_string()),
2284    }
2285}
2286
2287fn read_limited_sql<R: Read>(reader: R, context: &str) -> Result<String, String> {
2288    let mut buf = Vec::new();
2289    let mut limited = reader.take(MAX_SQL_BYTES as u64 + 1);
2290    limited
2291        .read_to_end(&mut buf)
2292        .map_err(|e| format!("{context} failed: {e}"))?;
2293    if buf.len() > MAX_SQL_BYTES {
2294        return Err(sql_size_error());
2295    }
2296    String::from_utf8(buf).map_err(|e| format!("{context} failed: {e}"))
2297}
2298
2299fn validate_sql_size(sql: String) -> Result<String, String> {
2300    if sql.len() > MAX_SQL_BYTES {
2301        return Err(sql_size_error());
2302    }
2303    Ok(sql)
2304}
2305
2306fn sql_size_error() -> String {
2307    format!("sql exceeds maximum size; maximum SQL size is {MAX_SQL_BYTES} bytes")
2308}
2309
2310fn parse_log_categories(entries: &[String]) -> LogFilters {
2311    cli_parse_log_filters(entries)
2312}
2313
2314fn parse_csv_env(name: &str) -> Vec<String> {
2315    std::env::var(name)
2316        .ok()
2317        .into_iter()
2318        .flat_map(|value| {
2319            value
2320                .split(',')
2321                .map(str::trim)
2322                .filter(|part| !part.is_empty())
2323                .map(std::string::ToString::to_string)
2324                .collect::<Vec<_>>()
2325        })
2326        .collect()
2327}
2328
2329fn startup_env_snapshot() -> Value {
2330    Value::Array(
2331        STARTUP_ENV_KEYS
2332            .iter()
2333            .map(|key| {
2334                json!({
2335                    "key": key,
2336                    "present": std::env::var_os(key).is_some(),
2337                })
2338            })
2339            .collect(),
2340    )
2341}
2342
2343fn startup_args(
2344    mode: &str,
2345    sql: Option<&str>,
2346    sql_file: Option<&str>,
2347    param_count: usize,
2348) -> Value {
2349    json!({
2350        "mode": mode,
2351        "sql": startup_sql_summary(sql, sql_file),
2352        "param_count": param_count,
2353    })
2354}
2355
2356fn with_connection_sources(mut args: Value, sources: &Value) -> Value {
2357    if let (Some(args), Some(sources)) = (args.as_object_mut(), sources.as_object())
2358        && !sources.is_empty()
2359    {
2360        args.insert(
2361            "connection_sources".to_string(),
2362            Value::Object(sources.clone()),
2363        );
2364    }
2365    args
2366}
2367
2368fn startup_sql_summary(sql: Option<&str>, sql_file: Option<&str>) -> Value {
2369    let Some(sql) = sql else {
2370        return json!({
2371            "present": false,
2372            "source": "none",
2373            "bytes": 0,
2374            "chars": 0,
2375            "operation": null,
2376        });
2377    };
2378    json!({
2379        "present": true,
2380        "source": if sql_file.is_some() { "file" } else { "inline" },
2381        "bytes": sql.len(),
2382        "chars": sql.chars().count(),
2383        "operation": sql_operation(sql),
2384    })
2385}
2386
2387fn sql_operation(sql: &str) -> Option<String> {
2388    let sql = trim_leading_sql_comments(sql);
2389    let token: String = sql
2390        .chars()
2391        .skip_while(|c| c.is_whitespace())
2392        .take_while(|c| c.is_ascii_alphabetic() || *c == '_')
2393        .collect();
2394    if token.is_empty() {
2395        None
2396    } else {
2397        Some(token.to_ascii_lowercase())
2398    }
2399}
2400
2401fn trim_leading_sql_comments(mut sql: &str) -> &str {
2402    loop {
2403        sql = sql.trim_start();
2404        if let Some(rest) = sql.strip_prefix("--") {
2405            sql = rest.split_once('\n').map(|(_, rest)| rest).unwrap_or("");
2406            continue;
2407        }
2408        if let Some(rest) = sql.strip_prefix("/*") {
2409            let Some((_, after)) = rest.split_once("*/") else {
2410                return "";
2411            };
2412            sql = after;
2413            continue;
2414        }
2415        return sql;
2416    }
2417}
2418
2419struct PsqlStartupArgs<'a> {
2420    mode: &'a str,
2421    sql: Option<&'a str>,
2422    sql_file: Option<String>,
2423    param_count: usize,
2424}
2425
2426fn psql_startup_args(args: PsqlStartupArgs<'_>) -> Value {
2427    startup_args(
2428        args.mode,
2429        args.sql,
2430        args.sql_file.as_deref(),
2431        args.param_count,
2432    )
2433}
2434
2435pub fn parse_params(entries: &[String]) -> Result<Vec<Value>, String> {
2436    if entries.len() > MAX_PARAMS {
2437        return Err(format!("too many params; maximum params is {MAX_PARAMS}"));
2438    }
2439
2440    let mut by_index: BTreeMap<usize, Value> = BTreeMap::new();
2441    for entry in entries {
2442        let (idx, raw) = split_index_value(entry)?;
2443        if idx == 0 {
2444            return Err("param index must start at 1".to_string());
2445        }
2446        if idx > MAX_PARAMS {
2447            return Err(format!(
2448                "parameter index {idx} exceeds maximum params {MAX_PARAMS}"
2449            ));
2450        }
2451        match by_index.entry(idx) {
2452            Entry::Vacant(slot) => {
2453                slot.insert(parse_param_value(raw));
2454            }
2455            Entry::Occupied(_) => return Err(format!("duplicate parameter index {idx}")),
2456        }
2457    }
2458    if by_index.is_empty() {
2459        return Ok(vec![]);
2460    }
2461    let max = by_index.keys().max().copied().unwrap_or(0);
2462    for i in 1..=max {
2463        if !by_index.contains_key(&i) {
2464            return Err(format!("missing parameter index {i}"));
2465        }
2466    }
2467    Ok(by_index.into_values().collect())
2468}
2469
2470fn split_index_value(entry: &str) -> Result<(usize, &str), String> {
2471    let mut parts = entry.splitn(2, '=');
2472    let left = parts.next().unwrap_or_default();
2473    let right = parts
2474        .next()
2475        .ok_or_else(|| format!("invalid param '{entry}', expected N=value"))?;
2476    let idx = left
2477        .parse::<usize>()
2478        .map_err(|_| format!("invalid param index in '{entry}'"))?;
2479    Ok((idx, right))
2480}
2481
2482fn parse_param_value(v: &str) -> Value {
2483    if let Some(text) = v.strip_prefix("text:") {
2484        return Value::String(text.to_string());
2485    }
2486    if v == "null" {
2487        return Value::Null;
2488    }
2489    if v == "true" {
2490        return Value::Bool(true);
2491    }
2492    if v == "false" {
2493        return Value::Bool(false);
2494    }
2495    // Strings are passed verbatim to PostgreSQL via the text bind path so
2496    // that values like "00123" or "1.0" preserve their original form. The
2497    // server coerces them based on the prepared statement's parameter type.
2498    Value::String(v.to_string())
2499}
2500
2501fn wrap_explain_sql(user_sql: &str, analyze: bool) -> String {
2502    let body = crate::db::trim_trailing_statement_terminators(user_sql);
2503    if analyze {
2504        format!("explain (analyze true, format json, buffers true) {body}")
2505    } else {
2506        format!("explain (format json) {body}")
2507    }
2508}
2509
2510fn optional_string_value(value: Option<String>) -> Value {
2511    value.map(Value::String).unwrap_or(Value::Null)
2512}
2513
2514fn split_table_name(default_schema: String, name: String) -> (String, String) {
2515    match name.split_once('.') {
2516        Some((schema, table)) => (schema.to_string(), table.to_string()),
2517        None => (default_schema, name),
2518    }
2519}
2520
2521fn split_optional_table(default_schema: String, table: Option<String>) -> (String, Option<String>) {
2522    match table {
2523        Some(name) => {
2524            let (schema, table_name) = split_table_name(default_schema, name);
2525            (schema, Some(table_name))
2526        }
2527        None => (default_schema, None),
2528    }
2529}
2530
2531fn full_schema_snapshot_sql(relation_filter: &str, schema_only_filter: &str) -> String {
2532    format!(
2533        "with relation_filter as ( \
2534             select c.oid, c.relname, c.relkind, c.relpersistence, c.reltuples, c.relowner, \
2535                    n.nspname, pg_catalog.obj_description(c.oid, 'pg_class') as comment \
2536             from pg_catalog.pg_class c \
2537             join pg_catalog.pg_namespace n on n.oid = c.relnamespace \
2538             where n.nspname = $1 \
2539               and c.relkind in ('r', 'p', 'f', 'v', 'm', 'S') \
2540               and ({relation_filter}) \
2541         ), snapshot as ( \
2542             select 'extension'::text as kind, \
2543                    n.nspname::text as schema, \
2544                    null::text as relation, \
2545                    e.extname::text as name, \
2546                    'extension'::text as object_type, \
2547                    null::integer as position, \
2548                    null::text as definition, \
2549                    null::bigint as size_bytes, \
2550                    null::text as size, \
2551                    null::bigint as estimated_rows, \
2552                    pg_catalog.jsonb_build_object('version', e.extversion) as payload \
2553             from pg_catalog.pg_extension e \
2554             join pg_catalog.pg_namespace n on n.oid = e.extnamespace \
2555             where n.nspname = $1 and ({schema_only_filter}) \
2556             union all \
2557             select 'relation'::text as kind, \
2558                    rf.nspname::text as schema, \
2559                    rf.relname::text as relation, \
2560                    rf.relname::text as name, \
2561                    case rf.relkind \
2562                        when 'r' then 'table' \
2563                        when 'p' then 'partitioned table' \
2564                        when 'f' then 'foreign table' \
2565                        when 'v' then 'view' \
2566                        when 'm' then 'materialized view' \
2567                        else rf.relkind::text \
2568                    end as object_type, \
2569                    null::integer as position, \
2570                    case when rf.relkind in ('v', 'm') \
2571                         then pg_catalog.pg_get_viewdef(rf.oid, true) end as definition, \
2572                    case when rf.relkind in ('r', 'p', 'm') \
2573                         then pg_catalog.pg_total_relation_size(rf.oid) end as size_bytes, \
2574                    case when rf.relkind in ('r', 'p', 'm') \
2575                         then pg_catalog.pg_size_pretty(pg_catalog.pg_total_relation_size(rf.oid)) end as size, \
2576                    rf.reltuples::bigint as estimated_rows, \
2577                    pg_catalog.jsonb_build_object( \
2578                        'owner', pg_catalog.pg_get_userbyid(rf.relowner), \
2579                        'persistence', rf.relpersistence, \
2580                        'comment', rf.comment \
2581                    ) as payload \
2582             from relation_filter rf \
2583             where rf.relkind in ('r', 'p', 'f', 'v', 'm') \
2584             union all \
2585             select 'sequence'::text as kind, \
2586                    rf.nspname::text as schema, \
2587                    rf.relname::text as relation, \
2588                    rf.relname::text as name, \
2589                    'sequence'::text as object_type, \
2590                    null::integer as position, \
2591                    null::text as definition, \
2592                    pg_catalog.pg_relation_size(rf.oid) as size_bytes, \
2593                    pg_catalog.pg_size_pretty(pg_catalog.pg_relation_size(rf.oid)) as size, \
2594                    null::bigint as estimated_rows, \
2595                    pg_catalog.jsonb_build_object( \
2596                        'owner', pg_catalog.pg_get_userbyid(rf.relowner), \
2597                        'comment', rf.comment \
2598                    ) as payload \
2599             from relation_filter rf \
2600             where rf.relkind = 'S' \
2601             union all \
2602             select 'column'::text as kind, \
2603                    rf.nspname::text as schema, \
2604                    rf.relname::text as relation, \
2605                    a.attname::text as name, \
2606                    pg_catalog.format_type(a.atttypid, a.atttypmod)::text as object_type, \
2607                    a.attnum::integer as position, \
2608                    pg_catalog.pg_get_expr(ad.adbin, ad.adrelid)::text as definition, \
2609                    null::bigint as size_bytes, \
2610                    null::text as size, \
2611                    null::bigint as estimated_rows, \
2612                    pg_catalog.jsonb_build_object( \
2613                        'nullable', not a.attnotnull, \
2614                        'primary_key', coalesce(pk.is_primary, false), \
2615                        'identity', a.attidentity::text, \
2616                        'generated', a.attgenerated::text, \
2617                        'serial_sequence', pg_catalog.pg_get_serial_sequence( \
2618                            pg_catalog.format('%I.%I', rf.nspname, rf.relname), a.attname), \
2619                        'comment', pg_catalog.col_description(rf.oid, a.attnum) \
2620                    ) as payload \
2621             from pg_catalog.pg_attribute a \
2622             join relation_filter rf on rf.oid = a.attrelid \
2623             left join pg_catalog.pg_attrdef ad on ad.adrelid = a.attrelid and ad.adnum = a.attnum \
2624             left join lateral ( \
2625                 select true as is_primary \
2626                 from pg_catalog.pg_index i \
2627                 where i.indrelid = a.attrelid and i.indisprimary \
2628                   and a.attnum = any(i.indkey) \
2629             ) pk on true \
2630             where rf.relkind in ('r', 'p', 'f', 'v', 'm') \
2631               and a.attnum > 0 and not a.attisdropped \
2632             union all \
2633             select 'constraint'::text as kind, \
2634                    rf.nspname::text as schema, \
2635                    rf.relname::text as relation, \
2636                    con.conname::text as name, \
2637                    case con.contype \
2638                        when 'p' then 'primary key' \
2639                        when 'u' then 'unique' \
2640                        when 'f' then 'foreign key' \
2641                        when 'c' then 'check' \
2642                        when 'x' then 'exclusion' \
2643                        else con.contype::text \
2644                    end as object_type, \
2645                    null::integer as position, \
2646                    pg_catalog.pg_get_constraintdef(con.oid, true)::text as definition, \
2647                    null::bigint as size_bytes, \
2648                    null::text as size, \
2649                    null::bigint as estimated_rows, \
2650                    pg_catalog.jsonb_build_object( \
2651                        'type', con.contype::text, \
2652                        'deferrable', con.condeferrable, \
2653                        'deferred_by_default', con.condeferred, \
2654                        'validated', con.convalidated \
2655                    ) as payload \
2656             from pg_catalog.pg_constraint con \
2657             join relation_filter rf on rf.oid = con.conrelid \
2658             union all \
2659             select 'index'::text as kind, \
2660                    rf.nspname::text as schema, \
2661                    rf.relname::text as relation, \
2662                    ic.relname::text as name, \
2663                    am.amname::text as object_type, \
2664                    null::integer as position, \
2665                    pg_catalog.pg_get_indexdef(i.indexrelid)::text as definition, \
2666                    pg_catalog.pg_relation_size(i.indexrelid) as size_bytes, \
2667                    pg_catalog.pg_size_pretty(pg_catalog.pg_relation_size(i.indexrelid)) as size, \
2668                    null::bigint as estimated_rows, \
2669                    pg_catalog.jsonb_build_object( \
2670                        'unique', i.indisunique, \
2671                        'primary', i.indisprimary, \
2672                        'valid', i.indisvalid, \
2673                        'ready', i.indisready \
2674                    ) as payload \
2675             from pg_catalog.pg_index i \
2676             join pg_catalog.pg_class ic on ic.oid = i.indexrelid \
2677             join relation_filter rf on rf.oid = i.indrelid \
2678             join pg_catalog.pg_am am on am.oid = ic.relam \
2679             union all \
2680             select 'trigger'::text as kind, \
2681                    rf.nspname::text as schema, \
2682                    rf.relname::text as relation, \
2683                    tg.tgname::text as name, \
2684                    'trigger'::text as object_type, \
2685                    null::integer as position, \
2686                    pg_catalog.pg_get_triggerdef(tg.oid, true)::text as definition, \
2687                    null::bigint as size_bytes, \
2688                    null::text as size, \
2689                    null::bigint as estimated_rows, \
2690                    pg_catalog.jsonb_build_object( \
2691                        'enabled', tg.tgenabled::text, \
2692                        'function_schema', fn_ns.nspname, \
2693                        'function_name', fn.proname \
2694                    ) as payload \
2695             from pg_catalog.pg_trigger tg \
2696             join relation_filter rf on rf.oid = tg.tgrelid \
2697             join pg_catalog.pg_proc fn on fn.oid = tg.tgfoid \
2698             join pg_catalog.pg_namespace fn_ns on fn_ns.oid = fn.pronamespace \
2699             where not tg.tgisinternal \
2700             union all \
2701             select 'function'::text as kind, \
2702                    n.nspname::text as schema, \
2703                    null::text as relation, \
2704                    (p.proname || '(' || pg_catalog.pg_get_function_identity_arguments(p.oid) || ')')::text as name, \
2705                    'function'::text as object_type, \
2706                    null::integer as position, \
2707                    pg_catalog.pg_get_functiondef(p.oid)::text as definition, \
2708                    null::bigint as size_bytes, \
2709                    null::text as size, \
2710                    null::bigint as estimated_rows, \
2711                    pg_catalog.jsonb_build_object( \
2712                        'language', l.lanname, \
2713                        'result', pg_catalog.pg_get_function_result(p.oid), \
2714                        'identity_args', pg_catalog.pg_get_function_identity_arguments(p.oid) \
2715                    ) as payload \
2716             from pg_catalog.pg_proc p \
2717             join pg_catalog.pg_namespace n on n.oid = p.pronamespace \
2718             join pg_catalog.pg_language l on l.oid = p.prolang \
2719             where n.nspname = $1 \
2720               and p.prokind = 'f' \
2721               and ({schema_only_filter}) \
2722               and not exists ( \
2723                   select 1 \
2724                   from pg_catalog.pg_depend d \
2725                   where d.classid = 'pg_catalog.pg_proc'::regclass \
2726                     and d.objid = p.oid \
2727                     and d.deptype = 'e' \
2728               ) \
2729         ) \
2730         select * from snapshot \
2731         order by case kind \
2732                    when 'extension' then 0 \
2733                    when 'relation' then 1 \
2734                    when 'sequence' then 2 \
2735                    when 'column' then 3 \
2736                    when 'constraint' then 4 \
2737                    when 'index' then 5 \
2738                    when 'trigger' then 6 \
2739                    when 'function' then 7 \
2740                    else 99 end, \
2741                  schema, relation nulls first, position nulls last, name"
2742    )
2743}
2744
2745fn build_schema_snapshot_sql(args: InspectSchemaArgs) -> (String, Vec<Value>) {
2746    (
2747        full_schema_snapshot_sql("$2::text is null or c.relname like $2", "$2::text is null"),
2748        vec![Value::String(args.schema), optional_string_value(args.like)],
2749    )
2750}
2751
2752fn build_table_full_sql(schema: String, name: String) -> (String, Vec<Value>) {
2753    (
2754        full_schema_snapshot_sql("c.relname = $2", "false"),
2755        vec![Value::String(schema), Value::String(name)],
2756    )
2757}
2758
2759fn build_inspect_indexes_sql(args: InspectIndexesArgs) -> (String, Vec<Value>) {
2760    let (schema, table) = split_optional_table(args.schema, args.table);
2761    let mut sql = String::from(
2762        "select n.nspname as schema, \
2763                tc.relname as table, \
2764                ic.relname as name, \
2765                am.amname as method, \
2766                i.indisunique as unique, \
2767                i.indisprimary as primary, \
2768                i.indisvalid as valid, \
2769                i.indisready as ready, \
2770                pg_catalog.pg_get_indexdef(i.indexrelid) as definition, \
2771                pg_catalog.pg_relation_size(i.indexrelid) as size_bytes, \
2772                pg_catalog.pg_size_pretty(pg_catalog.pg_relation_size(i.indexrelid)) as size",
2773    );
2774    if args.stats {
2775        sql.push_str(
2776            ", s.idx_scan as index_scan_count, \
2777             s.idx_tup_read as index_tuple_read_count, \
2778             s.idx_tup_fetch as index_tuple_fetch_count",
2779        );
2780    }
2781    sql.push_str(
2782        " from pg_catalog.pg_index i \
2783          join pg_catalog.pg_class ic on ic.oid = i.indexrelid \
2784          join pg_catalog.pg_class tc on tc.oid = i.indrelid \
2785          join pg_catalog.pg_namespace n on n.oid = tc.relnamespace \
2786          join pg_catalog.pg_am am on am.oid = ic.relam",
2787    );
2788    if args.stats {
2789        sql.push_str(" left join pg_catalog.pg_stat_user_indexes s on s.indexrelid = i.indexrelid");
2790    }
2791    sql.push_str(" where n.nspname = $1");
2792
2793    let mut params = vec![Value::String(schema)];
2794    if let Some(table_name) = table {
2795        sql.push_str(" and tc.relname = $2");
2796        params.push(Value::String(table_name));
2797    }
2798    sql.push_str(" order by tc.relname, ic.relname");
2799    (sql, params)
2800}
2801
2802fn build_inspect_sql(action: InspectAction) -> (String, Vec<Value>) {
2803    match action {
2804        InspectAction::Databases(args) => {
2805            let mut sql = String::from(
2806                "select d.datname as database, \
2807                        pg_catalog.pg_get_userbyid(d.datdba) as owner, \
2808                        pg_catalog.pg_encoding_to_char(d.encoding) as encoding, \
2809                        d.datcollate as collate, \
2810                        d.datctype as ctype, \
2811                        d.datistemplate as is_template, \
2812                        d.datallowconn as allow_connections, \
2813                        d.datconnlimit as connection_limit, \
2814                        case when has_database_privilege(d.datname, 'CONNECT') \
2815                             then pg_catalog.pg_database_size(d.oid) end as size_bytes, \
2816                        case when has_database_privilege(d.datname, 'CONNECT') \
2817                             then pg_catalog.pg_size_pretty(pg_catalog.pg_database_size(d.oid)) end as size, \
2818                        s.numbackends as active_connections \
2819                 from pg_catalog.pg_database d \
2820                 left join pg_catalog.pg_stat_database s on s.datid = d.oid",
2821            );
2822            if !args.all {
2823                sql.push_str(" where not d.datistemplate");
2824            }
2825            sql.push_str(" order by d.datname");
2826            (sql, vec![])
2827        }
2828        InspectAction::Database => (
2829            "with rels as ( \
2830                 select c.relkind \
2831                 from pg_catalog.pg_class c \
2832                 join pg_catalog.pg_namespace n on n.oid = c.relnamespace \
2833                 where n.nspname not in ('pg_catalog', 'information_schema') \
2834                   and n.nspname not like 'pg_toast%' \
2835                   and n.nspname not like 'pg_temp_%' \
2836             ) \
2837             select current_database() as database, \
2838                    ( select count(*) from pg_catalog.pg_namespace n \
2839                       where n.nspname not in ('pg_catalog', 'information_schema') \
2840                         and n.nspname not like 'pg_toast%' \
2841                         and n.nspname not like 'pg_temp_%' ) as schemas, \
2842                    count(*) filter (where relkind in ('r', 'p')) as tables, \
2843                    count(*) filter (where relkind = 'v') as views, \
2844                    count(*) filter (where relkind = 'm') as materialized_views, \
2845                    count(*) filter (where relkind = 'S') as sequences, \
2846                    pg_catalog.pg_database_size(current_database()) as size_bytes, \
2847                    pg_catalog.pg_size_pretty(pg_catalog.pg_database_size(current_database())) as size \
2848             from rels"
2849                .to_string(),
2850            vec![],
2851        ),
2852        InspectAction::Schemas => (
2853            "select n.nspname as schema, \
2854                    pg_catalog.pg_get_userbyid(n.nspowner) as owner, \
2855                    count(*) filter (where c.relkind in ('r', 'p')) as tables, \
2856                    count(*) filter (where c.relkind = 'v') as views, \
2857                    count(*) filter (where c.relkind = 'm') as materialized_views, \
2858                    count(*) filter (where c.relkind = 'S') as sequences, \
2859                    pg_catalog.pg_size_pretty(coalesce( \
2860                        sum(pg_catalog.pg_total_relation_size(c.oid)) \
2861                            filter (where c.relkind in ('r', 'p', 'm')), 0)) as size \
2862             from pg_catalog.pg_namespace n \
2863             left join pg_catalog.pg_class c on c.relnamespace = n.oid \
2864             where n.nspname not in ('pg_catalog', 'information_schema') \
2865               and n.nspname not like 'pg_toast%' \
2866               and n.nspname not like 'pg_temp_%' \
2867             group by n.nspname, n.nspowner \
2868             order by n.nspname"
2869                .to_string(),
2870            vec![],
2871        ),
2872        InspectAction::Schema(args) | InspectAction::Snapshot(args) => build_schema_snapshot_sql(args),
2873        InspectAction::Tables(args) => {
2874            let mut sql = String::from(
2875                "select n.nspname as schema, \
2876                        c.relname as name, \
2877                        case c.relkind when 'r' then 'table' \
2878                                       when 'p' then 'partitioned table' \
2879                                       when 'f' then 'foreign table' end as kind, \
2880                        pg_catalog.pg_get_userbyid(c.relowner) as owner, \
2881                        c.reltuples::bigint as estimated_rows, \
2882                        pg_catalog.pg_size_pretty(pg_catalog.pg_total_relation_size(c.oid)) as size, \
2883                        pg_catalog.pg_total_relation_size(c.oid) as size_bytes \
2884                 from pg_catalog.pg_class c \
2885                 join pg_catalog.pg_namespace n on n.oid = c.relnamespace \
2886                 where n.nspname = $1 and c.relkind in ('r', 'p', 'f')",
2887            );
2888            let mut params = vec![Value::String(args.schema)];
2889            if let Some(pattern) = args.like {
2890                sql.push_str(" and c.relname like $2");
2891                params.push(Value::String(pattern));
2892            }
2893            sql.push_str(" order by c.relname");
2894            (sql, params)
2895        }
2896        InspectAction::Views(args) => {
2897            let mut sql = String::from(
2898                "select n.nspname as schema, \
2899                        c.relname as name, \
2900                        case c.relkind when 'm' then true else false end as materialized, \
2901                        pg_catalog.pg_get_userbyid(c.relowner) as owner \
2902                 from pg_catalog.pg_class c \
2903                 join pg_catalog.pg_namespace n on n.oid = c.relnamespace \
2904                 where n.nspname = $1 and c.relkind in ('v', 'm')",
2905            );
2906            let mut params = vec![Value::String(args.schema)];
2907            if let Some(pattern) = args.like {
2908                sql.push_str(" and c.relname like $2");
2909                params.push(Value::String(pattern));
2910            }
2911            sql.push_str(" order by c.relname");
2912            (sql, params)
2913        }
2914        InspectAction::Indexes(args) => build_inspect_indexes_sql(args),
2915        InspectAction::Table(args) => {
2916            let (schema, name) = split_table_name("public".to_string(), args.name);
2917            if args.full {
2918                return build_table_full_sql(schema, name);
2919            }
2920            (
2921                "select a.attname as name, \
2922                        pg_catalog.format_type(a.atttypid, a.atttypmod) as type, \
2923                        not a.attnotnull as nullable, \
2924                        pg_catalog.pg_get_expr(ad.adbin, ad.adrelid) as default, \
2925                        a.attnum as position, \
2926                        coalesce(pk.is_primary, false) as primary_key, \
2927                        pg_catalog.col_description(c.oid, a.attnum) as comment \
2928                 from pg_catalog.pg_attribute a \
2929                 join pg_catalog.pg_class c on c.oid = a.attrelid \
2930                 join pg_catalog.pg_namespace n on n.oid = c.relnamespace \
2931                 left join pg_catalog.pg_attrdef ad \
2932                     on ad.adrelid = a.attrelid and ad.adnum = a.attnum \
2933                 left join lateral ( \
2934                     select true as is_primary \
2935                     from pg_catalog.pg_index i \
2936                     where i.indrelid = a.attrelid and i.indisprimary \
2937                       and a.attnum = any(i.indkey) \
2938                 ) pk on true \
2939                 where n.nspname = $1 and c.relname = $2 \
2940                   and a.attnum > 0 and not a.attisdropped \
2941                 order by a.attnum"
2942                    .to_string(),
2943                vec![Value::String(schema), Value::String(name)],
2944            )
2945        }
2946    }
2947}
2948
2949#[cfg(test)]
2950#[path = "../tests/support/unit_cli.rs"]
2951mod tests;