pushkin 0.2.1

Schema-first enforcement harness that gates AI coding agents' file writes against project contracts
//! Phase 4 task 4 (wiring half): the daemon probes schema epochs at
//! startup and queues regeneration (spec §5.2 "the daemon probes headers
//! cheaply at startup and queues sequential background regeneration").
//! Committed first, read-only hereafter (charter §4.1, N10). Pins: a
//! stale generated artifact is named on stderr at serve startup and
//! regenerated to the current epoch; an up-to-date tree stays silent and
//! untouched; a missing generated/ dir is not an error (pre-compile
//! repos serve fine).

use assert_cmd::Command;
use std::fs;
use std::io::Read;
use std::path::Path;
use std::time::{Duration, Instant};

const MANIFEST: &str = r#"
version = 1
canonical = "json-schema-2020-12"
authoring = "zod"

[[contracts]]
name = "user"
source = "contracts/user.zod.ts"
emit = ["zod"]

[[mappings]]
glob = "app/api/**/*.ts"
contracts = ["user"]
require = "boundary-validation"

[gates]
protected_paths = ["pushkin.toml"]
"#;

const USER_SCHEMA: &str = r#"{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "type": "object",
  "properties": {
    "name": { "type": "string", "minLength": 1 },
    "email": { "type": "string", "format": "email" }
  },
  "required": ["name", "email"],
  "additionalProperties": false
}"#;

fn repo() -> std::io::Result<tempfile::TempDir> {
    let dir = tempfile::tempdir()?;
    fs::write(dir.path().join("pushkin.toml"), MANIFEST)?;
    fs::create_dir_all(dir.path().join("schemas"))?;
    fs::write(dir.path().join("schemas/user.schema.json"), USER_SCHEMA)?;
    Ok(dir)
}

/// Run `daemon serve` to completion: spawn, await socket, stop, join;
/// returns captured stderr. Failures surface as `Err` for the `#[test]`
/// fn to unwrap (no expects in free helpers — the §7 precedent).
fn serve_once(dir: &Path) -> Result<String, String> {
    let binary = assert_cmd::cargo::cargo_bin("pushkin");
    let mut child = std::process::Command::new(binary)
        .current_dir(dir)
        .args(["daemon", "serve"])
        .stdout(std::process::Stdio::null())
        .stderr(std::process::Stdio::piped())
        .spawn()
        .map_err(|e| format!("serve spawns: {e}"))?;

    let socket = dir.join(".pushkin/daemon.sock");
    let deadline = Instant::now() + Duration::from_secs(10);
    while Instant::now() < deadline && !socket.exists() {
        std::thread::sleep(Duration::from_millis(20));
    }
    if !socket.exists() {
        return Err("daemon never came up".to_owned());
    }
    let stop = Command::cargo_bin("pushkin")
        .map_err(|e| format!("binary resolves: {e}"))?
        .current_dir(dir)
        .args(["daemon", "stop"])
        .output()
        .map_err(|e| format!("stop runs: {e}"))?;
    if stop.status.code() != Some(0) {
        return Err(format!("stop exited {:?}", stop.status.code()));
    }
    let status = child.wait().map_err(|e| format!("serve joins: {e}"))?;
    if !status.success() {
        return Err(format!("serve exited {status:?}"));
    }

    let mut stderr = String::new();
    child
        .stderr
        .take()
        .ok_or("stderr piped")?
        .read_to_string(&mut stderr)
        .map_err(|e| format!("stderr reads: {e}"))?;
    Ok(stderr)
}

#[test]
fn serve_startup_regenerates_stale_epoch_artifacts() {
    let dir = repo().unwrap();
    fs::create_dir_all(dir.path().join("generated")).unwrap();
    fs::write(
        dir.path().join("generated/user.zod.gen.ts"),
        "// GENERATED by pushkin compile from contract 'user' — do not hand-edit.\n\
         // pushkin-epoch: 0\nexport const stale = true;\n",
    )
    .unwrap();

    let stderr = serve_once(dir.path()).unwrap();
    assert!(
        stderr.contains("stale epoch"),
        "stale artifact must be named at startup: {stderr}"
    );
    assert!(
        stderr.contains("user.zod.gen.ts"),
        "the named artifact is the stale one: {stderr}"
    );

    let regenerated = fs::read_to_string(dir.path().join("generated/user.zod.gen.ts")).unwrap();
    assert!(
        regenerated.contains("pushkin-epoch: 1"),
        "artifact must carry the current epoch after the startup queue: {regenerated}"
    );
    assert!(
        !regenerated.contains("export const stale"),
        "stale content must be regenerated, not just re-stamped"
    );
}

#[test]
fn serve_startup_silent_on_current_epochs() {
    let dir = repo().unwrap();
    // Generate current-epoch artifacts the honest way: compile.
    let compile = Command::cargo_bin("pushkin")
        .unwrap()
        .current_dir(dir.path())
        .arg("compile")
        .output()
        .unwrap();
    assert_eq!(compile.status.code(), Some(0));
    let before = fs::read_to_string(dir.path().join("generated/user.zod.gen.ts")).unwrap();

    let stderr = serve_once(dir.path()).unwrap();
    assert!(
        !stderr.contains("stale epoch"),
        "an up-to-date tree stays silent: {stderr}"
    );
    let after = fs::read_to_string(dir.path().join("generated/user.zod.gen.ts")).unwrap();
    assert_eq!(before, after, "an up-to-date artifact is not rewritten");
}

#[test]
fn serve_startup_tolerates_missing_generated_dir() {
    let dir = repo().unwrap();
    let stderr = serve_once(dir.path()).unwrap();
    assert!(
        !stderr.contains("stale epoch"),
        "no generated/ dir means nothing to probe: {stderr}"
    );
}