agent-first-psql 0.4.2

Persistent PostgreSQL client for AI agents — SQL-native JSONL in, JSONL out
Documentation
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]

use serde_json::Value;
use std::io::Write;
use std::path::PathBuf;
use std::process::{Command, Stdio};

fn bin() -> PathBuf {
    let exe = std::env::current_exe().expect("current exe");
    let debug_dir = exe
        .parent()
        .and_then(|p| p.parent())
        .expect("target debug dir");
    debug_dir.join("afpsql")
}

fn test_dsn() -> String {
    std::env::var("AFPSQL_TEST_DSN_SECRET")
        .or_else(|_| std::env::var("DATABASE_URL"))
        .unwrap_or_else(|_| "postgresql://localhost/postgres".to_string())
}

fn run(mut cmd: Command) -> (i32, String, String) {
    let out = cmd.output().expect("run command");
    (
        out.status.code().unwrap_or(-1),
        String::from_utf8_lossy(&out.stdout).to_string(),
        String::from_utf8_lossy(&out.stderr).to_string(),
    )
}

#[test]
fn psql_mode_all_translation_paths() {
    let path = std::env::temp_dir().join(format!("afpsql_cov_{}.sql", std::process::id()));
    std::fs::write(&path, "select $1::int as n").expect("write temp sql");

    let mut cmd = Command::new(bin());
    cmd.arg("--mode")
        .arg("psql")
        .arg("--dsn-secret")
        .arg(test_dsn())
        .arg("-f")
        .arg(path.to_string_lossy().to_string())
        .arg("-v")
        .arg("1=9")
        .arg("--output")
        .arg("json");
    let (code, stdout, _stderr) = run(cmd);
    assert_eq!(code, 0);
    let v: Value = serde_json::from_str(&stdout).expect("json output");
    assert_eq!(v["rows"][0]["n"], 9);

    let mut bad = Command::new(bin());
    bad.arg("--mode").arg("psql").arg("--bad");
    let (code, stdout, _) = run(bad);
    assert_eq!(code, 2);
    assert!(stdout.contains("unsupported psql-mode argument"));

    let _ = std::fs::remove_file(path);
}

#[test]
fn pipe_config_full_patch_and_close() {
    let payload = serde_json::json!({
        "code":"config",
        "default_session":"s1",
        "inline_max_rows":11,
        "inline_max_bytes":22,
        "statement_timeout_ms":33,
        "lock_timeout_ms":44,
        "log":["x"],
        "sessions": {
            "s1": {
                "dsn_secret": test_dsn(),
                "conninfo_secret": "host=localhost user=roger dbname=postgres",
                "host": "localhost",
                "port": 5432,
                "user": "roger",
                "dbname": "postgres",
                "password_secret": "pw"
            }
        }
    })
    .to_string()
        + "\n"
        + &serde_json::json!({"code":"close"}).to_string()
        + "\n";

    let mut child = Command::new(bin())
        .arg("--mode")
        .arg("pipe")
        .stdin(Stdio::piped())
        .stdout(Stdio::piped())
        .stderr(Stdio::piped())
        .spawn()
        .expect("spawn");
    child
        .stdin
        .as_mut()
        .expect("stdin")
        .write_all(payload.as_bytes())
        .expect("write");
    let out = child.wait_with_output().expect("wait");
    assert!(out.status.success());
    let text = String::from_utf8(out.stdout).expect("utf8");
    assert!(text.contains("\"code\":\"config\""));
    assert!(text.contains("\"default_session\":\"s1\""));
}

#[test]
fn conn_via_env_fallback() {
    let mut cmd = Command::new(bin());
    cmd.arg("--sql")
        .arg("select 1 as n")
        .env("AFPSQL_DSN_SECRET", test_dsn());
    let (code, stdout, _stderr) = run(cmd);
    assert_eq!(code, 0);
    let v: Value = serde_json::from_str(&stdout).expect("json output");
    assert_eq!(v["rows"][0]["n"], 1);
}

#[test]
fn has_session_override_each_field_in_pipe_mode() {
    for args in [
        vec!["--dsn-secret", &test_dsn()],
        vec![
            "--conninfo-secret",
            "host=localhost user=roger dbname=postgres",
        ],
        vec!["--host", "localhost"],
        vec!["--port", "5432"],
        vec!["--user", "roger"],
        vec!["--dbname", "postgres"],
        vec!["--password-secret", "pw"],
    ] {
        let payload = serde_json::json!({"code":"close"}).to_string() + "\n";
        let mut cmd = Command::new(bin());
        cmd.arg("--mode").arg("pipe");
        cmd.args(args);
        let mut child = cmd
            .stdin(Stdio::piped())
            .stdout(Stdio::piped())
            .stderr(Stdio::piped())
            .spawn()
            .expect("spawn");
        child
            .stdin
            .as_mut()
            .expect("stdin")
            .write_all(payload.as_bytes())
            .expect("write");
        let out = child.wait_with_output().expect("wait");
        assert!(out.status.success());
    }
}

#[test]
fn cli_emits_structured_stdout_log_events_when_enabled() {
    let mut cmd = Command::new(bin());
    cmd.arg("--dsn-secret")
        .arg(test_dsn())
        .arg("--log")
        .arg("query.result")
        .arg("--sql")
        .arg("select 1 as n");
    let (code, stdout, stderr) = run(cmd);
    assert_eq!(code, 0);
    assert!(stdout.contains("\"code\":\"result\""));
    assert!(stdout.contains("\"code\":\"log\""));
    assert!(stdout.contains("\"event\":\"query.result\""));
    assert!(stdout.contains("\"duration_ms\""));
    assert!(stderr.trim().is_empty());
}

#[test]
fn handler_param_types_and_empty_rows() {
    let mut cmd = Command::new(bin());
    cmd.arg("--dsn-secret")
        .arg(test_dsn())
        .arg("--sql")
        .arg("select $1::text as a, $2::boolean as b, $3::double precision as c, $4::jsonb as d, $5::jsonb as e")
        .arg("--param")
        .arg("1=NaN")
        .arg("--param")
        .arg("2=true")
        .arg("--param")
        .arg("3=1.25")
        .arg("--param")
        .arg("4=[1,2]")
        .arg("--param")
        .arg("5={\"x\":1}");
    let (code, stdout, _stderr) = run(cmd);
    assert_eq!(code, 0);
    let v: Value = serde_json::from_str(&stdout).expect("json output");
    assert_eq!(v["code"], "result");

    let mut empty = Command::new(bin());
    empty
        .arg("--dsn-secret")
        .arg(test_dsn())
        .arg("--sql")
        .arg("select 1 as n where false");
    let (code, stdout, _stderr) = run(empty);
    assert_eq!(code, 0);
    let v: Value = serde_json::from_str(&stdout).expect("json output");
    assert_eq!(v["columns"].as_array().map(|a| a.len()).unwrap_or(0), 1);
    assert_eq!(v["columns"][0]["name"], "n");
}