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    LogFilters, OutputFormat, OutputTo, cli_parse_log_filters, cli_parse_output,
8};
9use clap::{Args, CommandFactory, FromArgMatches, Parser, Subcommand, ValueEnum};
10use serde_json::{Value, json};
11use std::collections::{BTreeMap, btree_map::Entry};
12
13const STARTUP_ENV_KEYS: &[&str] = &[
14    "AFPSQL_DSN_SECRET",
15    "AFPSQL_CONNINFO_SECRET",
16    "AFPSQL_HOST",
17    "AFPSQL_PORT",
18    "AFPSQL_USER",
19    "AFPSQL_DBNAME",
20    "AFPSQL_PASSWORD_SECRET",
21    "AFPSQL_SSH",
22    "AFPSQL_SSH_LOCAL_HOST",
23    "AFPSQL_SSH_LOCAL_PORT",
24    "AFPSQL_SSH_REMOTE_SOCKET",
25    "AFPSQL_SSH_SUDO_USER",
26    "AFPSQL_CONTAINER",
27    "AFPSQL_CONTAINER_DRIVER",
28    "AFPSQL_CONTAINER_RUNTIME",
29    "AFPSQL_CONTAINER_USER",
30    "AFPSQL_CONTAINER_NAMESPACE",
31    "AFPSQL_CONTAINER_CONTEXT",
32    "AFPSQL_CONTAINER_COMPOSE_FILE",
33    "AFPSQL_CONTAINER_COMPOSE_PROJECT",
34    "AFPSQL_CONTAINER_POD_CONTAINER",
35    "PGHOST",
36    "PGPORT",
37    "PGUSER",
38    "PGDATABASE",
39    "PGPASSWORD",
40    "PGSSLMODE",
41];
42
43pub enum Mode {
44    Cli(CliRequest),
45    Pipe(PipeInit),
46    PsqlAdmin(PsqlAdminRequest),
47    SkillAdmin(SkillAdminRequest),
48    PsqlUnsupported(PsqlUnsupportedRequest),
49}
50
51pub struct PipeInit {
52    pub output: OutputFormat,
53    pub session: SessionConfig,
54    pub log: LogFilters,
55    pub startup_args: Value,
56    pub startup_env: Value,
57    pub startup_requested: bool,
58}
59
60#[derive(Debug, Clone)]
61pub struct PsqlAdminRequest {
62    pub action: PsqlAdminAction,
63    pub output: OutputFormat,
64}
65
66#[derive(Debug, Clone)]
67pub enum PsqlAdminAction {
68    Status { bin_dir: Option<String> },
69    Install { bin_dir: Option<String> },
70    Uninstall { bin_dir: Option<String> },
71}
72
73#[derive(Debug, Clone)]
74pub struct SkillAdminRequest {
75    pub action: SkillAdminAction,
76    pub output: OutputFormat,
77}
78
79#[derive(Debug, Clone)]
80pub enum SkillAdminAction {
81    Status(SkillAdminOptions),
82    Install(SkillAdminOptions),
83    Uninstall(SkillAdminOptions),
84}
85
86#[derive(Debug, Clone)]
87pub struct SkillAdminOptions {
88    pub agent: SkillAgentSelection,
89    pub scope: SkillScope,
90    pub skills_dir: Option<String>,
91    pub force: bool,
92}
93
94#[derive(Clone, Copy, Debug, PartialEq, Eq, ValueEnum)]
95pub enum SkillAgentSelection {
96    /// Manage every agent that supports the requested scope.
97    All,
98    /// Manage the Codex local skill under $CODEX_HOME/skills.
99    Codex,
100    /// Manage the Claude Code skill under ~/.claude/skills or .claude/skills.
101    #[value(name = "claude-code", alias = "claude")]
102    ClaudeCode,
103    /// Manage the opencode skill under ~/.config/opencode/skills or .opencode/skills.
104    Opencode,
105    /// Manage the Hermes skill under $HERMES_HOME/skills or ~/.hermes/skills.
106    Hermes,
107}
108
109#[derive(Clone, Copy, Debug, PartialEq, Eq, ValueEnum)]
110pub enum SkillScope {
111    /// Install under the user-level skills directory.
112    Personal,
113    /// Install under the current workspace's skills directory.
114    Workspace,
115}
116
117pub struct CliRequest {
118    pub sql: String,
119    pub params: Vec<Value>,
120    pub options: QueryOptions,
121    pub session: SessionConfig,
122    pub output: OutputFormat,
123    pub log: LogFilters,
124    pub startup_args: Value,
125    pub startup_env: Value,
126    pub startup_requested: bool,
127    pub dry_run: bool,
128    pub psql_mode: bool,
129}
130
131pub struct PsqlUnsupportedRequest {
132    pub reason: String,
133}
134
135#[derive(Clone, Copy, Debug, PartialEq, Eq, ValueEnum)]
136enum RuntimeMode {
137    Cli,
138    Pipe,
139    #[value(name = "psql")]
140    Psql,
141}
142
143#[derive(Subcommand)]
144enum AfdCommand {
145    /// Manage the local psql wrapper for afpsql --mode psql.
146    Psql(PsqlCommand),
147    /// Manage Agent-First PSQL skills for Codex, Claude Code, opencode, and Hermes.
148    Skill(SkillCommand),
149    /// Schema discovery: inspect databases, schemas, tables, indexes, or snapshots.
150    Inspect(InspectCommand),
151}
152
153#[derive(Args)]
154struct InspectCommand {
155    #[command(subcommand)]
156    action: InspectAction,
157}
158
159#[derive(Subcommand)]
160enum InspectAction {
161    /// List databases on the connected server with size, encoding, and connection facts.
162    Databases(InspectDatabasesArgs),
163    /// Summarize the connected database: schema/table/view/sequence counts and size.
164    Database,
165    /// List user-visible schemas.
166    Schemas,
167    /// Export full schema metadata for one schema.
168    Schema(InspectSchemaArgs),
169    /// Export a stable full-schema snapshot for machine consumption.
170    Snapshot(InspectSchemaArgs),
171    /// List tables in a schema with owner, estimated rows, and size.
172    Tables(InspectTablesArgs),
173    /// List views (regular and materialized) in a schema with owner.
174    Views(InspectViewsArgs),
175    /// List indexes with definitions, size, validity, and optional usage stats.
176    Indexes(InspectIndexesArgs),
177    /// Describe a table's columns: types, nullability, defaults, primary key, comments.
178    Table(InspectTableArgs),
179}
180
181#[derive(Args)]
182struct InspectDatabasesArgs {
183    /// Include template databases (template0/template1) in the listing.
184    #[arg(long = "all")]
185    all: bool,
186}
187
188#[derive(Args)]
189struct InspectTablesArgs {
190    /// Schema to filter on. Defaults to `public`.
191    #[arg(long = "schema", default_value = "public")]
192    schema: String,
193    /// Optional `LIKE` pattern matched against the table name (use `%` as wildcard).
194    #[arg(long = "like")]
195    like: Option<String>,
196}
197
198#[derive(Args)]
199struct InspectSchemaArgs {
200    /// Schema to inspect. Defaults to `public`.
201    #[arg(long = "schema", default_value = "public")]
202    schema: String,
203    /// Optional `LIKE` pattern matched against relation names (use `%` as wildcard).
204    #[arg(long = "like")]
205    like: Option<String>,
206}
207
208#[derive(Args)]
209struct InspectViewsArgs {
210    /// Schema to filter on. Defaults to `public`.
211    #[arg(long = "schema", default_value = "public")]
212    schema: String,
213    /// Optional `LIKE` pattern matched against the view name (use `%` as wildcard).
214    #[arg(long = "like")]
215    like: Option<String>,
216}
217
218#[derive(Args)]
219struct InspectIndexesArgs {
220    /// Schema to filter on. Defaults to `public`.
221    #[arg(long = "schema", default_value = "public")]
222    schema: String,
223    /// Optional table name to filter on. Accepts `schema.table` to override --schema.
224    #[arg(long = "table")]
225    table: Option<String>,
226    /// Include PostgreSQL's built-in pg_stat_user_indexes usage counters.
227    #[arg(long = "stats")]
228    stats: bool,
229}
230
231#[derive(Args)]
232struct InspectTableArgs {
233    /// Table name. Accepts `schema.table`; defaults to `public.NAME` when unqualified.
234    name: String,
235    /// Include relation, constraints, indexes, triggers, and sequence/default metadata.
236    #[arg(long = "full")]
237    full: bool,
238}
239
240#[derive(Args)]
241struct PsqlCommand {
242    #[command(subcommand)]
243    action: PsqlCliAction,
244}
245
246#[derive(Subcommand)]
247enum PsqlCliAction {
248    /// Show whether the afpsql-managed psql wrapper is installed and active.
249    Status(PsqlPathArgs),
250    /// Install an afpsql-managed psql wrapper.
251    Install(PsqlPathArgs),
252    /// Remove an afpsql-managed psql wrapper.
253    Uninstall(PsqlPathArgs),
254}
255
256#[derive(Args)]
257struct PsqlPathArgs {
258    /// Directory that contains the psql wrapper. Defaults to the afpsql executable directory.
259    #[arg(long = "bin-dir")]
260    bin_dir: Option<String>,
261}
262
263#[derive(Args)]
264struct SkillCommand {
265    #[command(subcommand)]
266    action: SkillCliAction,
267}
268
269#[derive(Subcommand)]
270enum SkillCliAction {
271    /// Show whether the Agent-First PSQL skill is installed, valid, and up to date.
272    Status(SkillTargetArgs),
273    /// Install the Agent-First PSQL skill.
274    Install(SkillWriteArgs),
275    /// Remove an afpsql-managed Agent-First PSQL skill.
276    Uninstall(SkillWriteArgs),
277}
278
279#[derive(Args)]
280struct SkillTargetArgs {
281    /// Agent to manage. Defaults to all personal skill targets.
282    #[arg(long = "agent", value_enum, default_value_t = SkillAgentSelection::All)]
283    agent: SkillAgentSelection,
284    /// Skill scope.
285    #[arg(long = "scope", value_enum, default_value_t = SkillScope::Personal)]
286    scope: SkillScope,
287    /// Directory that contains skill folders. Requires an explicit single --agent.
288    #[arg(long = "skills-dir")]
289    skills_dir: Option<String>,
290}
291
292#[derive(Args)]
293struct SkillWriteArgs {
294    #[command(flatten)]
295    target: SkillTargetArgs,
296    /// Overwrite or remove an unmanaged Agent-First PSQL skill at the target path.
297    #[arg(long)]
298    force: bool,
299}
300
301#[doc = r#"`afpsql` gives agents a reliable PostgreSQL contract: structured
302AFDATA events, first-class SSH/container transports, explicit write permissions,
303stable pipe sessions, and machine-readable failures.
304
305### Interface Policy
306
307- default mode is canonical agent-first CLI
308- `--mode psql` is argument translation only; runtime output stays JSONL
309- a finite query splits by kind: result to stdout, errors/logs/progress to stderr
310- `--mode pipe` and `--stream-rows` are ordered event streams and stay on one stream (stdout)
311- `--output-to split|stdout|stderr` overrides the destination; `split` is rejected for a stream
312- native CLI and pipe mode default to read-only transactions; writes require permission
313- SSH/container transports keep afpsql local instead of running human `psql` across boundaries
314
315### Modes
316
317- default (native CLI): one SQL action per process — a single agent step
318- `--mode pipe`: a long-lived JSONL session with `id` correlation and named sessions for multi-step work
319- `--mode psql`: run existing `psql` scripts unchanged — flags are translated, runtime output stays JSONL
320
321### Query Sources and Parameters
322
323- use `--sql` for inline SQL or `--sql-file` for a file
324- use repeatable `--param N=value` for positional binds
325- placeholder count is validated from prepared-statement metadata, not by SQL text scanning
326
327### Connection Sources
328
329- `--dsn-secret` for a PostgreSQL URI
330- `--conninfo-secret` for libpq-style conninfo
331- or discrete `--host`, `--port`, `--user`, `--dbname`, `--password-secret`
332- every `*-secret` flag has a `*-secret-env` partner that reads the value from a named environment variable
333- every secret slot also has a `*-secret-config FILE DOT_PATH` source for JSON, TOML, YAML, or dotenv
334- add `--ssh user@server` when PostgreSQL is reachable only from the server boundary
335- with `--ssh`, DSN/conninfo endpoints are interpreted from the final SSH host and secrets stay local
336- SSH and container transports currently require one PostgreSQL endpoint, not a multi-host failover list
337- add `--container TARGET` when PostgreSQL is reachable only from inside a container boundary
338- use named container scope flags instead of raw driver option passthrough
339- use `--container-driver docker|podman|nerdctl|compose|kubectl` for the exec syntax
340- combine `--ssh user@server --container TARGET` for containers on an SSH host
341- agent-first environment fallbacks: `AFPSQL_*`
342- PostgreSQL environment fallbacks: `PGHOST`, `PGPORT`, `PGUSER`, `PGDATABASE`, `PGPASSWORD`, `PGSSLMODE`
343
344### Result Shaping
345
346- default mode buffers a bounded inline result
347- use `--stream-rows` for large result sets, with `--batch-rows` and `--batch-bytes` to tune chunk size
348- `--output json|yaml|plain` changes rendering only, not the runtime schema
349
350### Examples
351
352```text
353afpsql --sql "select now() as now_rfc3339"
354afpsql --sql-file ./query.sql
355afpsql --sql 'select * from users where id = $1' --param 1=123
356afpsql --dsn-secret-env DATABASE_URL --sql "select 1"
357afpsql --dsn-secret-config config.yaml database.url --sql "select 1"
358afpsql --ssh user@server --dsn-secret-config config.yaml database.url --sql "select 1"
359afpsql --container pg-container --dsn-secret-env DATABASE_URL --sql "select 1"
360afpsql --ssh root@server --container app --host host.container.internal --port 5432 --user app --dbname appdb --sql "select 1"
361afpsql --mode psql -h 127.0.0.1 -p 5432 -U app -d appdb -c "select 1"
362afpsql --sql "select * from big_table" --stream-rows --batch-rows 1000
363afpsql --mode pipe
364afpsql psql status
365afpsql psql install
366afpsql skill status
367afpsql skill install
368```
369
370### Exit Codes
371
372- `0`: query completed successfully
373- `1`: SQL error or runtime error
374- `2`: invalid CLI arguments
375"#]
376#[derive(Parser)]
377#[command(
378    name = env!("DISPLAY_NAME"),
379    bin_name = "afpsql",
380    verbatim_doc_comment,
381    about = env!("CARGO_PKG_DESCRIPTION"),
382    disable_help_flag = true,
383    disable_version_flag = true,
384    disable_help_subcommand = true,
385)]
386pub struct AfdCli {
387    /// Inline SQL string to execute.
388    #[arg(long, allow_hyphen_values = true, help_heading = "Query")]
389    sql: Option<String>,
390    /// Read SQL from a file.
391    #[arg(long = "sql-file", allow_hyphen_values = true, help_heading = "Query")]
392    sql_file: Option<String>,
393    /// Positional bind parameter in `N=value` form. Repeat for additional parameters.
394    #[arg(long = "param", help_heading = "Query")]
395    param: Vec<String>,
396    /// Stream large results as `result_rows` batches.
397    ///
398    /// Avoids buffering one large inline result.
399    #[arg(long = "stream-rows", help_heading = "Query")]
400    stream_rows: bool,
401    /// Maximum rows per streamed batch.
402    #[arg(long = "batch-rows", help_heading = "Query")]
403    batch_rows: Option<usize>,
404    /// Soft byte target per streamed batch.
405    #[arg(long = "batch-bytes", help_heading = "Query")]
406    batch_bytes: Option<usize>,
407    /// Per-query statement timeout in milliseconds.
408    #[arg(long = "statement-timeout-ms", help_heading = "Query")]
409    statement_timeout_ms: Option<u64>,
410    /// Per-query lock timeout in milliseconds.
411    #[arg(long = "lock-timeout-ms", help_heading = "Query")]
412    lock_timeout_ms: Option<u64>,
413    /// Maximum inline rows before returning `result_too_large`.
414    #[arg(long = "inline-max-rows", help_heading = "Query")]
415    inline_max_rows: Option<usize>,
416    /// Maximum inline payload bytes before returning `result_too_large`.
417    #[arg(long = "inline-max-bytes", help_heading = "Query")]
418    inline_max_bytes: Option<usize>,
419    /// Query permission policy.
420    ///
421    /// Defaults to read, ssh-read with --ssh, or container-read with
422    /// --container.
423    #[arg(long = "permission", value_enum, help_heading = "Query")]
424    permission: Option<Permission>,
425    /// Preview the query without executing it
426    #[arg(long, help_heading = "Query")]
427    dry_run: bool,
428    /// Wrap the query in EXPLAIN (FORMAT JSON) and return the plan tree instead
429    /// of executing the user's SQL.
430    #[arg(
431        long = "explain",
432        help_heading = "Query",
433        conflicts_with = "explain_analyze"
434    )]
435    explain: bool,
436    /// Run EXPLAIN ANALYZE with JSON and buffer metrics.
437    ///
438    /// The underlying SQL actually runs; writes require the matching write
439    /// permission.
440    #[arg(long = "explain-analyze", help_heading = "Query")]
441    explain_analyze: bool,
442
443    /// PostgreSQL DSN URI. Redacted in structured output.
444    #[arg(
445        long = "dsn-secret",
446        global = true,
447        help_heading = "Connection",
448        conflicts_with_all = ["dsn_secret_env", "dsn_secret_config"]
449    )]
450    dsn_secret: Option<String>,
451    /// Read PostgreSQL DSN URI from an environment variable.
452    #[arg(
453        long = "dsn-secret-env",
454        global = true,
455        help_heading = "Connection",
456        conflicts_with = "dsn_secret_config"
457    )]
458    dsn_secret_env: Option<String>,
459    /// Read PostgreSQL DSN URI from FILE at DOT_PATH.
460    #[arg(
461        long = "dsn-secret-config",
462        global = true,
463        help_heading = "Connection",
464        value_names = ["FILE", "DOT_PATH"],
465        num_args = 2
466    )]
467    dsn_secret_config: Option<Vec<String>>,
468    /// libpq-style conninfo string. Redacted in structured output.
469    #[arg(
470        long = "conninfo-secret",
471        global = true,
472        help_heading = "Connection",
473        conflicts_with_all = ["conninfo_secret_env", "conninfo_secret_config"]
474    )]
475    conninfo_secret: Option<String>,
476    /// Read libpq-style conninfo string from an environment variable.
477    #[arg(
478        long = "conninfo-secret-env",
479        global = true,
480        help_heading = "Connection",
481        conflicts_with = "conninfo_secret_config"
482    )]
483    conninfo_secret_env: Option<String>,
484    /// Read libpq-style conninfo from FILE at DOT_PATH.
485    #[arg(
486        long = "conninfo-secret-config",
487        global = true,
488        help_heading = "Connection",
489        value_names = ["FILE", "DOT_PATH"],
490        num_args = 2
491    )]
492    conninfo_secret_config: Option<Vec<String>>,
493    /// PostgreSQL host.
494    #[arg(long, global = true, help_heading = "Connection")]
495    host: Option<String>,
496    /// PostgreSQL port.
497    #[arg(long, global = true, help_heading = "Connection")]
498    port: Option<u16>,
499    /// PostgreSQL user name.
500    #[arg(long, global = true, help_heading = "Connection")]
501    user: Option<String>,
502    /// PostgreSQL database name.
503    #[arg(long, global = true, help_heading = "Connection")]
504    dbname: Option<String>,
505    /// PostgreSQL password. Redacted in structured output.
506    #[arg(
507        long = "password-secret",
508        global = true,
509        help_heading = "Connection",
510        conflicts_with_all = ["password_secret_env", "password_secret_config"]
511    )]
512    password_secret: Option<String>,
513    /// Read PostgreSQL password from an environment variable.
514    #[arg(
515        long = "password-secret-env",
516        global = true,
517        help_heading = "Connection",
518        conflicts_with = "password_secret_config"
519    )]
520    password_secret_env: Option<String>,
521    /// Read PostgreSQL password from FILE at DOT_PATH.
522    #[arg(
523        long = "password-secret-config",
524        global = true,
525        help_heading = "Connection",
526        value_names = ["FILE", "DOT_PATH"],
527        num_args = 2
528    )]
529    password_secret_config: Option<Vec<String>>,
530    /// Open an SSH transport to USER@HOST before connecting to PostgreSQL.
531    #[arg(long = "ssh", global = true, help_heading = "SSH Transport")]
532    ssh: Option<String>,
533    /// SSH hop to reach before the final --ssh destination. Repeat for multiple hops.
534    #[arg(long = "ssh-via", global = true, help_heading = "SSH Transport")]
535    ssh_via: Vec<String>,
536    /// Additional OpenSSH -o option. Repeat for multiple options.
537    #[arg(long = "ssh-option", global = true, help_heading = "SSH Transport")]
538    ssh_options: Vec<String>,
539    /// Local bind host for the SSH tunnel.
540    #[arg(long = "ssh-local-host", global = true, help_heading = "SSH Transport")]
541    ssh_local_host: Option<String>,
542    /// Local bind port for the SSH tunnel. Defaults to an ephemeral port.
543    #[arg(long = "ssh-local-port", global = true, help_heading = "SSH Transport")]
544    ssh_local_port: Option<u16>,
545    /// Explicit remote PostgreSQL Unix socket path for SSH forwarding.
546    #[arg(
547        long = "ssh-remote-socket",
548        global = true,
549        help_heading = "SSH Transport"
550    )]
551    ssh_remote_socket: Option<String>,
552    /// Remote OS user for sudo -n Unix-socket bridge mode; requires an explicit socket.
553    #[arg(long = "ssh-sudo-user", global = true, help_heading = "SSH Transport")]
554    ssh_sudo_user: Option<String>,
555
556    /// Run a container exec stdio bridge in TARGET before connecting to PostgreSQL.
557    #[arg(
558        long = "container",
559        global = true,
560        help_heading = "Container Transport"
561    )]
562    container: Option<String>,
563    /// Container exec driver: docker, podman, nerdctl, compose, or kubectl.
564    #[arg(
565        long = "container-driver",
566        global = true,
567        help_heading = "Container Transport"
568    )]
569    container_driver: Option<String>,
570    /// Runtime command for the selected container driver. Defaults to the driver command.
571    #[arg(
572        long = "container-runtime",
573        global = true,
574        help_heading = "Container Transport"
575    )]
576    container_runtime: Option<String>,
577    /// OS user passed to drivers that support exec user selection.
578    #[arg(
579        long = "container-user",
580        global = true,
581        help_heading = "Container Transport"
582    )]
583    container_user: Option<String>,
584    /// Kubernetes namespace for kubectl exec.
585    #[arg(
586        long = "container-namespace",
587        global = true,
588        help_heading = "Container Transport"
589    )]
590    container_namespace: Option<String>,
591    /// Docker or Kubernetes context for the selected driver.
592    #[arg(
593        long = "container-context",
594        global = true,
595        help_heading = "Container Transport"
596    )]
597    container_context: Option<String>,
598    /// Compose file passed before compose exec. Repeat for multiple files.
599    #[arg(
600        long = "container-compose-file",
601        global = true,
602        help_heading = "Container Transport"
603    )]
604    container_compose_files: Vec<String>,
605    /// Compose project name passed before compose exec.
606    #[arg(
607        long = "container-compose-project",
608        global = true,
609        help_heading = "Container Transport"
610    )]
611    container_compose_project: Option<String>,
612    /// Kubernetes container name for multi-container pods.
613    #[arg(
614        long = "container-pod-container",
615        global = true,
616        help_heading = "Container Transport"
617    )]
618    container_pod_container: Option<String>,
619
620    /// Output format: json (default), yaml, or plain.
621    #[arg(long, default_value = "json", global = true, help_heading = "Runtime")]
622    output: String,
623    /// Output routing: split, stdout, or stderr.
624    ///
625    /// Defaults to split for a finite query, and to stdout for --mode pipe and
626    /// --stream-rows, whose ordered event stream must stay on one stream.
627    #[arg(long = "output-to", global = true, help_heading = "Runtime")]
628    output_to: Option<String>,
629    /// Redirect stdout bytes to this file.
630    #[arg(
631        long = "stdout-file",
632        value_name = "PATH",
633        global = true,
634        help_heading = "Runtime"
635    )]
636    stdout_file: Option<String>,
637    /// Redirect stderr bytes to this file.
638    #[arg(
639        long = "stderr-file",
640        value_name = "PATH",
641        global = true,
642        help_heading = "Runtime"
643    )]
644    stderr_file: Option<String>,
645    /// Diagnostic log filters (comma-separated).
646    ///
647    /// Use startup, connect, query, transport, mode, an exact event such as
648    /// `query.error`, or `all`.
649    #[arg(
650        long = "log",
651        value_delimiter = ',',
652        global = true,
653        help_heading = "Runtime"
654    )]
655    log: Vec<String>,
656    /// Runtime mode: canonical cli, pipe, or `psql` translation mode.
657    #[arg(long, value_enum, default_value_t = RuntimeMode::Cli, help_heading = "Runtime")]
658    mode: RuntimeMode,
659
660    #[command(subcommand)]
661    command: Option<AfdCommand>,
662}
663
664pub fn parse_args(bin_name: &str) -> Result<Mode, String> {
665    let raw: Vec<String> = std::env::args().collect();
666    if is_psql_mode_requested(&raw) {
667        return parse_psql_mode(&raw);
668    }
669    let startup_requested = startup_requested_from_raw(&raw);
670
671    let build = match env!("GIT_SHA") {
672        "unknown" => None,
673        sha => Some(sha),
674    };
675    match agent_first_data::cli_handle_version_or_help_or_continue(
676        &raw,
677        &command_for_bin(bin_name),
678        &agent_first_data::HelpConfig::output_aware(),
679        bin_name,
680        Some(env!("DISPLAY_NAME")),
681        env!("CARGO_PKG_VERSION"),
682        build,
683    ) {
684        Ok(Some(rendered)) => {
685            let _ = crate::emit::write_result_text(&rendered);
686            std::process::exit(0);
687        }
688        Ok(None) => {}
689        Err(err) => {
690            let _ = crate::emit::emit_value(err, OutputFormat::Json);
691            std::process::exit(2);
692        }
693    }
694
695    let cli = match command_for_bin(bin_name)
696        .try_get_matches_from(&raw)
697        .and_then(|matches| AfdCli::from_arg_matches(&matches))
698    {
699        Ok(c) => c,
700        Err(e) => {
701            use clap::error::ErrorKind;
702            if matches!(e.kind(), ErrorKind::DisplayVersion | ErrorKind::DisplayHelp) {
703                let _ = crate::emit::write_result_text(&e.to_string());
704                std::process::exit(0);
705            }
706            return Err(e.to_string());
707        }
708    };
709    let _stream_redirect_args = (&cli.stdout_file, &cli.stderr_file);
710    let output = parse_output(&cli.output)?;
711    // The destination is resolved before clap in `emit::install_output_to_from_raw`,
712    // which also knows the consumption mode. Re-parse here so an invalid value is
713    // rejected as a clap-time usage error too.
714    if let Some(value) = &cli.output_to {
715        let _ = OutputTo::parse(value)?;
716    }
717    let log = parse_log_categories(&cli.log);
718    let dsn_config = SecretConfigRef::from_values("--dsn-secret-config", cli.dsn_secret_config)?;
719    let conninfo_config =
720        SecretConfigRef::from_values("--conninfo-secret-config", cli.conninfo_secret_config)?;
721    let password_config =
722        SecretConfigRef::from_values("--password-secret-config", cli.password_secret_config)?;
723    let connection_sources = connection_source_metadata([
724        (
725            "dsn",
726            cli.dsn_secret.is_some(),
727            cli.dsn_secret_env.as_deref(),
728            dsn_config.as_ref(),
729        ),
730        (
731            "conninfo",
732            cli.conninfo_secret.is_some(),
733            cli.conninfo_secret_env.as_deref(),
734            conninfo_config.as_ref(),
735        ),
736        (
737            "password",
738            cli.password_secret.is_some(),
739            cli.password_secret_env.as_deref(),
740            password_config.as_ref(),
741        ),
742    ]);
743    let dsn_secret = resolve_secret_value(
744        "--dsn-secret",
745        cli.dsn_secret,
746        cli.dsn_secret_env.as_deref(),
747        dsn_config.as_ref(),
748    )?;
749    let password_secret = resolve_secret_value(
750        "--password-secret",
751        cli.password_secret,
752        cli.password_secret_env.as_deref(),
753        password_config.as_ref(),
754    )?;
755    let conninfo_secret = resolve_secret_value(
756        "--conninfo-secret",
757        cli.conninfo_secret,
758        cli.conninfo_secret_env.as_deref(),
759        conninfo_config.as_ref(),
760    )?;
761    let session = SessionConfig {
762        // Caller-supplied, so the ordinary environment fallbacks still apply;
763        // only a locked administrator profile pins the endpoint.
764        profile_pinned: false,
765        dsn_secret,
766        conninfo_secret,
767        host: cli.host,
768        port: cli.port,
769        user: cli.user,
770        dbname: cli.dbname,
771        password_secret,
772        ssh: SshConfig {
773            destination: cli.ssh.or_else(|| std::env::var("AFPSQL_SSH").ok()),
774            via: if cli.ssh_via.is_empty() {
775                parse_csv_env("AFPSQL_SSH_VIA")
776            } else {
777                cli.ssh_via
778            },
779            options: cli.ssh_options,
780            local_host: cli
781                .ssh_local_host
782                .or_else(|| std::env::var("AFPSQL_SSH_LOCAL_HOST").ok()),
783            local_port: cli.ssh_local_port.or_else(|| {
784                std::env::var("AFPSQL_SSH_LOCAL_PORT")
785                    .ok()
786                    .and_then(|v| v.parse().ok())
787            }),
788            remote_socket: cli
789                .ssh_remote_socket
790                .or_else(|| std::env::var("AFPSQL_SSH_REMOTE_SOCKET").ok()),
791            sudo_user: cli
792                .ssh_sudo_user
793                .or_else(|| std::env::var("AFPSQL_SSH_SUDO_USER").ok()),
794        },
795        container: ContainerConfig {
796            target: cli
797                .container
798                .or_else(|| std::env::var("AFPSQL_CONTAINER").ok()),
799            driver: cli
800                .container_driver
801                .or_else(|| std::env::var("AFPSQL_CONTAINER_DRIVER").ok()),
802            runtime: cli
803                .container_runtime
804                .or_else(|| std::env::var("AFPSQL_CONTAINER_RUNTIME").ok()),
805            user: cli
806                .container_user
807                .or_else(|| std::env::var("AFPSQL_CONTAINER_USER").ok()),
808            namespace: cli
809                .container_namespace
810                .or_else(|| std::env::var("AFPSQL_CONTAINER_NAMESPACE").ok()),
811            context: cli
812                .container_context
813                .or_else(|| std::env::var("AFPSQL_CONTAINER_CONTEXT").ok()),
814            compose_files: resolve_container_compose_files(cli.container_compose_files),
815            compose_project: cli
816                .container_compose_project
817                .or_else(|| std::env::var("AFPSQL_CONTAINER_COMPOSE_PROJECT").ok()),
818            pod_container: cli
819                .container_pod_container
820                .or_else(|| std::env::var("AFPSQL_CONTAINER_POD_CONTAINER").ok()),
821        },
822    };
823    let mode_name = match cli.mode {
824        RuntimeMode::Cli => "cli",
825        RuntimeMode::Pipe => "pipe",
826        RuntimeMode::Psql => "psql",
827    };
828    let startup_env = startup_env_snapshot();
829
830    if let Some(command) = cli.command {
831        return match command {
832            AfdCommand::Psql(psql) => Ok(Mode::PsqlAdmin(PsqlAdminRequest {
833                action: psql_admin_action(psql.action),
834                output,
835            })),
836            AfdCommand::Skill(skill) => Ok(Mode::SkillAdmin(SkillAdminRequest {
837                action: skill_admin_action(skill.action),
838                output,
839            })),
840            AfdCommand::Inspect(inspect) => {
841                let (sql, params) = build_inspect_sql(inspect.action);
842                let startup_args = with_connection_sources(
843                    startup_args(mode_name, Some(&sql), None, params.len()),
844                    &connection_sources,
845                );
846                Ok(Mode::Cli(CliRequest {
847                    sql,
848                    params,
849                    options: QueryOptions::default(),
850                    session,
851                    output,
852                    log,
853                    startup_args,
854                    startup_env,
855                    startup_requested,
856                    dry_run: false,
857                    psql_mode: false,
858                }))
859            }
860        };
861    }
862
863    match cli.mode {
864        RuntimeMode::Pipe => {
865            return Ok(Mode::Pipe(PipeInit {
866                output,
867                session,
868                log: log.clone(),
869                startup_args: with_connection_sources(
870                    startup_args(mode_name, None, None, 0),
871                    &connection_sources,
872                ),
873                startup_env,
874                startup_requested,
875            }));
876        }
877        RuntimeMode::Cli | RuntimeMode::Psql => {}
878    }
879
880    let startup_sql_file = cli.sql_file.clone();
881    let user_sql = load_sql(cli.sql, cli.sql_file)?;
882    let params = parse_params(&cli.param)?;
883    let sql = if cli.explain {
884        wrap_explain_sql(&user_sql, false)
885    } else if cli.explain_analyze {
886        wrap_explain_sql(&user_sql, true)
887    } else {
888        user_sql
889    };
890    let startup_args = with_connection_sources(
891        startup_args(
892            mode_name,
893            Some(&sql),
894            startup_sql_file.as_deref(),
895            params.len(),
896        ),
897        &connection_sources,
898    );
899
900    let options = QueryOptions {
901        stream_rows: cli.stream_rows,
902        batch_rows: cli.batch_rows,
903        batch_bytes: cli.batch_bytes,
904        statement_timeout_ms: cli.statement_timeout_ms,
905        lock_timeout_ms: cli.lock_timeout_ms,
906        permission: cli.permission,
907        inline_max_rows: cli.inline_max_rows,
908        inline_max_bytes: cli.inline_max_bytes,
909    };
910
911    Ok(Mode::Cli(CliRequest {
912        sql,
913        params,
914        options,
915        session,
916        output,
917        log,
918        startup_args,
919        startup_env,
920        startup_requested,
921        dry_run: cli.dry_run,
922        psql_mode: false,
923    }))
924}
925
926fn command_for_bin(bin_name: &str) -> clap::Command {
927    match bin_name {
928        "afpsql-readonly" => AfdCli::command()
929            .name("afpsql-readonly")
930            .bin_name("afpsql-readonly"),
931        _ => AfdCli::command().name("afpsql").bin_name("afpsql"),
932    }
933}
934
935fn parse_psql_mode(raw: &[String]) -> Result<Mode, String> {
936    let startup_requested = startup_requested_from_raw(raw);
937    let mut state = PsqlModeState::default();
938
939    let mut i = 1usize;
940    while i < raw.len() {
941        let arg = raw[i].as_str();
942        if arg == "--" {
943            i += 1;
944            while i < raw.len() {
945                state.positionals.push(raw[i].clone());
946                i += 1;
947            }
948            break;
949        }
950        if arg.starts_with("--") {
951            parse_psql_long_arg(raw, &mut i, &mut state)?;
952            continue;
953        }
954        if arg.starts_with('-') && arg.len() > 1 {
955            parse_psql_short_arg(raw, &mut i, &mut state)?;
956            continue;
957        }
958        state.positionals.push(raw[i].clone());
959        i += 1;
960    }
961
962    if let Some(reason) = state.interactive_reason {
963        return Ok(Mode::PsqlUnsupported(PsqlUnsupportedRequest { reason }));
964    }
965
966    apply_psql_positionals(&mut state)?;
967    if state.list_databases {
968        state.sql = Some(psql_list_databases_sql());
969        state.sql_file = None;
970    }
971    if state.sql.is_none() && state.sql_file.is_none() {
972        return Ok(Mode::PsqlUnsupported(PsqlUnsupportedRequest {
973            reason: "no -c/--command, -f/--file, or -l/--list was provided".to_string(),
974        }));
975    }
976
977    let connection_sources = connection_source_metadata([
978        (
979            "dsn",
980            state.dsn_secret.is_some(),
981            state.dsn_secret_env.as_deref(),
982            state.dsn_secret_config.as_ref(),
983        ),
984        (
985            "conninfo",
986            state.conninfo_secret.is_some(),
987            state.conninfo_secret_env.as_deref(),
988            state.conninfo_secret_config.as_ref(),
989        ),
990        (
991            "password",
992            state.password_secret.is_some(),
993            state.password_secret_env.as_deref(),
994            state.password_secret_config.as_ref(),
995        ),
996    ]);
997    let dsn_secret = resolve_secret_value(
998        "--dsn-secret",
999        state.dsn_secret,
1000        state.dsn_secret_env.as_deref(),
1001        state.dsn_secret_config.as_ref(),
1002    )?;
1003    let password_secret = resolve_secret_value(
1004        "--password-secret",
1005        state.password_secret,
1006        state.password_secret_env.as_deref(),
1007        state.password_secret_config.as_ref(),
1008    )?;
1009    let conninfo_secret = resolve_secret_value(
1010        "--conninfo-secret",
1011        state.conninfo_secret,
1012        state.conninfo_secret_env.as_deref(),
1013        state.conninfo_secret_config.as_ref(),
1014    )?;
1015    let session = SessionConfig {
1016        // Caller-supplied, so the ordinary environment fallbacks still apply;
1017        // only a locked administrator profile pins the endpoint.
1018        profile_pinned: false,
1019        dsn_secret,
1020        conninfo_secret,
1021        host: state.host,
1022        port: state.port,
1023        user: state.user,
1024        dbname: state.dbname,
1025        password_secret,
1026        ssh: SshConfig::default(),
1027        container: ContainerConfig {
1028            target: state
1029                .container
1030                .or_else(|| std::env::var("AFPSQL_CONTAINER").ok()),
1031            driver: state
1032                .container_driver
1033                .or_else(|| std::env::var("AFPSQL_CONTAINER_DRIVER").ok()),
1034            runtime: state
1035                .container_runtime
1036                .or_else(|| std::env::var("AFPSQL_CONTAINER_RUNTIME").ok()),
1037            user: state
1038                .container_user
1039                .or_else(|| std::env::var("AFPSQL_CONTAINER_USER").ok()),
1040            namespace: state
1041                .container_namespace
1042                .or_else(|| std::env::var("AFPSQL_CONTAINER_NAMESPACE").ok()),
1043            context: state
1044                .container_context
1045                .or_else(|| std::env::var("AFPSQL_CONTAINER_CONTEXT").ok()),
1046            compose_files: resolve_container_compose_files(state.container_compose_files),
1047            compose_project: state
1048                .container_compose_project
1049                .or_else(|| std::env::var("AFPSQL_CONTAINER_COMPOSE_PROJECT").ok()),
1050            pod_container: state
1051                .container_pod_container
1052                .or_else(|| std::env::var("AFPSQL_CONTAINER_POD_CONTAINER").ok()),
1053        },
1054    };
1055
1056    let startup_sql_file = state.sql_file.clone();
1057    let sql = load_sql(state.sql, state.sql_file)?;
1058    let params = parse_params(&state.params_kv)?;
1059    let startup_args = with_connection_sources(
1060        psql_startup_args(PsqlStartupArgs {
1061            mode: "psql",
1062            sql: Some(&sql),
1063            sql_file: startup_sql_file,
1064            param_count: params.len(),
1065        }),
1066        &connection_sources,
1067    );
1068    Ok(Mode::Cli(CliRequest {
1069        sql,
1070        params,
1071        options: QueryOptions {
1072            permission: Some(if session.uses_container_transport() {
1073                Permission::ContainerWrite
1074            } else {
1075                Permission::Write
1076            }),
1077            ..Default::default()
1078        },
1079        session,
1080        output: state.output,
1081        log: parse_log_categories(&state.log_entries),
1082        startup_args,
1083        startup_env: startup_env_snapshot(),
1084        startup_requested,
1085        dry_run: false,
1086        psql_mode: true,
1087    }))
1088}
1089
1090struct PsqlModeState {
1091    sql: Option<String>,
1092    sql_file: Option<String>,
1093    host: Option<String>,
1094    port: Option<u16>,
1095    user: Option<String>,
1096    dbname: Option<String>,
1097    dsn_secret: Option<String>,
1098    dsn_secret_env: Option<String>,
1099    dsn_secret_config: Option<SecretConfigRef>,
1100    conninfo_secret: Option<String>,
1101    conninfo_secret_env: Option<String>,
1102    conninfo_secret_config: Option<SecretConfigRef>,
1103    password_secret: Option<String>,
1104    password_secret_env: Option<String>,
1105    password_secret_config: Option<SecretConfigRef>,
1106    container: Option<String>,
1107    container_driver: Option<String>,
1108    container_runtime: Option<String>,
1109    container_user: Option<String>,
1110    container_namespace: Option<String>,
1111    container_context: Option<String>,
1112    container_compose_files: Vec<String>,
1113    container_compose_project: Option<String>,
1114    container_pod_container: Option<String>,
1115    params_kv: Vec<String>,
1116    output: OutputFormat,
1117    log_entries: Vec<String>,
1118    list_databases: bool,
1119    positionals: Vec<String>,
1120    interactive_reason: Option<String>,
1121}
1122
1123impl Default for PsqlModeState {
1124    fn default() -> Self {
1125        Self {
1126            sql: None,
1127            sql_file: None,
1128            host: None,
1129            port: None,
1130            user: None,
1131            dbname: None,
1132            dsn_secret: None,
1133            dsn_secret_env: None,
1134            dsn_secret_config: None,
1135            conninfo_secret: None,
1136            conninfo_secret_env: None,
1137            conninfo_secret_config: None,
1138            password_secret: None,
1139            password_secret_env: None,
1140            password_secret_config: None,
1141            container: None,
1142            container_driver: None,
1143            container_runtime: None,
1144            container_user: None,
1145            container_namespace: None,
1146            container_context: None,
1147            container_compose_files: vec![],
1148            container_compose_project: None,
1149            container_pod_container: None,
1150            params_kv: vec![],
1151            output: OutputFormat::Json,
1152            log_entries: vec![],
1153            list_databases: false,
1154            positionals: vec![],
1155            interactive_reason: None,
1156        }
1157    }
1158}
1159
1160impl PsqlModeState {
1161    fn set_sql(&mut self, sql: String, flag: &str) -> Result<(), String> {
1162        if self.sql.is_some() || self.sql_file.is_some() {
1163            return Err(format!(
1164                "psql mode currently supports only one -c/--command or -f/--file source; repeated source at {flag}"
1165            ));
1166        }
1167        self.sql = Some(sql);
1168        Ok(())
1169    }
1170
1171    fn set_sql_file(&mut self, path: String, flag: &str) -> Result<(), String> {
1172        if self.sql.is_some() || self.sql_file.is_some() {
1173            return Err(format!(
1174                "psql mode currently supports only one -c/--command or -f/--file source; repeated source at {flag}"
1175            ));
1176        }
1177        self.sql_file = Some(path);
1178        Ok(())
1179    }
1180}
1181
1182fn parse_psql_long_arg(
1183    raw: &[String],
1184    i: &mut usize,
1185    state: &mut PsqlModeState,
1186) -> Result<(), String> {
1187    let arg = raw[*i].as_str();
1188    if arg == "--mode" {
1189        let value = take_arg_value(raw, i, "--mode")?;
1190        if value != "psql" {
1191            return Err(format!(
1192                "unsupported psql-mode argument: --mode {value}; only --mode psql is allowed with psql translation"
1193            ));
1194        }
1195        return Ok(());
1196    }
1197    if let Some(value) = arg.strip_prefix("--mode=") {
1198        if value != "psql" {
1199            return Err(format!(
1200                "unsupported psql-mode argument: {arg}; only --mode=psql is allowed with psql translation"
1201            ));
1202        }
1203        *i += 1;
1204        return Ok(());
1205    }
1206
1207    if arg == "--help" || arg.starts_with("--help=") {
1208        emit_psql_mode_help();
1209        std::process::exit(0);
1210    }
1211    if arg == "--version" {
1212        emit_psql_mode_version();
1213        std::process::exit(0);
1214    }
1215
1216    match long_name(arg) {
1217        "--command" => {
1218            let value = take_long_arg_value(raw, i, "--command")?;
1219            state.set_sql(value, "--command")
1220        }
1221        "--file" => {
1222            let value = take_long_arg_value(raw, i, "--file")?;
1223            state.set_sql_file(value, "--file")
1224        }
1225        "--host" => {
1226            state.host = Some(take_long_arg_value(raw, i, "--host")?);
1227            Ok(())
1228        }
1229        "--port" => {
1230            state.port = Some(parse_port(
1231                &take_long_arg_value(raw, i, "--port")?,
1232                "--port",
1233            )?);
1234            Ok(())
1235        }
1236        "--username" | "--user" => {
1237            state.user = Some(take_long_arg_value(raw, i, long_name(arg))?);
1238            Ok(())
1239        }
1240        "--dbname" => {
1241            apply_dbname_value(state, take_long_arg_value(raw, i, "--dbname")?);
1242            Ok(())
1243        }
1244        "--set" | "--variable" => {
1245            let value = take_long_arg_value(raw, i, long_name(arg))?;
1246            add_psql_variable(state, value)
1247        }
1248        "--list" => {
1249            state.list_databases = true;
1250            *i += 1;
1251            Ok(())
1252        }
1253        "--no-password"
1254        | "--no-psqlrc"
1255        | "--no-readline"
1256        | "--quiet"
1257        | "--echo-all"
1258        | "--echo-errors"
1259        | "--echo-queries"
1260        | "--echo-hidden"
1261        | "--no-align"
1262        | "--csv"
1263        | "--html"
1264        | "--tuples-only"
1265        | "--expanded"
1266        | "--field-separator-zero"
1267        | "--record-separator-zero"
1268        | "--single-transaction" => {
1269            *i += 1;
1270            Ok(())
1271        }
1272        "--field-separator" | "--record-separator" | "--pset" | "--table-attr" => {
1273            let _ = take_long_arg_value(raw, i, long_name(arg))?;
1274            Ok(())
1275        }
1276        "--password" => {
1277            state.interactive_reason =
1278                Some("--password/-W requests an interactive password prompt".to_string());
1279            *i += 1;
1280            Ok(())
1281        }
1282        "--single-step" => {
1283            state.interactive_reason =
1284                Some("--single-step/-s requires interactive command confirmation".to_string());
1285            *i += 1;
1286            Ok(())
1287        }
1288        "--single-line" => {
1289            state.interactive_reason =
1290                Some("--single-line/-S is a human-interactive input mode".to_string());
1291            *i += 1;
1292            Ok(())
1293        }
1294        "--dsn-secret" => {
1295            state.dsn_secret = Some(take_long_arg_value(raw, i, "--dsn-secret")?);
1296            Ok(())
1297        }
1298        "--dsn-secret-env" => {
1299            state.dsn_secret_env = Some(take_long_arg_value(raw, i, "--dsn-secret-env")?);
1300            Ok(())
1301        }
1302        "--dsn-secret-config" => {
1303            state.dsn_secret_config = Some(take_secret_config_ref(raw, i, "--dsn-secret-config")?);
1304            Ok(())
1305        }
1306        "--conninfo-secret" => {
1307            state.conninfo_secret = Some(take_long_arg_value(raw, i, "--conninfo-secret")?);
1308            Ok(())
1309        }
1310        "--conninfo-secret-env" => {
1311            state.conninfo_secret_env = Some(take_long_arg_value(raw, i, "--conninfo-secret-env")?);
1312            Ok(())
1313        }
1314        "--conninfo-secret-config" => {
1315            state.conninfo_secret_config =
1316                Some(take_secret_config_ref(raw, i, "--conninfo-secret-config")?);
1317            Ok(())
1318        }
1319        "--password-secret" => {
1320            state.password_secret = Some(take_long_arg_value(raw, i, "--password-secret")?);
1321            Ok(())
1322        }
1323        "--password-secret-env" => {
1324            state.password_secret_env = Some(take_long_arg_value(raw, i, "--password-secret-env")?);
1325            Ok(())
1326        }
1327        "--password-secret-config" => {
1328            state.password_secret_config =
1329                Some(take_secret_config_ref(raw, i, "--password-secret-config")?);
1330            Ok(())
1331        }
1332        "--container" => {
1333            state.container = Some(take_long_arg_value(raw, i, "--container")?);
1334            Ok(())
1335        }
1336        "--container-driver" => {
1337            state.container_driver = Some(take_long_arg_value(raw, i, "--container-driver")?);
1338            Ok(())
1339        }
1340        "--container-runtime" => {
1341            state.container_runtime = Some(take_long_arg_value(raw, i, "--container-runtime")?);
1342            Ok(())
1343        }
1344        "--container-user" => {
1345            state.container_user = Some(take_long_arg_value(raw, i, "--container-user")?);
1346            Ok(())
1347        }
1348        "--container-namespace" => {
1349            state.container_namespace = Some(take_long_arg_value(raw, i, "--container-namespace")?);
1350            Ok(())
1351        }
1352        "--container-context" => {
1353            state.container_context = Some(take_long_arg_value(raw, i, "--container-context")?);
1354            Ok(())
1355        }
1356        "--container-compose-file" => {
1357            state.container_compose_files.push(take_long_arg_value(
1358                raw,
1359                i,
1360                "--container-compose-file",
1361            )?);
1362            Ok(())
1363        }
1364        "--container-compose-project" => {
1365            state.container_compose_project =
1366                Some(take_long_arg_value(raw, i, "--container-compose-project")?);
1367            Ok(())
1368        }
1369        "--container-pod-container" => {
1370            state.container_pod_container =
1371                Some(take_long_arg_value(raw, i, "--container-pod-container")?);
1372            Ok(())
1373        }
1374        "--stdout-file" | "--stderr-file" => {
1375            let _ = take_long_arg_value(raw, i, long_name(arg))?;
1376            Ok(())
1377        }
1378        "--output-to" => {
1379            let value = take_long_arg_value(raw, i, "--output-to")?;
1380            let _ = OutputTo::parse(&value)?;
1381            Ok(())
1382        }
1383        "--log" => {
1384            let values = take_long_arg_value(raw, i, "--log")?;
1385            add_log_entries(state, &values);
1386            Ok(())
1387        }
1388        _ => Err(format!("unsupported psql-mode argument: {arg}")),
1389    }
1390}
1391
1392fn parse_psql_short_arg(
1393    raw: &[String],
1394    i: &mut usize,
1395    state: &mut PsqlModeState,
1396) -> Result<(), String> {
1397    let arg = raw[*i].as_str();
1398    let mut offset = 1usize;
1399    while offset < arg.len() {
1400        let flag = arg.as_bytes()[offset] as char;
1401        offset += 1;
1402        match flag {
1403            '?' => {
1404                emit_psql_mode_help();
1405                std::process::exit(0);
1406            }
1407            'V' => {
1408                emit_psql_mode_version();
1409                std::process::exit(0);
1410            }
1411            'c' => {
1412                let value = take_short_arg_value(raw, i, arg, offset, "-c")?;
1413                return state.set_sql(value, "-c");
1414            }
1415            'f' => {
1416                let value = take_short_arg_value(raw, i, arg, offset, "-f")?;
1417                return state.set_sql_file(value, "-f");
1418            }
1419            'h' => {
1420                state.host = Some(take_short_arg_value(raw, i, arg, offset, "-h")?);
1421                return Ok(());
1422            }
1423            'p' => {
1424                let value = take_short_arg_value(raw, i, arg, offset, "-p")?;
1425                state.port = Some(parse_port(&value, "-p")?);
1426                return Ok(());
1427            }
1428            'U' => {
1429                state.user = Some(take_short_arg_value(raw, i, arg, offset, "-U")?);
1430                return Ok(());
1431            }
1432            'd' => {
1433                apply_dbname_value(state, take_short_arg_value(raw, i, arg, offset, "-d")?);
1434                return Ok(());
1435            }
1436            'v' => {
1437                let value = take_short_arg_value(raw, i, arg, offset, "-v")?;
1438                return add_psql_variable(state, value);
1439            }
1440            'F' | 'P' | 'R' | 'T' => {
1441                let _ = take_short_arg_value(raw, i, arg, offset, &format!("-{flag}"))?;
1442                return Ok(());
1443            }
1444            'l' => state.list_databases = true,
1445            'W' => {
1446                state.interactive_reason =
1447                    Some("--password/-W requests an interactive password prompt".to_string());
1448            }
1449            's' => {
1450                state.interactive_reason =
1451                    Some("--single-step/-s requires interactive command confirmation".to_string());
1452            }
1453            'S' => {
1454                state.interactive_reason =
1455                    Some("--single-line/-S is a human-interactive input mode".to_string());
1456            }
1457            'a' | 'A' | 'b' | 'e' | 'E' | 'H' | 'n' | 'q' | 't' | 'w' | 'x' | 'X' | 'z' | '0'
1458            | '1' => {}
1459            _ => return Err(format!("unsupported psql-mode argument: -{flag}")),
1460        }
1461    }
1462    *i += 1;
1463    Ok(())
1464}
1465
1466fn long_name(arg: &str) -> &str {
1467    arg.split_once('=').map(|(name, _)| name).unwrap_or(arg)
1468}
1469
1470fn take_arg_value(raw: &[String], i: &mut usize, flag: &str) -> Result<String, String> {
1471    *i += 1;
1472    let value = raw
1473        .get(*i)
1474        .ok_or_else(|| format!("{flag} requires value"))?
1475        .clone();
1476    *i += 1;
1477    Ok(value)
1478}
1479
1480fn take_long_arg_value(raw: &[String], i: &mut usize, flag: &str) -> Result<String, String> {
1481    let arg = raw[*i].as_str();
1482    if let Some((_, value)) = arg.split_once('=') {
1483        *i += 1;
1484        return Ok(value.to_string());
1485    }
1486    take_arg_value(raw, i, flag)
1487}
1488
1489fn take_secret_config_ref(
1490    raw: &[String],
1491    i: &mut usize,
1492    flag: &str,
1493) -> Result<SecretConfigRef, String> {
1494    if raw[*i].contains('=') {
1495        return Err(format!(
1496            "{flag} requires space-separated values: {flag} <FILE> <DOT_PATH>"
1497        ));
1498    }
1499    let file = take_long_arg_value(raw, i, flag)?;
1500    let path = raw
1501        .get(*i)
1502        .filter(|value| !value.starts_with('-'))
1503        .ok_or_else(|| format!("{flag} requires exactly two values: <FILE> <DOT_PATH>"))?
1504        .clone();
1505    *i += 1;
1506    if raw.get(*i).is_some_and(|value| !value.starts_with('-')) {
1507        return Err(format!(
1508            "{flag} accepts exactly two values: <FILE> <DOT_PATH>"
1509        ));
1510    }
1511    if file.is_empty() || path.is_empty() {
1512        return Err(format!(
1513            "{flag} requires exactly two non-empty values: <FILE> <DOT_PATH>"
1514        ));
1515    }
1516    Ok(SecretConfigRef {
1517        file: file.into(),
1518        path,
1519    })
1520}
1521
1522fn take_short_arg_value(
1523    raw: &[String],
1524    i: &mut usize,
1525    arg: &str,
1526    offset: usize,
1527    flag: &str,
1528) -> Result<String, String> {
1529    if offset < arg.len() {
1530        let value = arg[offset..].to_string();
1531        *i += 1;
1532        return Ok(value);
1533    }
1534    take_arg_value(raw, i, flag)
1535}
1536
1537fn parse_port(value: &str, flag: &str) -> Result<u16, String> {
1538    value.parse().map_err(|_| format!("invalid {flag} port"))
1539}
1540
1541fn add_log_entries(state: &mut PsqlModeState, values: &str) {
1542    for part in values.split(',') {
1543        let trimmed = part.trim();
1544        if !trimmed.is_empty() {
1545            state.log_entries.push(trimmed.to_string());
1546        }
1547    }
1548}
1549
1550fn add_psql_variable(state: &mut PsqlModeState, value: String) -> Result<(), String> {
1551    let name = value
1552        .split_once('=')
1553        .map(|(name, _)| name)
1554        .unwrap_or(value.as_str());
1555    if name.parse::<usize>().is_ok() {
1556        if value.contains('=') {
1557            state.params_kv.push(value);
1558            return Ok(());
1559        }
1560        return Err(format!("invalid param '{value}', expected N=value"));
1561    }
1562    if is_psql_behavior_variable(name) {
1563        return Ok(());
1564    }
1565    Err(format!(
1566        "invalid or unsupported psql variable '{name}'; afpsql supports numeric -v N=value bind parameters, not client-side :name interpolation"
1567    ))
1568}
1569
1570fn is_psql_behavior_variable(name: &str) -> bool {
1571    matches!(
1572        name.to_ascii_uppercase().as_str(),
1573        "ON_ERROR_STOP"
1574            | "ON_ERROR_ROLLBACK"
1575            | "QUIET"
1576            | "ECHO"
1577            | "ECHO_HIDDEN"
1578            | "FETCH_COUNT"
1579            | "VERBOSITY"
1580            | "SHOW_CONTEXT"
1581            | "HISTCONTROL"
1582            | "HISTFILE"
1583            | "HISTSIZE"
1584            | "IGNOREEOF"
1585            | "PAGER"
1586            | "COLUMNS"
1587    )
1588}
1589
1590fn apply_psql_positionals(state: &mut PsqlModeState) -> Result<(), String> {
1591    let positionals = std::mem::take(&mut state.positionals);
1592    for value in positionals {
1593        if is_postgres_uri(&value) {
1594            state.dsn_secret = Some(value);
1595            continue;
1596        }
1597        if looks_like_conninfo(&value) {
1598            state.conninfo_secret = Some(value);
1599            continue;
1600        }
1601        if state.dbname.is_none() {
1602            state.dbname = Some(value);
1603            continue;
1604        }
1605        if state.user.is_none() {
1606            state.user = Some(value);
1607            continue;
1608        }
1609        return Err(format!("too many positional psql arguments: {value}"));
1610    }
1611    Ok(())
1612}
1613
1614fn apply_dbname_value(state: &mut PsqlModeState, value: String) {
1615    if is_postgres_uri(&value) {
1616        state.dsn_secret = Some(value);
1617    } else if looks_like_conninfo(&value) {
1618        state.conninfo_secret = Some(value);
1619    } else {
1620        state.dbname = Some(value);
1621    }
1622}
1623
1624fn is_postgres_uri(value: &str) -> bool {
1625    value.starts_with("postgresql://") || value.starts_with("postgres://")
1626}
1627
1628fn looks_like_conninfo(value: &str) -> bool {
1629    value.contains('=')
1630}
1631
1632fn psql_list_databases_sql() -> String {
1633    "select datname as name from pg_catalog.pg_database where datallowconn order by datname"
1634        .to_string()
1635}
1636
1637fn emit_psql_mode_version() {
1638    let _ = crate::emit::write_result_text(&format!(
1639        "psql (afpsql wrapper) {}\n",
1640        env!("CARGO_PKG_VERSION")
1641    ));
1642}
1643
1644fn emit_psql_mode_help() {
1645    let _ = crate::emit::write_result_text(&format!(
1646        "psql (afpsql wrapper) {}\n\
1647Usage:\n  psql [OPTION]... [DBNAME [USERNAME]]\n\n\
1648Supported 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\
1649Output:\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\
1650Human-interactive psql modes and psql meta-commands are not supported by this wrapper.",
1651        env!("CARGO_PKG_VERSION")
1652    ));
1653}
1654
1655fn psql_admin_action(action: PsqlCliAction) -> PsqlAdminAction {
1656    match action {
1657        PsqlCliAction::Status(args) => PsqlAdminAction::Status {
1658            bin_dir: args.bin_dir,
1659        },
1660        PsqlCliAction::Install(args) => PsqlAdminAction::Install {
1661            bin_dir: args.bin_dir,
1662        },
1663        PsqlCliAction::Uninstall(args) => PsqlAdminAction::Uninstall {
1664            bin_dir: args.bin_dir,
1665        },
1666    }
1667}
1668
1669fn skill_admin_action(action: SkillCliAction) -> SkillAdminAction {
1670    match action {
1671        SkillCliAction::Status(args) => SkillAdminAction::Status(skill_options(args, false)),
1672        SkillCliAction::Install(args) => {
1673            SkillAdminAction::Install(skill_options(args.target, args.force))
1674        }
1675        SkillCliAction::Uninstall(args) => {
1676            SkillAdminAction::Uninstall(skill_options(args.target, args.force))
1677        }
1678    }
1679}
1680
1681fn skill_options(args: SkillTargetArgs, force: bool) -> SkillAdminOptions {
1682    SkillAdminOptions {
1683        agent: args.agent,
1684        scope: args.scope,
1685        skills_dir: args.skills_dir,
1686        force,
1687    }
1688}
1689
1690fn is_psql_mode_requested(raw: &[String]) -> bool {
1691    let mut i = 1usize;
1692    while i < raw.len() {
1693        let arg = raw[i].as_str();
1694        if arg == "--" {
1695            break;
1696        }
1697        if arg == "--mode" {
1698            if let Some(v) = raw.get(i + 1) {
1699                return v == "psql";
1700            }
1701            return false;
1702        }
1703        if arg == "--mode=psql" {
1704            return true;
1705        }
1706        if top_level_arg_consumes_two_values(arg) {
1707            i += if arg.contains('=') { 2 } else { 3 };
1708            continue;
1709        }
1710        if top_level_arg_consumes_value(arg) {
1711            i += if arg.contains('=') { 1 } else { 2 };
1712            continue;
1713        }
1714        if arg.starts_with('-') {
1715            i += 1;
1716            continue;
1717        }
1718        break;
1719    }
1720    false
1721}
1722
1723pub(crate) fn top_level_arg_consumes_two_values(arg: &str) -> bool {
1724    let name = arg.split_once('=').map(|(name, _)| name).unwrap_or(arg);
1725    matches!(
1726        name,
1727        "--dsn-secret-config" | "--conninfo-secret-config" | "--password-secret-config"
1728    )
1729}
1730
1731pub(crate) fn top_level_arg_consumes_value(arg: &str) -> bool {
1732    let name = arg.split_once('=').map(|(name, _)| name).unwrap_or(arg);
1733    matches!(
1734        name,
1735        "--sql"
1736            | "--sql-file"
1737            | "--param"
1738            | "--batch-rows"
1739            | "--batch-bytes"
1740            | "--statement-timeout-ms"
1741            | "--lock-timeout-ms"
1742            | "--inline-max-rows"
1743            | "--inline-max-bytes"
1744            | "--permission"
1745            | "--dsn-secret"
1746            | "--dsn-secret-env"
1747            | "--conninfo-secret"
1748            | "--conninfo-secret-env"
1749            | "--host"
1750            | "--port"
1751            | "--user"
1752            | "--dbname"
1753            | "--password-secret"
1754            | "--password-secret-env"
1755            | "--ssh"
1756            | "--ssh-via"
1757            | "--ssh-option"
1758            | "--ssh-local-host"
1759            | "--ssh-local-port"
1760            | "--ssh-remote-socket"
1761            | "--ssh-sudo-user"
1762            | "--container"
1763            | "--container-driver"
1764            | "--container-runtime"
1765            | "--container-user"
1766            | "--container-namespace"
1767            | "--container-context"
1768            | "--container-compose-file"
1769            | "--container-compose-project"
1770            | "--container-pod-container"
1771            | "--output"
1772            | "--output-to"
1773            | "--stdout-file"
1774            | "--stderr-file"
1775            | "--log"
1776    )
1777}
1778
1779fn resolve_container_compose_files(cli_files: Vec<String>) -> Vec<String> {
1780    if !cli_files.is_empty() {
1781        return cli_files;
1782    }
1783    std::env::var("AFPSQL_CONTAINER_COMPOSE_FILE")
1784        .ok()
1785        .map(|value| {
1786            value
1787                .split(':')
1788                .filter(|part| !part.is_empty())
1789                .map(std::string::ToString::to_string)
1790                .collect()
1791        })
1792        .unwrap_or_default()
1793}
1794
1795fn load_sql(sql: Option<String>, sql_file: Option<String>) -> Result<String, String> {
1796    match (sql, sql_file) {
1797        (Some(s), None) => validate_sql_size(s),
1798        (None, Some(path)) if path == "-" => {
1799            let stdin = std::io::stdin();
1800            read_limited_sql(stdin.lock(), "read --sql-file -")
1801        }
1802        (None, Some(path)) => {
1803            let metadata =
1804                std::fs::metadata(&path).map_err(|e| format!("read --sql-file failed: {e}"))?;
1805            if metadata.is_file() && metadata.len() > MAX_SQL_BYTES as u64 {
1806                return Err(sql_size_error());
1807            }
1808            let file =
1809                std::fs::File::open(&path).map_err(|e| format!("read --sql-file failed: {e}"))?;
1810            read_limited_sql(file, "read --sql-file")
1811        }
1812        (Some(_), Some(_)) => Err("--sql and --sql-file are mutually exclusive".to_string()),
1813        (None, None) => Err("one of --sql or --sql-file is required".to_string()),
1814    }
1815}
1816
1817fn read_limited_sql<R: Read>(reader: R, context: &str) -> Result<String, String> {
1818    let mut buf = Vec::new();
1819    let mut limited = reader.take(MAX_SQL_BYTES as u64 + 1);
1820    limited
1821        .read_to_end(&mut buf)
1822        .map_err(|e| format!("{context} failed: {e}"))?;
1823    if buf.len() > MAX_SQL_BYTES {
1824        return Err(sql_size_error());
1825    }
1826    String::from_utf8(buf).map_err(|e| format!("{context} failed: {e}"))
1827}
1828
1829fn validate_sql_size(sql: String) -> Result<String, String> {
1830    if sql.len() > MAX_SQL_BYTES {
1831        return Err(sql_size_error());
1832    }
1833    Ok(sql)
1834}
1835
1836fn sql_size_error() -> String {
1837    format!("sql exceeds maximum size; maximum SQL size is {MAX_SQL_BYTES} bytes")
1838}
1839
1840fn parse_output(v: &str) -> Result<OutputFormat, String> {
1841    cli_parse_output(v)
1842}
1843
1844fn parse_log_categories(entries: &[String]) -> LogFilters {
1845    cli_parse_log_filters(entries)
1846}
1847
1848fn parse_csv_env(name: &str) -> Vec<String> {
1849    std::env::var(name)
1850        .ok()
1851        .into_iter()
1852        .flat_map(|value| {
1853            value
1854                .split(',')
1855                .map(str::trim)
1856                .filter(|part| !part.is_empty())
1857                .map(std::string::ToString::to_string)
1858                .collect::<Vec<_>>()
1859        })
1860        .collect()
1861}
1862
1863fn startup_requested_from_raw(raw: &[String]) -> bool {
1864    let mut i = 1usize;
1865    while i < raw.len() {
1866        if raw[i] == "--log" {
1867            if let Some(values) = raw.get(i + 1) {
1868                for part in values.split(',') {
1869                    let v = part.trim().to_ascii_lowercase();
1870                    if matches!(v.as_str(), "startup" | "all" | "*") {
1871                        return true;
1872                    }
1873                }
1874            }
1875            i += 2;
1876            continue;
1877        }
1878        if let Some(values) = raw[i].strip_prefix("--log=") {
1879            for part in values.split(',') {
1880                let v = part.trim().to_ascii_lowercase();
1881                if matches!(v.as_str(), "startup" | "all" | "*") {
1882                    return true;
1883                }
1884            }
1885        }
1886        i += 1;
1887    }
1888    false
1889}
1890
1891fn startup_env_snapshot() -> Value {
1892    Value::Array(
1893        STARTUP_ENV_KEYS
1894            .iter()
1895            .map(|key| {
1896                json!({
1897                    "key": key,
1898                    "present": std::env::var_os(key).is_some(),
1899                })
1900            })
1901            .collect(),
1902    )
1903}
1904
1905fn startup_args(
1906    mode: &str,
1907    sql: Option<&str>,
1908    sql_file: Option<&str>,
1909    param_count: usize,
1910) -> Value {
1911    json!({
1912        "mode": mode,
1913        "sql": startup_sql_summary(sql, sql_file),
1914        "param_count": param_count,
1915    })
1916}
1917
1918fn with_connection_sources(mut args: Value, sources: &Value) -> Value {
1919    if let (Some(args), Some(sources)) = (args.as_object_mut(), sources.as_object())
1920        && !sources.is_empty()
1921    {
1922        args.insert(
1923            "connection_sources".to_string(),
1924            Value::Object(sources.clone()),
1925        );
1926    }
1927    args
1928}
1929
1930fn connection_source_metadata<const N: usize>(
1931    sources: [(&str, bool, Option<&str>, Option<&SecretConfigRef>); N],
1932) -> Value {
1933    let mut metadata = serde_json::Map::new();
1934    for (slot, direct, env_name, config) in sources {
1935        let value = if let Some(reference) = config {
1936            Some(reference.safe_metadata())
1937        } else if let Some(env_name) = env_name {
1938            Some(json!({"kind": "env", "name": env_name}))
1939        } else if direct {
1940            Some(json!({"kind": "direct"}))
1941        } else {
1942            None
1943        };
1944        if let Some(value) = value {
1945            metadata.insert(slot.to_string(), value);
1946        }
1947    }
1948    Value::Object(metadata)
1949}
1950
1951fn startup_sql_summary(sql: Option<&str>, sql_file: Option<&str>) -> Value {
1952    let Some(sql) = sql else {
1953        return json!({
1954            "present": false,
1955            "source": "none",
1956            "bytes": 0,
1957            "chars": 0,
1958            "operation": null,
1959        });
1960    };
1961    json!({
1962        "present": true,
1963        "source": if sql_file.is_some() { "file" } else { "inline" },
1964        "bytes": sql.len(),
1965        "chars": sql.chars().count(),
1966        "operation": sql_operation(sql),
1967    })
1968}
1969
1970fn sql_operation(sql: &str) -> Option<String> {
1971    let sql = trim_leading_sql_comments(sql);
1972    let token: String = sql
1973        .chars()
1974        .skip_while(|c| c.is_whitespace())
1975        .take_while(|c| c.is_ascii_alphabetic() || *c == '_')
1976        .collect();
1977    if token.is_empty() {
1978        None
1979    } else {
1980        Some(token.to_ascii_lowercase())
1981    }
1982}
1983
1984fn trim_leading_sql_comments(mut sql: &str) -> &str {
1985    loop {
1986        sql = sql.trim_start();
1987        if let Some(rest) = sql.strip_prefix("--") {
1988            sql = rest.split_once('\n').map(|(_, rest)| rest).unwrap_or("");
1989            continue;
1990        }
1991        if let Some(rest) = sql.strip_prefix("/*") {
1992            let Some((_, after)) = rest.split_once("*/") else {
1993                return "";
1994            };
1995            sql = after;
1996            continue;
1997        }
1998        return sql;
1999    }
2000}
2001
2002struct PsqlStartupArgs<'a> {
2003    mode: &'a str,
2004    sql: Option<&'a str>,
2005    sql_file: Option<String>,
2006    param_count: usize,
2007}
2008
2009fn psql_startup_args(args: PsqlStartupArgs<'_>) -> Value {
2010    startup_args(
2011        args.mode,
2012        args.sql,
2013        args.sql_file.as_deref(),
2014        args.param_count,
2015    )
2016}
2017
2018fn resolve_secret_value(
2019    flag_name: &str,
2020    direct: Option<String>,
2021    env_name: Option<&str>,
2022    config: Option<&SecretConfigRef>,
2023) -> Result<Option<String>, String> {
2024    let source_count = usize::from(direct.is_some())
2025        + usize::from(env_name.is_some())
2026        + usize::from(config.is_some());
2027    if source_count > 1 {
2028        return Err(format!(
2029            "{flag_name}, {flag_name}-env, and {flag_name}-config are mutually exclusive"
2030        ));
2031    }
2032    match (direct, env_name, config) {
2033        (Some(value), None, None) => Ok(Some(value)),
2034        (None, Some(name), None) => {
2035            if name.is_empty() {
2036                return Err(format!(
2037                    "{flag_name}-env requires a non-empty variable name"
2038                ));
2039            }
2040            std::env::var(name).map(Some).map_err(|_| {
2041                format!("{flag_name}-env references unset environment variable: {name}")
2042            })
2043        }
2044        (None, None, Some(reference)) => {
2045            resolve_config_secret(&format!("{flag_name}-config"), reference).map(Some)
2046        }
2047        (None, None, None) => Ok(None),
2048        _ => Err(format!(
2049            "{flag_name}, {flag_name}-env, and {flag_name}-config are mutually exclusive"
2050        )),
2051    }
2052}
2053
2054pub fn parse_params(entries: &[String]) -> Result<Vec<Value>, String> {
2055    if entries.len() > MAX_PARAMS {
2056        return Err(format!("too many params; maximum params is {MAX_PARAMS}"));
2057    }
2058
2059    let mut by_index: BTreeMap<usize, Value> = BTreeMap::new();
2060    for entry in entries {
2061        let (idx, raw) = split_index_value(entry)?;
2062        if idx == 0 {
2063            return Err("param index must start at 1".to_string());
2064        }
2065        if idx > MAX_PARAMS {
2066            return Err(format!(
2067                "parameter index {idx} exceeds maximum params {MAX_PARAMS}"
2068            ));
2069        }
2070        match by_index.entry(idx) {
2071            Entry::Vacant(slot) => {
2072                slot.insert(parse_param_value(raw));
2073            }
2074            Entry::Occupied(_) => return Err(format!("duplicate parameter index {idx}")),
2075        }
2076    }
2077    if by_index.is_empty() {
2078        return Ok(vec![]);
2079    }
2080    let max = by_index.keys().max().copied().unwrap_or(0);
2081    for i in 1..=max {
2082        if !by_index.contains_key(&i) {
2083            return Err(format!("missing parameter index {i}"));
2084        }
2085    }
2086    Ok(by_index.into_values().collect())
2087}
2088
2089fn split_index_value(entry: &str) -> Result<(usize, &str), String> {
2090    let mut parts = entry.splitn(2, '=');
2091    let left = parts.next().unwrap_or_default();
2092    let right = parts
2093        .next()
2094        .ok_or_else(|| format!("invalid param '{entry}', expected N=value"))?;
2095    let idx = left
2096        .parse::<usize>()
2097        .map_err(|_| format!("invalid param index in '{entry}'"))?;
2098    Ok((idx, right))
2099}
2100
2101fn parse_param_value(v: &str) -> Value {
2102    if v == "null" {
2103        return Value::Null;
2104    }
2105    if v == "true" {
2106        return Value::Bool(true);
2107    }
2108    if v == "false" {
2109        return Value::Bool(false);
2110    }
2111    // Strings are passed verbatim to PostgreSQL via the text bind path so
2112    // that values like "00123" or "1.0" preserve their original form. The
2113    // server coerces them based on the prepared statement's parameter type.
2114    Value::String(v.to_string())
2115}
2116
2117fn wrap_explain_sql(user_sql: &str, analyze: bool) -> String {
2118    let body = user_sql.trim_end_matches([';', ' ', '\n', '\t', '\r']);
2119    if analyze {
2120        format!("explain (analyze true, format json, buffers true) {body}")
2121    } else {
2122        format!("explain (format json) {body}")
2123    }
2124}
2125
2126fn optional_string_value(value: Option<String>) -> Value {
2127    value.map(Value::String).unwrap_or(Value::Null)
2128}
2129
2130fn split_table_name(default_schema: String, name: String) -> (String, String) {
2131    match name.split_once('.') {
2132        Some((schema, table)) => (schema.to_string(), table.to_string()),
2133        None => (default_schema, name),
2134    }
2135}
2136
2137fn split_optional_table(default_schema: String, table: Option<String>) -> (String, Option<String>) {
2138    match table {
2139        Some(name) => {
2140            let (schema, table_name) = split_table_name(default_schema, name);
2141            (schema, Some(table_name))
2142        }
2143        None => (default_schema, None),
2144    }
2145}
2146
2147fn full_schema_snapshot_sql(relation_filter: &str, schema_only_filter: &str) -> String {
2148    format!(
2149        "with relation_filter as ( \
2150             select c.oid, c.relname, c.relkind, c.relpersistence, c.reltuples, c.relowner, \
2151                    n.nspname, pg_catalog.obj_description(c.oid, 'pg_class') as comment \
2152             from pg_catalog.pg_class c \
2153             join pg_catalog.pg_namespace n on n.oid = c.relnamespace \
2154             where n.nspname = $1 \
2155               and c.relkind in ('r', 'p', 'f', 'v', 'm', 'S') \
2156               and ({relation_filter}) \
2157         ), snapshot as ( \
2158             select 'extension'::text as kind, \
2159                    n.nspname::text as schema, \
2160                    null::text as relation, \
2161                    e.extname::text as name, \
2162                    'extension'::text as object_type, \
2163                    null::integer as position, \
2164                    null::text as definition, \
2165                    null::bigint as size_bytes, \
2166                    null::text as size, \
2167                    null::bigint as estimated_rows, \
2168                    pg_catalog.jsonb_build_object('version', e.extversion) as payload \
2169             from pg_catalog.pg_extension e \
2170             join pg_catalog.pg_namespace n on n.oid = e.extnamespace \
2171             where n.nspname = $1 and ({schema_only_filter}) \
2172             union all \
2173             select 'relation'::text as kind, \
2174                    rf.nspname::text as schema, \
2175                    rf.relname::text as relation, \
2176                    rf.relname::text as name, \
2177                    case rf.relkind \
2178                        when 'r' then 'table' \
2179                        when 'p' then 'partitioned table' \
2180                        when 'f' then 'foreign table' \
2181                        when 'v' then 'view' \
2182                        when 'm' then 'materialized view' \
2183                        else rf.relkind::text \
2184                    end as object_type, \
2185                    null::integer as position, \
2186                    case when rf.relkind in ('v', 'm') \
2187                         then pg_catalog.pg_get_viewdef(rf.oid, true) end as definition, \
2188                    case when rf.relkind in ('r', 'p', 'm') \
2189                         then pg_catalog.pg_total_relation_size(rf.oid) end as size_bytes, \
2190                    case when rf.relkind in ('r', 'p', 'm') \
2191                         then pg_catalog.pg_size_pretty(pg_catalog.pg_total_relation_size(rf.oid)) end as size, \
2192                    rf.reltuples::bigint as estimated_rows, \
2193                    pg_catalog.jsonb_build_object( \
2194                        'owner', pg_catalog.pg_get_userbyid(rf.relowner), \
2195                        'persistence', rf.relpersistence, \
2196                        'comment', rf.comment \
2197                    ) as payload \
2198             from relation_filter rf \
2199             where rf.relkind in ('r', 'p', 'f', 'v', 'm') \
2200             union all \
2201             select 'sequence'::text as kind, \
2202                    rf.nspname::text as schema, \
2203                    rf.relname::text as relation, \
2204                    rf.relname::text as name, \
2205                    'sequence'::text as object_type, \
2206                    null::integer as position, \
2207                    null::text as definition, \
2208                    pg_catalog.pg_relation_size(rf.oid) as size_bytes, \
2209                    pg_catalog.pg_size_pretty(pg_catalog.pg_relation_size(rf.oid)) as size, \
2210                    null::bigint as estimated_rows, \
2211                    pg_catalog.jsonb_build_object( \
2212                        'owner', pg_catalog.pg_get_userbyid(rf.relowner), \
2213                        'comment', rf.comment \
2214                    ) as payload \
2215             from relation_filter rf \
2216             where rf.relkind = 'S' \
2217             union all \
2218             select 'column'::text as kind, \
2219                    rf.nspname::text as schema, \
2220                    rf.relname::text as relation, \
2221                    a.attname::text as name, \
2222                    pg_catalog.format_type(a.atttypid, a.atttypmod)::text as object_type, \
2223                    a.attnum::integer as position, \
2224                    pg_catalog.pg_get_expr(ad.adbin, ad.adrelid)::text as definition, \
2225                    null::bigint as size_bytes, \
2226                    null::text as size, \
2227                    null::bigint as estimated_rows, \
2228                    pg_catalog.jsonb_build_object( \
2229                        'nullable', not a.attnotnull, \
2230                        'primary_key', coalesce(pk.is_primary, false), \
2231                        'identity', a.attidentity::text, \
2232                        'generated', a.attgenerated::text, \
2233                        'serial_sequence', pg_catalog.pg_get_serial_sequence( \
2234                            pg_catalog.format('%I.%I', rf.nspname, rf.relname), a.attname), \
2235                        'comment', pg_catalog.col_description(rf.oid, a.attnum) \
2236                    ) as payload \
2237             from pg_catalog.pg_attribute a \
2238             join relation_filter rf on rf.oid = a.attrelid \
2239             left join pg_catalog.pg_attrdef ad on ad.adrelid = a.attrelid and ad.adnum = a.attnum \
2240             left join lateral ( \
2241                 select true as is_primary \
2242                 from pg_catalog.pg_index i \
2243                 where i.indrelid = a.attrelid and i.indisprimary \
2244                   and a.attnum = any(i.indkey) \
2245             ) pk on true \
2246             where rf.relkind in ('r', 'p', 'f', 'v', 'm') \
2247               and a.attnum > 0 and not a.attisdropped \
2248             union all \
2249             select 'constraint'::text as kind, \
2250                    rf.nspname::text as schema, \
2251                    rf.relname::text as relation, \
2252                    con.conname::text as name, \
2253                    case con.contype \
2254                        when 'p' then 'primary key' \
2255                        when 'u' then 'unique' \
2256                        when 'f' then 'foreign key' \
2257                        when 'c' then 'check' \
2258                        when 'x' then 'exclusion' \
2259                        else con.contype::text \
2260                    end as object_type, \
2261                    null::integer as position, \
2262                    pg_catalog.pg_get_constraintdef(con.oid, true)::text as definition, \
2263                    null::bigint as size_bytes, \
2264                    null::text as size, \
2265                    null::bigint as estimated_rows, \
2266                    pg_catalog.jsonb_build_object( \
2267                        'type', con.contype::text, \
2268                        'deferrable', con.condeferrable, \
2269                        'deferred_by_default', con.condeferred, \
2270                        'validated', con.convalidated \
2271                    ) as payload \
2272             from pg_catalog.pg_constraint con \
2273             join relation_filter rf on rf.oid = con.conrelid \
2274             union all \
2275             select 'index'::text as kind, \
2276                    rf.nspname::text as schema, \
2277                    rf.relname::text as relation, \
2278                    ic.relname::text as name, \
2279                    am.amname::text as object_type, \
2280                    null::integer as position, \
2281                    pg_catalog.pg_get_indexdef(i.indexrelid)::text as definition, \
2282                    pg_catalog.pg_relation_size(i.indexrelid) as size_bytes, \
2283                    pg_catalog.pg_size_pretty(pg_catalog.pg_relation_size(i.indexrelid)) as size, \
2284                    null::bigint as estimated_rows, \
2285                    pg_catalog.jsonb_build_object( \
2286                        'unique', i.indisunique, \
2287                        'primary', i.indisprimary, \
2288                        'valid', i.indisvalid, \
2289                        'ready', i.indisready \
2290                    ) as payload \
2291             from pg_catalog.pg_index i \
2292             join pg_catalog.pg_class ic on ic.oid = i.indexrelid \
2293             join relation_filter rf on rf.oid = i.indrelid \
2294             join pg_catalog.pg_am am on am.oid = ic.relam \
2295             union all \
2296             select 'trigger'::text as kind, \
2297                    rf.nspname::text as schema, \
2298                    rf.relname::text as relation, \
2299                    tg.tgname::text as name, \
2300                    'trigger'::text as object_type, \
2301                    null::integer as position, \
2302                    pg_catalog.pg_get_triggerdef(tg.oid, true)::text as definition, \
2303                    null::bigint as size_bytes, \
2304                    null::text as size, \
2305                    null::bigint as estimated_rows, \
2306                    pg_catalog.jsonb_build_object( \
2307                        'enabled', tg.tgenabled::text, \
2308                        'function_schema', fn_ns.nspname, \
2309                        'function_name', fn.proname \
2310                    ) as payload \
2311             from pg_catalog.pg_trigger tg \
2312             join relation_filter rf on rf.oid = tg.tgrelid \
2313             join pg_catalog.pg_proc fn on fn.oid = tg.tgfoid \
2314             join pg_catalog.pg_namespace fn_ns on fn_ns.oid = fn.pronamespace \
2315             where not tg.tgisinternal \
2316             union all \
2317             select 'function'::text as kind, \
2318                    n.nspname::text as schema, \
2319                    null::text as relation, \
2320                    (p.proname || '(' || pg_catalog.pg_get_function_identity_arguments(p.oid) || ')')::text as name, \
2321                    'function'::text as object_type, \
2322                    null::integer as position, \
2323                    pg_catalog.pg_get_functiondef(p.oid)::text as definition, \
2324                    null::bigint as size_bytes, \
2325                    null::text as size, \
2326                    null::bigint as estimated_rows, \
2327                    pg_catalog.jsonb_build_object( \
2328                        'language', l.lanname, \
2329                        'result', pg_catalog.pg_get_function_result(p.oid), \
2330                        'identity_args', pg_catalog.pg_get_function_identity_arguments(p.oid) \
2331                    ) as payload \
2332             from pg_catalog.pg_proc p \
2333             join pg_catalog.pg_namespace n on n.oid = p.pronamespace \
2334             join pg_catalog.pg_language l on l.oid = p.prolang \
2335             where n.nspname = $1 \
2336               and p.prokind = 'f' \
2337               and ({schema_only_filter}) \
2338               and not exists ( \
2339                   select 1 \
2340                   from pg_catalog.pg_depend d \
2341                   where d.classid = 'pg_catalog.pg_proc'::regclass \
2342                     and d.objid = p.oid \
2343                     and d.deptype = 'e' \
2344               ) \
2345         ) \
2346         select * from snapshot \
2347         order by case kind \
2348                    when 'extension' then 0 \
2349                    when 'relation' then 1 \
2350                    when 'sequence' then 2 \
2351                    when 'column' then 3 \
2352                    when 'constraint' then 4 \
2353                    when 'index' then 5 \
2354                    when 'trigger' then 6 \
2355                    when 'function' then 7 \
2356                    else 99 end, \
2357                  schema, relation nulls first, position nulls last, name"
2358    )
2359}
2360
2361fn build_schema_snapshot_sql(args: InspectSchemaArgs) -> (String, Vec<Value>) {
2362    (
2363        full_schema_snapshot_sql("$2::text is null or c.relname like $2", "$2::text is null"),
2364        vec![Value::String(args.schema), optional_string_value(args.like)],
2365    )
2366}
2367
2368fn build_table_full_sql(schema: String, name: String) -> (String, Vec<Value>) {
2369    (
2370        full_schema_snapshot_sql("c.relname = $2", "false"),
2371        vec![Value::String(schema), Value::String(name)],
2372    )
2373}
2374
2375fn build_inspect_indexes_sql(args: InspectIndexesArgs) -> (String, Vec<Value>) {
2376    let (schema, table) = split_optional_table(args.schema, args.table);
2377    let mut sql = String::from(
2378        "select n.nspname as schema, \
2379                tc.relname as table, \
2380                ic.relname as name, \
2381                am.amname as method, \
2382                i.indisunique as unique, \
2383                i.indisprimary as primary, \
2384                i.indisvalid as valid, \
2385                i.indisready as ready, \
2386                pg_catalog.pg_get_indexdef(i.indexrelid) as definition, \
2387                pg_catalog.pg_relation_size(i.indexrelid) as size_bytes, \
2388                pg_catalog.pg_size_pretty(pg_catalog.pg_relation_size(i.indexrelid)) as size",
2389    );
2390    if args.stats {
2391        sql.push_str(
2392            ", s.idx_scan as index_scan_count, \
2393             s.idx_tup_read as index_tuple_read_count, \
2394             s.idx_tup_fetch as index_tuple_fetch_count",
2395        );
2396    }
2397    sql.push_str(
2398        " from pg_catalog.pg_index i \
2399          join pg_catalog.pg_class ic on ic.oid = i.indexrelid \
2400          join pg_catalog.pg_class tc on tc.oid = i.indrelid \
2401          join pg_catalog.pg_namespace n on n.oid = tc.relnamespace \
2402          join pg_catalog.pg_am am on am.oid = ic.relam",
2403    );
2404    if args.stats {
2405        sql.push_str(" left join pg_catalog.pg_stat_user_indexes s on s.indexrelid = i.indexrelid");
2406    }
2407    sql.push_str(" where n.nspname = $1");
2408
2409    let mut params = vec![Value::String(schema)];
2410    if let Some(table_name) = table {
2411        sql.push_str(" and tc.relname = $2");
2412        params.push(Value::String(table_name));
2413    }
2414    sql.push_str(" order by tc.relname, ic.relname");
2415    (sql, params)
2416}
2417
2418fn build_inspect_sql(action: InspectAction) -> (String, Vec<Value>) {
2419    match action {
2420        InspectAction::Databases(args) => {
2421            let mut sql = String::from(
2422                "select d.datname as database, \
2423                        pg_catalog.pg_get_userbyid(d.datdba) as owner, \
2424                        pg_catalog.pg_encoding_to_char(d.encoding) as encoding, \
2425                        d.datcollate as collate, \
2426                        d.datctype as ctype, \
2427                        d.datistemplate as is_template, \
2428                        d.datallowconn as allow_connections, \
2429                        d.datconnlimit as connection_limit, \
2430                        case when has_database_privilege(d.datname, 'CONNECT') \
2431                             then pg_catalog.pg_database_size(d.oid) end as size_bytes, \
2432                        case when has_database_privilege(d.datname, 'CONNECT') \
2433                             then pg_catalog.pg_size_pretty(pg_catalog.pg_database_size(d.oid)) end as size, \
2434                        s.numbackends as active_connections \
2435                 from pg_catalog.pg_database d \
2436                 left join pg_catalog.pg_stat_database s on s.datid = d.oid",
2437            );
2438            if !args.all {
2439                sql.push_str(" where not d.datistemplate");
2440            }
2441            sql.push_str(" order by d.datname");
2442            (sql, vec![])
2443        }
2444        InspectAction::Database => (
2445            "with rels as ( \
2446                 select c.relkind \
2447                 from pg_catalog.pg_class c \
2448                 join pg_catalog.pg_namespace n on n.oid = c.relnamespace \
2449                 where n.nspname not in ('pg_catalog', 'information_schema') \
2450                   and n.nspname not like 'pg_toast%' \
2451                   and n.nspname not like 'pg_temp_%' \
2452             ) \
2453             select current_database() as database, \
2454                    ( select count(*) from pg_catalog.pg_namespace n \
2455                       where n.nspname not in ('pg_catalog', 'information_schema') \
2456                         and n.nspname not like 'pg_toast%' \
2457                         and n.nspname not like 'pg_temp_%' ) as schemas, \
2458                    count(*) filter (where relkind in ('r', 'p')) as tables, \
2459                    count(*) filter (where relkind = 'v') as views, \
2460                    count(*) filter (where relkind = 'm') as materialized_views, \
2461                    count(*) filter (where relkind = 'S') as sequences, \
2462                    pg_catalog.pg_database_size(current_database()) as size_bytes, \
2463                    pg_catalog.pg_size_pretty(pg_catalog.pg_database_size(current_database())) as size \
2464             from rels"
2465                .to_string(),
2466            vec![],
2467        ),
2468        InspectAction::Schemas => (
2469            "select n.nspname as schema, \
2470                    pg_catalog.pg_get_userbyid(n.nspowner) as owner, \
2471                    count(*) filter (where c.relkind in ('r', 'p')) as tables, \
2472                    count(*) filter (where c.relkind = 'v') as views, \
2473                    count(*) filter (where c.relkind = 'm') as materialized_views, \
2474                    count(*) filter (where c.relkind = 'S') as sequences, \
2475                    pg_catalog.pg_size_pretty(coalesce( \
2476                        sum(pg_catalog.pg_total_relation_size(c.oid)) \
2477                            filter (where c.relkind in ('r', 'p', 'm')), 0)) as size \
2478             from pg_catalog.pg_namespace n \
2479             left join pg_catalog.pg_class c on c.relnamespace = n.oid \
2480             where n.nspname not in ('pg_catalog', 'information_schema') \
2481               and n.nspname not like 'pg_toast%' \
2482               and n.nspname not like 'pg_temp_%' \
2483             group by n.nspname, n.nspowner \
2484             order by n.nspname"
2485                .to_string(),
2486            vec![],
2487        ),
2488        InspectAction::Schema(args) | InspectAction::Snapshot(args) => build_schema_snapshot_sql(args),
2489        InspectAction::Tables(args) => {
2490            let mut sql = String::from(
2491                "select n.nspname as schema, \
2492                        c.relname as name, \
2493                        case c.relkind when 'r' then 'table' \
2494                                       when 'p' then 'partitioned table' \
2495                                       when 'f' then 'foreign table' end as kind, \
2496                        pg_catalog.pg_get_userbyid(c.relowner) as owner, \
2497                        c.reltuples::bigint as estimated_rows, \
2498                        pg_catalog.pg_size_pretty(pg_catalog.pg_total_relation_size(c.oid)) as size, \
2499                        pg_catalog.pg_total_relation_size(c.oid) as size_bytes \
2500                 from pg_catalog.pg_class c \
2501                 join pg_catalog.pg_namespace n on n.oid = c.relnamespace \
2502                 where n.nspname = $1 and c.relkind in ('r', 'p', 'f')",
2503            );
2504            let mut params = vec![Value::String(args.schema)];
2505            if let Some(pattern) = args.like {
2506                sql.push_str(" and c.relname like $2");
2507                params.push(Value::String(pattern));
2508            }
2509            sql.push_str(" order by c.relname");
2510            (sql, params)
2511        }
2512        InspectAction::Views(args) => {
2513            let mut sql = String::from(
2514                "select n.nspname as schema, \
2515                        c.relname as name, \
2516                        case c.relkind when 'm' then true else false end as materialized, \
2517                        pg_catalog.pg_get_userbyid(c.relowner) as owner \
2518                 from pg_catalog.pg_class c \
2519                 join pg_catalog.pg_namespace n on n.oid = c.relnamespace \
2520                 where n.nspname = $1 and c.relkind in ('v', 'm')",
2521            );
2522            let mut params = vec![Value::String(args.schema)];
2523            if let Some(pattern) = args.like {
2524                sql.push_str(" and c.relname like $2");
2525                params.push(Value::String(pattern));
2526            }
2527            sql.push_str(" order by c.relname");
2528            (sql, params)
2529        }
2530        InspectAction::Indexes(args) => build_inspect_indexes_sql(args),
2531        InspectAction::Table(args) => {
2532            let (schema, name) = split_table_name("public".to_string(), args.name);
2533            if args.full {
2534                return build_table_full_sql(schema, name);
2535            }
2536            (
2537                "select a.attname as name, \
2538                        pg_catalog.format_type(a.atttypid, a.atttypmod) as type, \
2539                        not a.attnotnull as nullable, \
2540                        pg_catalog.pg_get_expr(ad.adbin, ad.adrelid) as default, \
2541                        a.attnum as position, \
2542                        coalesce(pk.is_primary, false) as primary_key, \
2543                        pg_catalog.col_description(c.oid, a.attnum) as comment \
2544                 from pg_catalog.pg_attribute a \
2545                 join pg_catalog.pg_class c on c.oid = a.attrelid \
2546                 join pg_catalog.pg_namespace n on n.oid = c.relnamespace \
2547                 left join pg_catalog.pg_attrdef ad \
2548                     on ad.adrelid = a.attrelid and ad.adnum = a.attnum \
2549                 left join lateral ( \
2550                     select true as is_primary \
2551                     from pg_catalog.pg_index i \
2552                     where i.indrelid = a.attrelid and i.indisprimary \
2553                       and a.attnum = any(i.indkey) \
2554                 ) pk on true \
2555                 where n.nspname = $1 and c.relname = $2 \
2556                   and a.attnum > 0 and not a.attisdropped \
2557                 order by a.attnum"
2558                    .to_string(),
2559                vec![Value::String(schema), Value::String(name)],
2560            )
2561        }
2562    }
2563}
2564
2565#[cfg(test)]
2566#[path = "../tests/support/unit_cli.rs"]
2567mod tests;