Skip to main content

agent_first_psql/
cli.rs

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