use repolith_cache::{Cache, SqliteCache};
use repolith_core::types::{ActionId, BuildError, BuildEvent, Sha256};
fn success(id: &str, ms: u64) -> BuildEvent {
BuildEvent::Success {
id: ActionId(id.to_string()),
input: Sha256([3; 32]),
output: Sha256([4; 32]),
ms,
}
}
fn failed(id: &str) -> BuildEvent {
BuildEvent::Failed {
id: ActionId(id.to_string()),
input: Sha256([5; 32]),
error: BuildError::Io("disk full".to_string()),
ms: 7,
}
}
async fn run_contract_suite(cache: &mut dyn Cache, ns: &str) {
let id = |s: &str| ActionId(format!("{ns}::{s}"));
assert!(
cache.last_build(&id("absent")).await.is_none(),
"unknown id must return None"
);
cache
.record(success(&id("ok").0, 42))
.await
.expect("record success");
match cache.last_build(&id("ok")).await.expect("event stored") {
BuildEvent::Success {
input, output, ms, ..
} => {
assert_eq!(input, Sha256([3; 32]));
assert_eq!(output, Sha256([4; 32]));
assert_eq!(ms, 42);
}
BuildEvent::Failed { .. } => panic!("expected Success"),
}
cache
.record(failed(&id("ko").0))
.await
.expect("record failed");
match cache.last_build(&id("ko")).await.expect("event stored") {
BuildEvent::Failed { error, .. } => {
assert!(
matches!(&error, BuildError::Io(m) if m == "disk full"),
"typed error must roundtrip, got: {error:?}"
);
}
BuildEvent::Success { .. } => panic!("expected Failed"),
}
cache
.record(success(&id("ok").0, 99))
.await
.expect("overwrite");
match cache.last_build(&id("ok")).await.expect("event stored") {
BuildEvent::Success { ms, .. } => assert_eq!(ms, 99, "latest event wins"),
BuildEvent::Failed { .. } => panic!("expected Success"),
}
cache
.record(failed(&id("ok").0))
.await
.expect("flip to failed");
assert!(
matches!(
cache.last_build(&id("ok")).await,
Some(BuildEvent::Failed { .. })
),
"status flip must be visible"
);
let batch: Vec<BuildEvent> = (0..5)
.map(|i| success(&id(&format!("b{i}")).0, i))
.collect();
cache.record_batch(batch).await.expect("record_batch");
for i in 0..5u64 {
match cache.last_build(&id(&format!("b{i}"))).await {
Some(BuildEvent::Success { ms, .. }) => assert_eq!(ms, i),
other => panic!("batch event b{i} missing or wrong: {other:?}"),
}
}
cache.record_batch(vec![]).await.expect("empty batch ok");
let before = now_ms();
cache
.record(success(&id("dated").0, 5))
.await
.expect("record dated");
let rec = cache
.last_record(&id("dated"))
.await
.expect("record must be readable");
let at = rec
.recorded_at
.expect("a backend that just wrote an event must be able to date it");
assert!(
at.abs_diff(before) < 60_000,
"recorded_at {at} is not a plausible now ({before}); \
a zero, an epoch date or a duration-in-timestamp bug looks exactly like this"
);
let via_build = cache
.last_build(&id("dated"))
.await
.expect("event must be readable");
match (&rec.event, &via_build) {
(
BuildEvent::Success {
ms: a, input: ia, ..
},
BuildEvent::Success {
ms: b, input: ib, ..
},
) => {
assert_eq!(a, b, "duration must not differ between the two read paths");
assert_eq!(*a, 5, "dating an event must not disturb its duration");
assert_eq!(ia, ib, "input hash must not differ between read paths");
}
other => panic!("both read paths must yield the same Success: {other:?}"),
}
}
fn now_ms() -> u64 {
u64::try_from(
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("clock after epoch")
.as_millis(),
)
.expect("epoch millis fit in u64")
}
#[tokio::test]
async fn sqlite_satisfies_the_contract() {
let mut cache = SqliteCache::in_memory().expect("in-memory sqlite");
run_contract_suite(&mut cache, "run").await;
}
#[tokio::test]
async fn sqlite_file_backed_satisfies_the_contract() {
let dir = tempfile::tempdir().expect("tempdir");
let mut cache = SqliteCache::open(dir.path().join("contract.db")).expect("open");
run_contract_suite(&mut cache, "run").await;
}
mod default_impl {
use async_trait::async_trait;
use repolith_cache::Cache;
use repolith_core::cache::Result;
use repolith_core::types::{ActionId, BuildEvent, Sha256};
use std::collections::HashMap;
#[derive(Default)]
struct TimelessCache(HashMap<String, BuildEvent>);
#[async_trait]
impl Cache for TimelessCache {
async fn last_build(&self, id: &ActionId) -> Option<BuildEvent> {
self.0.get(&id.0).cloned()
}
async fn record(&mut self, event: BuildEvent) -> Result<()> {
let id = match &event {
BuildEvent::Success { id, .. } | BuildEvent::Failed { id, .. } => id.0.clone(),
};
self.0.insert(id, event);
Ok(())
}
}
#[tokio::test]
async fn undated_backend_reports_absence_not_zero() {
let mut cache = TimelessCache::default();
let id = ActionId("a::b::0".to_string());
cache
.record(BuildEvent::Success {
id: id.clone(),
input: Sha256([1; 32]),
output: Sha256([2; 32]),
ms: 11,
})
.await
.expect("record");
let rec = cache.last_record(&id).await.expect("event is readable");
assert!(
rec.recorded_at.is_none(),
"a backend that tracks no write time must say None, never Some(0) — \
callers render absence, and Some(0) would print a 1970 date"
);
assert!(
matches!(rec.event, BuildEvent::Success { ms: 11, .. }),
"the default impl must pass the event through untouched"
);
}
}
#[cfg(feature = "neo4j")]
mod neo4j_contract {
use super::run_contract_suite;
use repolith_cache::{Neo4jCache, Neo4jConfig};
#[tokio::test]
#[ignore = "requires a live Neo4j server + REPOLITH_NEO4J_* env vars"]
async fn neo4j_satisfies_the_contract() {
let cfg = Neo4jConfig::from_env().expect("REPOLITH_NEO4J_* env vars");
let mut cache = Neo4jCache::connect(&cfg).await.expect("connect");
let ns = format!(
"contract-{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("clock after epoch")
.as_nanos()
);
run_contract_suite(&mut cache, &ns).await;
}
}