zynk-cli 0.1.0

Command-line interface for generating Zynk TypeScript clients
use std::fs;
use std::path::PathBuf;
use std::process::Command;
use std::time::{SystemTime, UNIX_EPOCH};

fn repo_root() -> PathBuf {
    PathBuf::from(env!("CARGO_MANIFEST_DIR"))
        .parent()
        .and_then(|crates| crates.parent())
        .expect("crate lives under <repo>/crates/zynk-cli")
        .to_path_buf()
}

fn temp_dir(name: &str) -> PathBuf {
    let nanos = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .expect("system time after unix epoch")
        .as_nanos();
    std::env::temp_dir().join(format!("zynk-cli-{name}-{}-{nanos}", std::process::id()))
}

fn zynk_bin() -> &'static str {
    env!("CARGO_BIN_EXE_zynk")
}

fn uv_python() -> PathBuf {
    repo_root().join("bindings/python/.venv/bin/python")
}

#[test]
fn gen_typescript_python_writes_api_and_internal_files() {
    let out = temp_dir("typescript");
    let output = Command::new(zynk_bin())
        .args(["gen", "typescript", "--target", "python", "--out"])
        .arg(&out)
        .args(["--app", "tests.fixtures.roundtrip_schema_fixture:bridge"])
        .arg("--python")
        .arg(uv_python())
        .current_dir(repo_root().join("bindings/python"))
        .output()
        .expect("run zynk gen typescript");

    assert!(
        output.status.success(),
        "stdout:\n{}\nstderr:\n{}",
        String::from_utf8_lossy(&output.stdout),
        String::from_utf8_lossy(&output.stderr)
    );
    assert!(out.join("api.ts").is_file());
    assert!(out.join("_internal.ts").is_file());
    let api = fs::read_to_string(out.join("api.ts")).expect("read api.ts");
    assert!(api.contains("Generated by zynk-gen-ts"));
    assert!(api.contains("export async function updateProfile"));
}

#[test]
fn gen_effect_python_writes_api_and_effect_internal_files() {
    let out = temp_dir("effect");
    let output = Command::new(zynk_bin())
        .args(["gen", "effect", "--target", "python", "--out"])
        .arg(&out)
        .args(["--app", "tests.fixtures.roundtrip_schema_fixture:bridge"])
        .arg("--python")
        .arg(uv_python())
        .current_dir(repo_root().join("bindings/python"))
        .output()
        .expect("run zynk gen effect");

    assert!(
        output.status.success(),
        "stdout:\n{}\nstderr:\n{}",
        String::from_utf8_lossy(&output.stdout),
        String::from_utf8_lossy(&output.stderr)
    );
    assert!(out.join("api.ts").is_file());
    assert!(out.join("_effect_internal.ts").is_file());
    let api = fs::read_to_string(out.join("api.ts")).expect("read api.ts");
    assert!(api.contains("Auto-generated by Zynk Effect connector"));
    assert!(api.contains("export const updateProfile"));
}

#[test]
fn python_import_errors_exit_nonzero_and_surface_stderr() {
    let out = temp_dir("bad-import");
    let output = Command::new(zynk_bin())
        .args(["gen", "typescript", "--target", "python", "--out"])
        .arg(&out)
        .args(["--app", "nonexistent:bad"])
        .arg("--python")
        .arg(uv_python())
        .current_dir(repo_root().join("bindings/python"))
        .output()
        .expect("run zynk gen bad import");

    assert!(!output.status.success());
    let stderr = String::from_utf8_lossy(&output.stderr);
    assert!(
        stderr.contains("ModuleNotFoundError"),
        "stderr was:\n{stderr}"
    );
    assert!(
        stderr.contains("user app schema dump subprocess failed"),
        "stderr was:\n{stderr}"
    );
    assert!(!out.join("api.ts").exists());
}

#[test]
fn garbage_stdout_exits_nonzero_without_writing_api() {
    let fixture_dir = temp_dir("garbage-fixture");
    fs::create_dir_all(&fixture_dir).expect("create fixture dir");
    fs::write(
        fixture_dir.join("garbage_app.py"),
        "class App:\n    def dump_schema_json(self):\n        return '{not json'\nbridge = App()\n",
    )
    .expect("write fixture");
    let out = temp_dir("garbage-out");

    let output = Command::new(zynk_bin())
        .args(["gen", "typescript", "--target", "python", "--out"])
        .arg(&out)
        .args(["--app", "garbage_app:bridge"])
        .current_dir(&fixture_dir)
        .output()
        .expect("run zynk gen garbage stdout");

    assert!(!output.status.success());
    let stderr = String::from_utf8_lossy(&output.stderr);
    assert!(
        stderr.contains("failed to parse schema dump stdout as ApiGraph JSON"),
        "stderr was:\n{stderr}"
    );
    assert!(!out.join("api.ts").exists());
}