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 All,
109 Codex,
111 ClaudeCode,
113 Opencode,
115 Hermes,
117}
118
119#[derive(Clone, Copy, Debug, PartialEq, Eq)]
120pub enum SkillScope {
121 Personal,
123 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
145pub 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#[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 fn invalid_request(message: impl Into<String>) -> Self {
217 Self::new(crate::protocol::error_code::INVALID_REQUEST, message)
218 }
219
220 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
235pub 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
253const 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
295fn 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
307fn 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
321pub 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 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
413fn 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
542fn 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 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
799fn 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
881pub 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 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 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
1019fn 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
1063fn 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
1078fn 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
1088struct 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 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 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 session
1235 .container
1236 .selected_driver()
1237 .map_err(ParseError::invalid_value)?;
1238 Ok(Connection { session, sources })
1239}
1240
1241fn 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
1327fn 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
1504struct 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 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
2206fn 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
2245fn 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 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;