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" {
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:?}"),
}
}