pushkin-daemon 0.1.1

Warm-path daemon for the pushkin write-gate
Documentation
//! Phase 4 task 4: eager epoch probe + sequential regeneration (spec
//! §5.2). Committed first, read-only hereafter (charter §4.1, N10).
//! Pins: files whose `pushkin-epoch:` header lags the expected epoch
//! (or is missing entirely) are queued; up-to-date files are not;
//! regeneration runs items SEQUENTIALLY (lazy checks leave artifacts
//! silently stale, parallel regen is unbounded resource use — the spec
//! chose eager+sequential deliberately); a stuck item times out without
//! blocking the rest of the queue; outcomes are reported per item, never
//! silently dropped.

use pushkin_daemon::regen::{probe_stale, run_queue, RegenOutcome};
use std::fs;
use std::path::PathBuf;
use std::sync::atomic::{AtomicU32, Ordering};
use std::sync::Arc;
use std::time::Duration;

fn generated_dir() -> std::io::Result<tempfile::TempDir> {
    let dir = tempfile::tempdir()?;
    fs::write(
        dir.path().join("user.zod.gen.ts"),
        "// GENERATED by pushkin compile from contract 'user' — do not hand-edit.\n\
         // pushkin-epoch: 1\nexport const x = 1;\n",
    )?;
    fs::write(
        dir.path().join("user.gen.rs"),
        "// GENERATED by pushkin compile from contract 'user' — do not hand-edit.\n\
         // pushkin-epoch: 2\npub struct X;\n",
    )?;
    fs::write(dir.path().join("orphan.gen.sql"), "-- no header at all\n")?;
    Ok(dir)
}

#[test]
fn stale_epoch_header_queues_regeneration() {
    let dir = generated_dir().unwrap();
    let mut stale = probe_stale(dir.path(), 2).unwrap();
    stale.sort();
    assert_eq!(
        stale,
        vec![
            PathBuf::from("orphan.gen.sql"),
            PathBuf::from("user.zod.gen.ts"),
        ],
        "epoch-1 and headerless files are stale against epoch 2; \
         the epoch-2 file is not"
    );
}

#[test]
fn up_to_date_tree_queues_nothing() {
    let dir = generated_dir().unwrap();
    fs::write(
        dir.path().join("user.zod.gen.ts"),
        "// pushkin-epoch: 2\nexport const x = 1;\n",
    )
    .unwrap();
    fs::write(dir.path().join("orphan.gen.sql"), "-- pushkin-epoch: 2\n").unwrap();
    let stale = probe_stale(dir.path(), 2).unwrap();
    assert!(stale.is_empty(), "nothing to do must mean an empty queue");
}

#[test]
fn regeneration_is_sequential_not_parallel() {
    let in_flight = Arc::new(AtomicU32::new(0));
    let peak = Arc::new(AtomicU32::new(0));
    let items: Vec<PathBuf> = (0..4).map(|i| PathBuf::from(format!("item-{i}"))).collect();

    let worker_in_flight = Arc::clone(&in_flight);
    let worker_peak = Arc::clone(&peak);
    let outcomes = run_queue(&items, Duration::from_secs(5), move |_item| {
        let now = worker_in_flight.fetch_add(1, Ordering::SeqCst) + 1;
        worker_peak.fetch_max(now, Ordering::SeqCst);
        std::thread::sleep(Duration::from_millis(30));
        worker_in_flight.fetch_sub(1, Ordering::SeqCst);
        Ok(())
    });

    assert_eq!(outcomes.len(), 4);
    assert!(
        outcomes
            .iter()
            .all(|(_, outcome)| matches!(outcome, RegenOutcome::Regenerated)),
        "all items should succeed: {outcomes:?}"
    );
    assert_eq!(
        peak.load(Ordering::SeqCst),
        1,
        "never more than one regeneration in flight (sequential by design)"
    );
}

#[test]
fn stuck_regeneration_item_times_out_without_blocking_queue() {
    let items = vec![
        PathBuf::from("fast-1"),
        PathBuf::from("stuck"),
        PathBuf::from("fast-2"),
    ];
    let started = std::time::Instant::now();
    let outcomes = run_queue(&items, Duration::from_millis(150), |item| {
        if item.to_string_lossy() == "stuck" {
            // Simulates a wedged toolchain: sleeps far past the per-item cap.
            std::thread::sleep(Duration::from_secs(10));
        }
        Ok(())
    });
    let elapsed = started.elapsed();

    assert_eq!(outcomes.len(), 3, "every item gets an outcome");
    assert!(
        matches!(outcomes[0].1, RegenOutcome::Regenerated),
        "fast-1 succeeds: {outcomes:?}"
    );
    assert!(
        matches!(outcomes[1].1, RegenOutcome::TimedOut),
        "the stuck item is reported timed-out, not hung: {outcomes:?}"
    );
    assert!(
        matches!(outcomes[2].1, RegenOutcome::Regenerated),
        "the item BEHIND the stuck one still runs: {outcomes:?}"
    );
    assert!(
        elapsed < Duration::from_secs(5),
        "the queue must not wait out the stuck item's full sleep ({elapsed:?})"
    );
}

#[test]
fn failed_item_is_reported_not_dropped() {
    let items = vec![PathBuf::from("bad")];
    let outcomes = run_queue(&items, Duration::from_secs(5), |_item| {
        Err("compiler said no".to_owned())
    });
    match &outcomes[0].1 {
        RegenOutcome::Failed(message) => assert!(message.contains("compiler said no")),
        other => panic!("expected Failed, got {other:?}"),
    }
}