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 Psql(PsqlCommand),
62}
63
64#[derive(Args)]
65struct PsqlCommand {
66 #[command(subcommand)]
67 action: PsqlCliAction,
68}
69
70#[derive(Subcommand)]
71enum PsqlCliAction {
72 Status(PsqlPathArgs),
74 Install(PsqlPathArgs),
76 Uninstall(PsqlPathArgs),
78}
79
80#[derive(Args)]
81struct PsqlPathArgs {
82 #[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 #[arg(long, help_heading = "Query")]
140 sql: Option<String>,
141 #[arg(long = "sql-file", help_heading = "Query")]
143 sql_file: Option<String>,
144 #[arg(long = "param", help_heading = "Query")]
146 param: Vec<String>,
147 #[arg(long = "stream-rows", help_heading = "Query")]
149 stream_rows: bool,
150 #[arg(long = "batch-rows", help_heading = "Query")]
152 batch_rows: Option<usize>,
153 #[arg(long = "batch-bytes", help_heading = "Query")]
155 batch_bytes: Option<usize>,
156 #[arg(long = "statement-timeout-ms", help_heading = "Query")]
158 statement_timeout_ms: Option<u64>,
159 #[arg(long = "lock-timeout-ms", help_heading = "Query")]
161 lock_timeout_ms: Option<u64>,
162 #[arg(long = "inline-max-rows", help_heading = "Query")]
164 inline_max_rows: Option<usize>,
165 #[arg(long = "inline-max-bytes", help_heading = "Query")]
167 inline_max_bytes: Option<usize>,
168 #[arg(long = "read-only", help_heading = "Query")]
170 read_only: bool,
171 #[arg(long, help_heading = "Query")]
173 dry_run: bool,
174
175 #[arg(long = "dsn-secret", help_heading = "Connection")]
177 dsn_secret: Option<String>,
178 #[arg(long = "dsn-secret-env", help_heading = "Connection")]
180 dsn_secret_env: Option<String>,
181 #[arg(long = "conninfo-secret", help_heading = "Connection")]
183 conninfo_secret: Option<String>,
184 #[arg(long, help_heading = "Connection")]
186 host: Option<String>,
187 #[arg(long, help_heading = "Connection")]
189 port: Option<u16>,
190 #[arg(long, help_heading = "Connection")]
192 user: Option<String>,
193 #[arg(long, help_heading = "Connection")]
195 dbname: Option<String>,
196 #[arg(long = "password-secret", help_heading = "Connection")]
198 password_secret: Option<String>,
199 #[arg(long = "password-secret-env", help_heading = "Connection")]
201 password_secret_env: Option<String>,
202
203 #[arg(long, default_value = "json", help_heading = "Runtime")]
205 output: String,
206 #[arg(long = "log", value_delimiter = ',', help_heading = "Runtime")]
208 log: Vec<String>,
209 #[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 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 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(¶ms_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: ¶ms_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;