pushkin 0.1.0

Schema-first enforcement harness that gates AI coding agents' file writes against project contracts
//! Warm-path plumbing (spec ยง4.3): `PUSHKIN_DAEMON` mode resolution, the
//! shim-side warm-or-cold check, and the `daemon serve` entrypoint. Mode is
//! env-resolved until the daemon's persisted config lands (the Phase-3
//! nudge-mode pattern): unset probes the socket and never spawns; `auto`
//! auto-starts the daemon on first connect; `off` never touches the socket.
//! Every failure on the warm path degrades to the cold in-process pipeline โ€”
//! the daemon being down, stale, or confused can slow a check, never skip it.

use anyhow::Result;
use pushkin_core::envelope::CheckResult;
use pushkin_core::manifest::Manifest;
use pushkin_core::pipeline::{check_write, WriteRequest};
use pushkin_daemon::protocol::{Request, Response, PROTOCOL_VERSION};
use pushkin_daemon::server::{self, ServerError};
use std::path::{Path, PathBuf};

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Mode {
    Off,
    Probe,
    Auto,
}

fn mode() -> Mode {
    match std::env::var("PUSHKIN_DAEMON").as_deref() {
        Ok("off") => Mode::Off,
        Ok("auto") => Mode::Auto,
        _ => Mode::Probe,
    }
}

/// The shim's one entry point: warm verdict when a daemon answers, cold
/// pipeline otherwise. Callers cannot tell which path ran โ€” that parity is
/// pinned by the conformance tests.
pub fn check_or_cold(manifest: &Manifest, request: &WriteRequest) -> CheckResult {
    let result = warm_check(request).unwrap_or_else(|| check_write(manifest, request));
    // Applied here, after either path, so warm/cold parity holds for the
    // read-only gate exactly as it does for the pipeline rules.
    super::gate_read_only(manifest, result, &request.file_path)
}

fn warm_check(request: &WriteRequest) -> Option<CheckResult> {
    let root = Path::new(".");
    match mode() {
        Mode::Off => None,
        Mode::Probe => warm_request(root, request).ok(),
        Mode::Auto => match warm_request(root, request) {
            Ok(result) => Some(result),
            Err(ServerError::NotRunning) => {
                autostart(root).ok()?;
                warm_request(root, request).ok()
            }
            Err(_) => None,
        },
    }
}

fn warm_request(root: &Path, request: &WriteRequest) -> Result<CheckResult, ServerError> {
    let response = server::request(
        root,
        &Request::Check {
            v: PROTOCOL_VERSION,
            file_path: request.file_path.clone(),
            content: request.content.clone(),
        },
    )?;
    match response {
        Response::Check { result } => Ok(result),
        other => Err(ServerError::Protocol(format!(
            "unexpected response to a check: {other:?}"
        ))),
    }
}

/// Spawn `pushkin daemon serve` detached and wait for its socket. The
/// child intentionally outlives this shim process (fire-and-forget by
/// design: the daemon IS the long-lived side); stdio is nulled so it can
/// never corrupt a hook's stdout verdict channel.
fn autostart(root: &Path) -> std::io::Result<()> {
    let exe = std::env::current_exe()?;
    std::process::Command::new(exe)
        .args(["daemon", "serve"])
        .stdin(std::process::Stdio::null())
        .stdout(std::process::Stdio::null())
        .stderr(std::process::Stdio::null())
        .spawn()?;

    let socket = server::socket_path(root);
    let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2);
    while std::time::Instant::now() < deadline {
        if socket.exists() {
            return Ok(());
        }
        std::thread::sleep(std::time::Duration::from_millis(10));
    }
    Err(std::io::Error::new(
        std::io::ErrorKind::TimedOut,
        "daemon socket did not appear within 2s",
    ))
}

/// `pushkin daemon serve`: run the daemon in the foreground until a
/// protocol Shutdown arrives. Shims spawn this detached in `auto` mode.
/// Startup repeats the doctor sweep (spec ยง6: config drift is a
/// continuously repaired condition) โ€” findings go to stderr where a
/// foreground operator or log collector sees them; a drifted hook must
/// never stop the daemon from serving.
pub fn run_serve() -> Result<i32> {
    let manifest = super::load_manifest()?;
    startup_sweep();
    startup_regen();
    server::serve(Path::new("."), manifest)?;
    Ok(0)
}

/// Doctor's health line for the canonical daemon (INFO, never a finding:
/// the cold path is a fully correct gate, so a stopped daemon must not
/// change doctor's exit code).
#[must_use]
pub fn health_line() -> String {
    match ping(Path::new(".")) {
        Some(info) => format!("daemon: running (pid {})", info.pid),
        None => "daemon: not running (warm path off; cold checks remain in force)".to_owned(),
    }
}

fn startup_sweep() {
    for finding in super::doctor::sweep_findings() {
        eprintln!("pushkin daemon: doctor: {finding}");
    }
}

/// The ยง5.2 eager pass: probe generated/ headers against the manifest's
/// `schema_epoch` (R9: the manifest is the sole epoch source), regenerate
/// stale artifacts sequentially (60s per item โ€” a stuck toolchain item is
/// reported and skipped, never a wedged queue). Absence of generated/ is
/// not an error: pre-compile repos serve fine.
fn startup_regen() {
    let generated = Path::new("generated");
    if !generated.is_dir() {
        return;
    }
    // The queue worker needs 'static anyway: load the owned manifest up
    // front โ€” it also carries the epoch the probe compares against.
    let Ok(owned_manifest) = super::load_manifest() else {
        eprintln!("pushkin daemon: epoch probe skipped: manifest failed to load");
        return;
    };
    let stale = match pushkin_daemon::regen::probe_stale(generated, owned_manifest.schema_epoch) {
        Ok(stale) => stale,
        Err(error) => {
            eprintln!("pushkin daemon: epoch probe failed: {error}");
            return;
        }
    };
    if stale.is_empty() {
        return;
    }
    for path in &stale {
        eprintln!(
            "pushkin daemon: stale epoch: generated/{} queued for regeneration",
            path.display()
        );
    }
    let outcomes =
        pushkin_daemon::regen::run_queue(&stale, std::time::Duration::from_mins(1), move |item| {
            let name = item.to_string_lossy();
            super::compile::regenerate_one(&owned_manifest, &name)
        });
    for (path, outcome) in outcomes {
        use pushkin_daemon::regen::RegenOutcome;
        match outcome {
            RegenOutcome::Regenerated => {
                eprintln!("pushkin daemon: regenerated generated/{}", path.display());
            }
            RegenOutcome::TimedOut => {
                eprintln!(
                    "pushkin daemon: regeneration TIMED OUT for generated/{} (queue continued)",
                    path.display()
                );
            }
            RegenOutcome::Failed(reason) => {
                eprintln!(
                    "pushkin daemon: regeneration FAILED for generated/{}: {reason}",
                    path.display()
                );
            }
        }
    }
}

// ---------- lifecycle verbs + canonical-binary guard (spec ยง8.4) ----------

/// Where the canonical binary path is pinned (trust-on-first-use, the
/// consent pattern): the first `daemon start` writes it; every later
/// lifecycle verb must match it.
const CANONICAL_FILE: &str = ".pushkin/daemon.canonical";

fn current_exe_canonical() -> Result<String> {
    let exe = std::env::current_exe()?.canonicalize()?;
    Ok(exe.to_string_lossy().into_owned())
}

/// The ยง8.4 guard. `Ok(pinned_path)` when this binary may run lifecycle
/// verbs; `Err` carries the refusal message (which must offer the
/// read-only alternative โ€” that offer is test-pinned).
fn guard() -> Result<String> {
    let me = current_exe_canonical()?;
    let pin_path = Path::new(CANONICAL_FILE);
    if !pin_path.exists() {
        if let Some(parent) = pin_path.parent() {
            std::fs::create_dir_all(parent)?;
        }
        std::fs::write(pin_path, format!("{me}\n"))?;
        return Ok(me);
    }
    let pinned = std::fs::read_to_string(pin_path)?.trim().to_owned();
    if pinned == me {
        return Ok(me);
    }
    anyhow::bail!(
        "pushkin daemon: this binary ({me}) is not the canonical one pinned at first start \
         ({pinned}). Lifecycle verbs are refused for non-canonical copies (spec ยง8.4). \
         Use `pushkin daemon start --read-only` for a read-only daemon on a private socket, \
         or have a human update {CANONICAL_FILE}."
    )
}

/// `daemon start [--read-only]`. Canonical: spawn the detached server on
/// the shared socket. Read-only: open to ANY binary, private socket,
/// prints `socket: <path>` for the caller to target.
pub fn run_start(read_only: bool) -> Result<i32> {
    if read_only {
        return start_read_only();
    }
    if let Err(refusal) = guard() {
        eprintln!("{refusal}");
        return Ok(1);
    }
    let root = Path::new(".");
    if ping(root).is_some() {
        println!("pushkin daemon already running");
        return Ok(0);
    }
    match autostart(root) {
        Ok(()) => {
            println!("pushkin daemon started");
            Ok(0)
        }
        Err(error) => {
            eprintln!("pushkin daemon failed to start: {error}");
            Ok(1)
        }
    }
}

fn start_read_only() -> Result<i32> {
    let socket = PathBuf::from(format!(".pushkin/daemon-ro-{}.sock", std::process::id()));
    let exe = std::env::current_exe()?;
    std::process::Command::new(exe)
        .args(["daemon", "serve", "--read-only", "--socket"])
        .arg(&socket)
        .stdin(std::process::Stdio::null())
        .stdout(std::process::Stdio::null())
        .stderr(std::process::Stdio::null())
        .spawn()?;
    let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2);
    while std::time::Instant::now() < deadline {
        if socket.exists() {
            // Absolute path: callers run from other cwds target it directly.
            let absolute = socket.canonicalize()?;
            println!("socket: {}", absolute.display());
            println!("read-only daemon started (kill its pid to stop it)");
            return Ok(0);
        }
        std::thread::sleep(std::time::Duration::from_millis(10));
    }
    eprintln!("read-only daemon socket did not appear within 2s");
    Ok(1)
}

/// `daemon stop`: guarded; a clean protocol shutdown.
#[must_use]
pub fn run_stop() -> i32 {
    if let Err(refusal) = guard() {
        eprintln!("{refusal}");
        return 1;
    }
    let root = Path::new(".");
    match server::request(
        root,
        &Request::Shutdown {
            v: PROTOCOL_VERSION,
        },
    ) {
        Ok(Response::ShuttingDown) => {
            // Shutdown is async on the daemon side; wait for the socket.
            let socket = server::socket_path(root);
            let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2);
            while std::time::Instant::now() < deadline {
                if !socket.exists() {
                    break;
                }
                std::thread::sleep(std::time::Duration::from_millis(10));
            }
            println!("pushkin daemon stopped");
            0
        }
        Err(ServerError::NotRunning) => {
            println!("pushkin daemon not running");
            0
        }
        other => {
            eprintln!("pushkin daemon stop failed: {other:?}");
            1
        }
    }
}

/// `daemon restart`: guarded; stop-if-running then start. A stale socket
/// file (unclean exit) is handled by `serve()`'s bind-time recovery.
#[must_use]
pub fn run_restart() -> i32 {
    if let Err(refusal) = guard() {
        eprintln!("{refusal}");
        return 1;
    }
    let root = Path::new(".");
    if ping(root).is_some() {
        let code = run_stop();
        if code != 0 {
            return code;
        }
    } else {
        // No live daemon; clear any stale socket so start binds cleanly.
        let _ = std::fs::remove_file(server::socket_path(root));
    }
    match autostart(root) {
        Ok(()) => {
            println!("pushkin daemon restarted");
            0
        }
        Err(error) => {
            eprintln!("pushkin daemon failed to restart: {error}");
            1
        }
    }
}

/// `daemon status`: a question, not a mutation โ€” no guard. Exit 0 when a
/// daemon answers, 1 otherwise (doctor-style unhealthy).
#[must_use]
pub fn run_status() -> i32 {
    let root = Path::new(".");
    if let Some(info) = ping(root) {
        println!(
            "pushkin daemon running\n  pid: {}\n  version: {}\n  socket: {}\n  read-only: {}",
            info.pid,
            info.version,
            server::socket_path(root).display(),
            info.read_only,
        );
        0
    } else {
        println!("pushkin daemon not running");
        1
    }
}

fn ping(root: &Path) -> Option<pushkin_daemon::protocol::DaemonInfo> {
    match server::request(
        root,
        &Request::Ping {
            v: PROTOCOL_VERSION,
        },
    ) {
        Ok(Response::Pong { info }) => Some(info),
        _ => None,
    }
}

/// `pushkin daemon serve --read-only --socket <path>`: foreground server
/// on an explicit private socket.
pub fn run_serve_at(socket: &Path, read_only: bool) -> Result<i32> {
    let manifest = super::load_manifest()?;
    server::serve_at(socket, manifest, read_only)?;
    Ok(0)
}