Skip to main content

agent_first_psql/
cli.rs

1use std::io::Write;
2
3use crate::types::{QueryOptions, SessionConfig};
4use agent_first_data::{cli_parse_log_filters, cli_parse_output, OutputFormat};
5use clap::{Args, CommandFactory, Parser, Subcommand, ValueEnum};
6use serde_json::{json, Value};
7use std::collections::BTreeMap;
8
9pub enum Mode {
10    Cli(CliRequest),
11    Pipe(PipeInit),
12    PsqlAdmin(PsqlAdminRequest),
13}
14
15pub struct PipeInit {
16    pub output: OutputFormat,
17    pub session: SessionConfig,
18    pub log: Vec<String>,
19    pub startup_args: Value,
20    pub startup_env: Value,
21    pub startup_requested: bool,
22}
23
24#[derive(Debug, Clone)]
25pub struct PsqlAdminRequest {
26    pub action: PsqlAdminAction,
27    pub output: OutputFormat,
28}
29
30#[derive(Debug, Clone)]
31pub enum PsqlAdminAction {
32    Status { bin_dir: Option<String> },
33    Install { bin_dir: Option<String> },
34    Uninstall { bin_dir: Option<String> },
35}
36
37pub struct CliRequest {
38    pub sql: String,
39    pub params: Vec<Value>,
40    pub options: QueryOptions,
41    pub session: SessionConfig,
42    pub output: OutputFormat,
43    pub log: Vec<String>,
44    pub startup_args: Value,
45    pub startup_env: Value,
46    pub startup_requested: bool,
47    pub dry_run: bool,
48}
49
50#[derive(Clone, Copy, Debug, PartialEq, Eq, ValueEnum)]
51enum RuntimeMode {
52    Cli,
53    Pipe,
54    #[value(name = "psql")]
55    Psql,
56}
57
58#[derive(Subcommand)]
59enum AfdCommand {
60    /// Manage the local psql wrapper for afpsql --mode psql.
61    Psql(PsqlCommand),
62}
63
64#[derive(Args)]
65struct PsqlCommand {
66    #[command(subcommand)]
67    action: PsqlCliAction,
68}
69
70#[derive(Subcommand)]
71enum PsqlCliAction {
72    /// Show whether the afpsql-managed psql wrapper is installed and active.
73    Status(PsqlPathArgs),
74    /// Install an afpsql-managed psql wrapper.
75    Install(PsqlPathArgs),
76    /// Remove an afpsql-managed psql wrapper.
77    Uninstall(PsqlPathArgs),
78}
79
80#[derive(Args)]
81struct PsqlPathArgs {
82    /// Directory that contains the psql wrapper. Defaults to the afpsql executable directory.
83    #[arg(long = "bin-dir")]
84    bin_dir: Option<String>,
85}
86
87#[doc = r#"Agent-First PostgreSQL client.
88
89### Interface Policy
90
91- default mode is canonical agent-first CLI
92- `--mode psql` is argument translation only; runtime output stays JSONL
93- stdout carries protocol events; stderr is not a protocol channel
94
95### Query Sources and Parameters
96
97- use `--sql` for inline SQL or `--sql-file` for a file
98- use repeatable `--param N=value` for positional binds
99- placeholder count is validated from prepared-statement metadata, not by SQL text scanning
100
101### Connection Sources
102
103- `--dsn-secret` for a PostgreSQL URI
104- `--conninfo-secret` for libpq-style conninfo
105- or discrete `--host`, `--port`, `--user`, `--dbname`, `--password-secret`
106- agent-first environment fallbacks: `AFPSQL_*`
107- PostgreSQL environment fallbacks: `PGHOST`, `PGPORT`, `PGUSER`, `PGDATABASE`
108
109### Result Shaping
110
111- default mode buffers a bounded inline result
112- use `--stream-rows` for large result sets, with `--batch-rows` and `--batch-bytes` to tune chunk size
113- `--output json|yaml|plain` changes rendering only, not the runtime schema
114
115### Examples
116
117```text
118afpsql --sql "select now() as now_rfc3339"
119afpsql --sql-file ./query.sql
120afpsql --sql "select * from users where id = $1" --param 1=123
121afpsql --dsn-secret "postgresql://app:secret@127.0.0.1:5432/appdb" --sql "select 1"
122afpsql --mode psql -h 127.0.0.1 -p 5432 -U app -d appdb -c "select 1"
123afpsql --sql "select * from big_table" --stream-rows --batch-rows 1000
124afpsql --mode pipe
125afpsql psql status
126afpsql psql install
127```
128
129### Exit Codes
130
131- `0`: query completed successfully
132- `1`: SQL error or runtime error
133- `2`: invalid CLI arguments
134"#]
135#[derive(Parser)]
136#[command(name = "afpsql", version, verbatim_doc_comment)]
137pub struct AfdCli {
138    /// Inline SQL string to execute.
139    #[arg(long, help_heading = "Query")]
140    sql: Option<String>,
141    /// Read SQL from a file.
142    #[arg(long = "sql-file", help_heading = "Query")]
143    sql_file: Option<String>,
144    /// Positional bind parameter in `N=value` form. Repeat for additional parameters.
145    #[arg(long = "param", help_heading = "Query")]
146    param: Vec<String>,
147    /// Stream large result sets as `result_rows` batches instead of a single inline result.
148    #[arg(long = "stream-rows", help_heading = "Query")]
149    stream_rows: bool,
150    /// Maximum rows per streamed batch.
151    #[arg(long = "batch-rows", help_heading = "Query")]
152    batch_rows: Option<usize>,
153    /// Soft byte target per streamed batch.
154    #[arg(long = "batch-bytes", help_heading = "Query")]
155    batch_bytes: Option<usize>,
156    /// Per-query statement timeout in milliseconds.
157    #[arg(long = "statement-timeout-ms", help_heading = "Query")]
158    statement_timeout_ms: Option<u64>,
159    /// Per-query lock timeout in milliseconds.
160    #[arg(long = "lock-timeout-ms", help_heading = "Query")]
161    lock_timeout_ms: Option<u64>,
162    /// Maximum inline rows before returning `result_too_large`.
163    #[arg(long = "inline-max-rows", help_heading = "Query")]
164    inline_max_rows: Option<usize>,
165    /// Maximum inline payload bytes before returning `result_too_large`.
166    #[arg(long = "inline-max-bytes", help_heading = "Query")]
167    inline_max_bytes: Option<usize>,
168    /// Force the query to run in a read-only transaction.
169    #[arg(long = "read-only", help_heading = "Query")]
170    read_only: bool,
171    /// Preview the query without executing it
172    #[arg(long, help_heading = "Query")]
173    dry_run: bool,
174
175    /// PostgreSQL DSN URI. Redacted in structured output.
176    #[arg(long = "dsn-secret", help_heading = "Connection")]
177    dsn_secret: Option<String>,
178    /// Read PostgreSQL DSN URI from an environment variable.
179    #[arg(long = "dsn-secret-env", help_heading = "Connection")]
180    dsn_secret_env: Option<String>,
181    /// libpq-style conninfo string. Redacted in structured output.
182    #[arg(long = "conninfo-secret", help_heading = "Connection")]
183    conninfo_secret: Option<String>,
184    /// PostgreSQL host.
185    #[arg(long, help_heading = "Connection")]
186    host: Option<String>,
187    /// PostgreSQL port.
188    #[arg(long, help_heading = "Connection")]
189    port: Option<u16>,
190    /// PostgreSQL user name.
191    #[arg(long, help_heading = "Connection")]
192    user: Option<String>,
193    /// PostgreSQL database name.
194    #[arg(long, help_heading = "Connection")]
195    dbname: Option<String>,
196    /// PostgreSQL password. Redacted in structured output.
197    #[arg(long = "password-secret", help_heading = "Connection")]
198    password_secret: Option<String>,
199    /// Read PostgreSQL password from an environment variable.
200    #[arg(long = "password-secret-env", help_heading = "Connection")]
201    password_secret_env: Option<String>,
202
203    /// Output format: json (default), yaml, or plain.
204    #[arg(long, default_value = "json", help_heading = "Runtime")]
205    output: String,
206    /// Diagnostic log categories.
207    #[arg(long = "log", value_delimiter = ',', help_heading = "Runtime")]
208    log: Vec<String>,
209    /// Runtime mode: canonical cli, pipe, or `psql` translation mode.
210    #[arg(long, value_enum, default_value_t = RuntimeMode::Cli, help_heading = "Runtime")]
211    mode: RuntimeMode,
212
213    #[command(subcommand)]
214    command: Option<AfdCommand>,
215}
216
217pub fn parse_args() -> Result<Mode, String> {
218    let raw: Vec<String> = std::env::args().collect();
219    if is_psql_mode_requested(&raw) {
220        return parse_psql_mode(&raw);
221    }
222    let startup_requested = startup_requested_from_raw(&raw);
223
224    // --help: recursive plain-text help (all subcommands expanded)
225    if top_level_help_requested(&raw) {
226        let _ = writeln!(
227            std::io::stdout(),
228            "{}",
229            agent_first_data::cli_render_help(&AfdCli::command(), &[])
230        );
231        std::process::exit(0);
232    }
233    // --help-markdown: Markdown for doc generation
234    if raw.iter().any(|a| a == "--help-markdown") {
235        let _ = writeln!(
236            std::io::stdout(),
237            "{}",
238            agent_first_data::cli_render_help_markdown(&AfdCli::command(), &[])
239        );
240        std::process::exit(0);
241    }
242
243    let cli = match AfdCli::try_parse_from(&raw) {
244        Ok(c) => c,
245        Err(e) => {
246            use clap::error::ErrorKind;
247            if matches!(e.kind(), ErrorKind::DisplayVersion | ErrorKind::DisplayHelp) {
248                let _ = writeln!(std::io::stdout(), "{e}");
249                std::process::exit(0);
250            }
251            return Err(e.to_string());
252        }
253    };
254    let output = parse_output(&cli.output)?;
255    let log = parse_log_categories(&cli.log);
256    let dsn_secret = resolve_secret_value(
257        "--dsn-secret",
258        cli.dsn_secret,
259        cli.dsn_secret_env.as_deref(),
260    )?;
261    let password_secret = resolve_secret_value(
262        "--password-secret",
263        cli.password_secret,
264        cli.password_secret_env.as_deref(),
265    )?;
266    let session = SessionConfig {
267        dsn_secret,
268        conninfo_secret: cli.conninfo_secret,
269        host: cli.host,
270        port: cli.port,
271        user: cli.user,
272        dbname: cli.dbname,
273        password_secret,
274    };
275    let mode_name = match cli.mode {
276        RuntimeMode::Cli => "cli",
277        RuntimeMode::Pipe => "pipe",
278        RuntimeMode::Psql => "psql",
279    };
280    let startup_args = json!({
281        "mode": mode_name,
282        "sql": &cli.sql,
283        "sql_file": &cli.sql_file,
284        "param_count": cli.param.len(),
285        "stream_rows": cli.stream_rows,
286        "batch_rows": cli.batch_rows,
287        "batch_bytes": cli.batch_bytes,
288        "statement_timeout_ms": cli.statement_timeout_ms,
289        "lock_timeout_ms": cli.lock_timeout_ms,
290        "inline_max_rows": cli.inline_max_rows,
291        "inline_max_bytes": cli.inline_max_bytes,
292        "read_only": cli.read_only,
293        "dsn_secret": &session.dsn_secret,
294        "dsn_secret_env": &cli.dsn_secret_env,
295        "conninfo_secret": &session.conninfo_secret,
296        "host": &session.host,
297        "port": session.port,
298        "user": &session.user,
299        "dbname": &session.dbname,
300        "password_secret": &session.password_secret,
301        "password_secret_env": &cli.password_secret_env,
302        "output": output_name(output),
303        "log": &log,
304    });
305    let startup_env = startup_env_snapshot();
306
307    if let Some(command) = cli.command {
308        return Ok(match command {
309            AfdCommand::Psql(psql) => Mode::PsqlAdmin(PsqlAdminRequest {
310                action: psql_admin_action(psql.action),
311                output,
312            }),
313        });
314    }
315
316    match cli.mode {
317        RuntimeMode::Pipe => {
318            return Ok(Mode::Pipe(PipeInit {
319                output,
320                session,
321                log: log.clone(),
322                startup_args,
323                startup_env,
324                startup_requested,
325            }));
326        }
327        RuntimeMode::Cli | RuntimeMode::Psql => {}
328    }
329
330    let sql = load_sql(cli.sql, cli.sql_file)?;
331    let params = parse_params(&cli.param)?;
332
333    let options = QueryOptions {
334        stream_rows: cli.stream_rows,
335        batch_rows: cli.batch_rows,
336        batch_bytes: cli.batch_bytes,
337        statement_timeout_ms: cli.statement_timeout_ms,
338        lock_timeout_ms: cli.lock_timeout_ms,
339        read_only: if cli.read_only { Some(true) } else { None },
340        inline_max_rows: cli.inline_max_rows,
341        inline_max_bytes: cli.inline_max_bytes,
342    };
343
344    Ok(Mode::Cli(CliRequest {
345        sql,
346        params,
347        options,
348        session,
349        output,
350        log,
351        startup_args,
352        startup_env,
353        startup_requested,
354        dry_run: cli.dry_run,
355    }))
356}
357
358fn parse_psql_mode(raw: &[String]) -> Result<Mode, String> {
359    let startup_requested = startup_requested_from_raw(raw);
360    let mut sql: Option<String> = None;
361    let mut sql_file: Option<String> = None;
362    let mut host: Option<String> = None;
363    let mut port: Option<u16> = None;
364    let mut user: Option<String> = None;
365    let mut dbname: Option<String> = None;
366    let mut dsn_secret: Option<String> = None;
367    let mut dsn_secret_env: Option<String> = None;
368    let mut conninfo_secret: Option<String> = None;
369    let mut password_secret: Option<String> = None;
370    let mut password_secret_env: Option<String> = None;
371    let mut params_kv: Vec<String> = vec![];
372    let mut output = OutputFormat::Json;
373    let mut log_entries: Vec<String> = vec![];
374
375    let mut i = 1usize;
376    while i < raw.len() {
377        match raw[i].as_str() {
378            "--mode" => {
379                i += 1;
380                let v = raw.get(i).ok_or("--mode requires value")?;
381                if v != "psql" {
382                    return Err(format!("unsupported psql-mode argument: --mode {v}; only --mode psql is allowed with psql translation"));
383                }
384                i += 1;
385            }
386            other if other.starts_with("--mode=") => {
387                let v = other.trim_start_matches("--mode=");
388                if v != "psql" {
389                    return Err(format!("unsupported psql-mode argument: {other}; only --mode=psql is allowed with psql translation"));
390                }
391                i += 1;
392            }
393            "-c" => {
394                i += 1;
395                let v = raw.get(i).ok_or("-c requires SQL")?;
396                sql = Some(v.clone());
397                i += 1;
398            }
399            "-f" => {
400                i += 1;
401                let v = raw.get(i).ok_or("-f requires file path")?;
402                sql_file = Some(v.clone());
403                i += 1;
404            }
405            "-h" => {
406                i += 1;
407                host = Some(raw.get(i).ok_or("-h requires value")?.clone());
408                i += 1;
409            }
410            "-p" => {
411                i += 1;
412                port = Some(
413                    raw.get(i)
414                        .ok_or("-p requires value")?
415                        .parse()
416                        .map_err(|_| "invalid -p port")?,
417                );
418                i += 1;
419            }
420            "-U" => {
421                i += 1;
422                user = Some(raw.get(i).ok_or("-U requires value")?.clone());
423                i += 1;
424            }
425            "-d" => {
426                i += 1;
427                dbname = Some(raw.get(i).ok_or("-d requires value")?.clone());
428                i += 1;
429            }
430            "--dsn-secret" => {
431                i += 1;
432                dsn_secret = Some(raw.get(i).ok_or("--dsn-secret requires value")?.clone());
433                i += 1;
434            }
435            "--dsn-secret-env" => {
436                i += 1;
437                dsn_secret_env = Some(raw.get(i).ok_or("--dsn-secret-env requires value")?.clone());
438                i += 1;
439            }
440            "--conninfo-secret" => {
441                i += 1;
442                conninfo_secret = Some(
443                    raw.get(i)
444                        .ok_or("--conninfo-secret requires value")?
445                        .clone(),
446                );
447                i += 1;
448            }
449            "--password-secret" => {
450                i += 1;
451                password_secret = Some(
452                    raw.get(i)
453                        .ok_or("--password-secret requires value")?
454                        .clone(),
455                );
456                i += 1;
457            }
458            "--password-secret-env" => {
459                i += 1;
460                password_secret_env = Some(
461                    raw.get(i)
462                        .ok_or("--password-secret-env requires value")?
463                        .clone(),
464                );
465                i += 1;
466            }
467            "-v" => {
468                i += 1;
469                params_kv.push(raw.get(i).ok_or("-v requires N=value")?.clone());
470                i += 1;
471            }
472            "--output" => {
473                i += 1;
474                output = parse_output(raw.get(i).ok_or("--output requires value")?)?;
475                i += 1;
476            }
477            "--log" => {
478                i += 1;
479                let values = raw.get(i).ok_or("--log requires value")?;
480                for part in values.split(',') {
481                    let trimmed = part.trim();
482                    if !trimmed.is_empty() {
483                        log_entries.push(trimmed.to_string());
484                    }
485                }
486                i += 1;
487            }
488            other if other.starts_with("postgresql://") || other.starts_with("postgres://") => {
489                dsn_secret = Some(other.to_string());
490                i += 1;
491            }
492            unsupported => {
493                return Err(format!(
494                    "unsupported psql-mode argument: {unsupported}; only --mode psql, -c/-f/-h/-p/-U/-d/-v/--dsn-secret/--dsn-secret-env/--conninfo-secret/--password-secret/--password-secret-env/--output/--log are supported"
495                ));
496            }
497        }
498    }
499
500    let dsn_secret = resolve_secret_value("--dsn-secret", dsn_secret, dsn_secret_env.as_deref())?;
501    let password_secret = resolve_secret_value(
502        "--password-secret",
503        password_secret,
504        password_secret_env.as_deref(),
505    )?;
506    let session = SessionConfig {
507        dsn_secret,
508        conninfo_secret,
509        host,
510        port,
511        user,
512        dbname,
513        password_secret,
514    };
515
516    let startup_sql = sql.clone();
517    let startup_sql_file = sql_file.clone();
518    let sql = load_sql(sql, sql_file)?;
519    let params = parse_params(&params_kv)?;
520    let startup_args = psql_startup_args(PsqlStartupArgs {
521        mode: "psql",
522        sql: startup_sql.or_else(|| Some(sql.clone())),
523        sql_file: startup_sql_file,
524        params_kv: &params_kv,
525        session: &session,
526        output,
527        log_entries: &log_entries,
528        dsn_secret_env: dsn_secret_env.as_deref(),
529        password_secret_env: password_secret_env.as_deref(),
530    });
531    Ok(Mode::Cli(CliRequest {
532        sql,
533        params,
534        options: QueryOptions::default(),
535        session,
536        output,
537        log: parse_log_categories(&log_entries),
538        startup_args,
539        startup_env: startup_env_snapshot(),
540        startup_requested,
541        dry_run: false,
542    }))
543}
544
545fn top_level_help_requested(raw: &[String]) -> bool {
546    raw.len() == 2 && matches!(raw.get(1).map(String::as_str), Some("--help" | "-h"))
547}
548
549fn psql_admin_action(action: PsqlCliAction) -> PsqlAdminAction {
550    match action {
551        PsqlCliAction::Status(args) => PsqlAdminAction::Status {
552            bin_dir: args.bin_dir,
553        },
554        PsqlCliAction::Install(args) => PsqlAdminAction::Install {
555            bin_dir: args.bin_dir,
556        },
557        PsqlCliAction::Uninstall(args) => PsqlAdminAction::Uninstall {
558            bin_dir: args.bin_dir,
559        },
560    }
561}
562
563fn is_psql_mode_requested(raw: &[String]) -> bool {
564    let mut i = 1usize;
565    while i < raw.len() {
566        let arg = raw[i].as_str();
567        if arg == "--mode" {
568            if let Some(v) = raw.get(i + 1) {
569                return v == "psql";
570            }
571            return false;
572        }
573        if arg == "--mode=psql" {
574            return true;
575        }
576        i += 1;
577    }
578    false
579}
580
581fn load_sql(sql: Option<String>, sql_file: Option<String>) -> Result<String, String> {
582    match (sql, sql_file) {
583        (Some(s), None) => Ok(s),
584        (None, Some(path)) => {
585            std::fs::read_to_string(path).map_err(|e| format!("read --sql-file failed: {e}"))
586        }
587        (Some(_), Some(_)) => Err("--sql and --sql-file are mutually exclusive".to_string()),
588        (None, None) => Err("one of --sql or --sql-file is required".to_string()),
589    }
590}
591
592fn parse_output(v: &str) -> Result<OutputFormat, String> {
593    cli_parse_output(v)
594}
595
596fn parse_log_categories(entries: &[String]) -> Vec<String> {
597    cli_parse_log_filters(entries)
598}
599
600fn startup_requested_from_raw(raw: &[String]) -> bool {
601    let mut i = 1usize;
602    while i < raw.len() {
603        if raw[i] == "--log" {
604            if let Some(values) = raw.get(i + 1) {
605                for part in values.split(',') {
606                    let v = part.trim().to_ascii_lowercase();
607                    if matches!(v.as_str(), "startup" | "all" | "*") {
608                        return true;
609                    }
610                }
611            }
612            i += 2;
613            continue;
614        }
615        if let Some(values) = raw[i].strip_prefix("--log=") {
616            for part in values.split(',') {
617                let v = part.trim().to_ascii_lowercase();
618                if matches!(v.as_str(), "startup" | "all" | "*") {
619                    return true;
620                }
621            }
622        }
623        i += 1;
624    }
625    false
626}
627
628fn output_name(output: OutputFormat) -> &'static str {
629    match output {
630        OutputFormat::Json => "json",
631        OutputFormat::Yaml => "yaml",
632        OutputFormat::Plain => "plain",
633    }
634}
635
636fn startup_env_snapshot() -> Value {
637    json!({
638        "AFPSQL_DSN_SECRET": std::env::var("AFPSQL_DSN_SECRET").ok(),
639        "AFPSQL_CONNINFO_SECRET": std::env::var("AFPSQL_CONNINFO_SECRET").ok(),
640        "AFPSQL_HOST": std::env::var("AFPSQL_HOST").ok(),
641        "AFPSQL_PORT": std::env::var("AFPSQL_PORT").ok(),
642        "AFPSQL_USER": std::env::var("AFPSQL_USER").ok(),
643        "AFPSQL_DBNAME": std::env::var("AFPSQL_DBNAME").ok(),
644        "AFPSQL_PASSWORD_SECRET": std::env::var("AFPSQL_PASSWORD_SECRET").ok(),
645        "PGHOST": std::env::var("PGHOST").ok(),
646        "PGPORT": std::env::var("PGPORT").ok(),
647        "PGUSER": std::env::var("PGUSER").ok(),
648        "PGDATABASE": std::env::var("PGDATABASE").ok(),
649    })
650}
651
652struct PsqlStartupArgs<'a> {
653    mode: &'a str,
654    sql: Option<String>,
655    sql_file: Option<String>,
656    params_kv: &'a [String],
657    session: &'a SessionConfig,
658    output: OutputFormat,
659    log_entries: &'a [String],
660    dsn_secret_env: Option<&'a str>,
661    password_secret_env: Option<&'a str>,
662}
663
664fn psql_startup_args(args: PsqlStartupArgs<'_>) -> Value {
665    json!({
666        "mode": args.mode,
667        "sql": args.sql,
668        "sql_file": args.sql_file,
669        "param_count": args.params_kv.len(),
670        "dsn_secret": args.session.dsn_secret,
671        "dsn_secret_env": args.dsn_secret_env,
672        "conninfo_secret": args.session.conninfo_secret,
673        "host": args.session.host,
674        "port": args.session.port,
675        "user": args.session.user,
676        "dbname": args.session.dbname,
677        "password_secret": args.session.password_secret,
678        "password_secret_env": args.password_secret_env,
679        "output": output_name(args.output),
680        "log": parse_log_categories(args.log_entries),
681    })
682}
683
684fn resolve_secret_value(
685    flag_name: &str,
686    direct: Option<String>,
687    env_name: Option<&str>,
688) -> Result<Option<String>, String> {
689    match (direct, env_name) {
690        (Some(_), Some(_)) => Err(format!(
691            "{flag_name} and {flag_name}-env are mutually exclusive"
692        )),
693        (Some(value), None) => Ok(Some(value)),
694        (None, Some(name)) => {
695            if name.is_empty() {
696                return Err(format!(
697                    "{flag_name}-env requires a non-empty variable name"
698                ));
699            }
700            std::env::var(name).map(Some).map_err(|_| {
701                format!("{flag_name}-env references unset environment variable: {name}")
702            })
703        }
704        (None, None) => Ok(None),
705    }
706}
707
708pub fn parse_params(entries: &[String]) -> Result<Vec<Value>, String> {
709    let mut by_index: BTreeMap<usize, Value> = BTreeMap::new();
710    for entry in entries {
711        let (idx, raw) = split_index_value(entry)?;
712        if idx == 0 {
713            return Err("param index must start at 1".to_string());
714        }
715        by_index.insert(idx, parse_param_value(raw));
716    }
717    if by_index.is_empty() {
718        return Ok(vec![]);
719    }
720    let max = by_index.keys().max().copied().unwrap_or(0);
721    let mut out = Vec::with_capacity(max);
722    for i in 1..=max {
723        let v = by_index
724            .remove(&i)
725            .ok_or_else(|| format!("missing parameter index {i}"))?;
726        out.push(v);
727    }
728    Ok(out)
729}
730
731fn split_index_value(entry: &str) -> Result<(usize, &str), String> {
732    let mut parts = entry.splitn(2, '=');
733    let left = parts.next().unwrap_or_default();
734    let right = parts
735        .next()
736        .ok_or_else(|| format!("invalid param '{entry}', expected N=value"))?;
737    let idx = left
738        .parse::<usize>()
739        .map_err(|_| format!("invalid param index in '{entry}'"))?;
740    Ok((idx, right))
741}
742
743fn parse_param_value(v: &str) -> Value {
744    if v == "null" {
745        return Value::Null;
746    }
747    if v == "true" {
748        return Value::Bool(true);
749    }
750    if v == "false" {
751        return Value::Bool(false);
752    }
753    if let Ok(i) = v.parse::<i64>() {
754        return Value::Number(i.into());
755    }
756    if let Ok(f) = v.parse::<f64>() {
757        if let Some(n) = serde_json::Number::from_f64(f) {
758            return Value::Number(n);
759        }
760    }
761    Value::String(v.to_string())
762}
763
764#[cfg(test)]
765#[path = "../tests/support/unit_cli.rs"]
766mod tests;