use std::collections::BTreeSet;
use std::path::Path;
use std::sync::{Mutex, OnceLock};
fn is_buildable_source(path: &str) -> bool {
Path::new(path)
.extension()
.and_then(|e| e.to_str())
.is_some_and(|e| e == "rs")
|| path.ends_with("Cargo.toml")
}
#[derive(Debug, Default)]
pub struct Verification {
pending: Mutex<BTreeSet<String>>,
}
impl Verification {
pub fn record(&self, tool: &str, input: &serde_json::Value, ok: bool) {
let Ok(mut pending) = self.pending.lock() else {
return;
};
match tool {
"write_file" | "edit_file" => {
if let Some(path) = input.get("path").and_then(|v| v.as_str()) {
if is_buildable_source(path) {
pending.insert(path.to_string());
}
}
}
"caatinga_build" | "run_tests" if ok => pending.clear(),
_ => {}
}
}
pub fn pending(&self) -> Vec<String> {
self.pending
.lock()
.map(|set| set.iter().cloned().collect())
.unwrap_or_default()
}
pub fn guard_deploy(&self) -> Result<(), String> {
let pending = self.pending();
if pending.is_empty() {
return Ok(());
}
Err(format!(
"Refusing to deploy: {} changed since the last successful build, and a deploy ships \
the wasm recorded by that build — this would put the previous version of the contract \
on chain and report success. Run `caatinga_build` first. Nothing was submitted.",
pending.join(", ")
))
}
}
pub fn session() -> &'static Verification {
static SESSION: OnceLock<Verification> = OnceLock::new();
SESSION.get_or_init(Verification::default)
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn a_source_write_leaves_the_build_unproven() {
let verification = Verification::default();
verification.record(
"write_file",
&json!({"path": "contracts/counter/src/lib.rs"}),
true,
);
assert_eq!(verification.pending(), vec!["contracts/counter/src/lib.rs"]);
assert!(verification.guard_deploy().is_err());
}
#[test]
fn a_successful_build_is_the_evidence_that_clears_it() {
let verification = Verification::default();
verification.record("edit_file", &json!({"path": "src/lib.rs"}), true);
verification.record("caatinga_build", &json!({}), true);
assert!(verification.pending().is_empty());
assert!(verification.guard_deploy().is_ok());
}
#[test]
fn a_failed_build_proves_nothing() {
let verification = Verification::default();
verification.record("write_file", &json!({"path": "src/lib.rs"}), true);
verification.record("caatinga_build", &json!({}), false);
assert!(verification.guard_deploy().is_err());
}
#[test]
fn passing_tests_count_as_evidence_too() {
let verification = Verification::default();
verification.record("write_file", &json!({"path": "src/lib.rs"}), true);
verification.record("run_tests", &json!({}), true);
assert!(verification.guard_deploy().is_ok());
}
#[test]
fn a_write_that_reported_failure_is_still_treated_as_a_change() {
let verification = Verification::default();
verification.record("edit_file", &json!({"path": "src/lib.rs"}), false);
assert!(verification.guard_deploy().is_err());
}
#[test]
fn changes_that_cannot_affect_the_build_are_not_tracked() {
let verification = Verification::default();
for path in ["README.md", "packages/client/index.ts", ".env"] {
verification.record("write_file", &json!({"path": path}), true);
}
assert!(
verification.pending().is_empty(),
"{:?}",
verification.pending()
);
}
#[test]
fn a_manifest_change_invalidates_the_build_as_much_as_a_source_change() {
let verification = Verification::default();
verification.record(
"write_file",
&json!({"path": "contracts/counter/Cargo.toml"}),
true,
);
assert!(verification.guard_deploy().is_err());
}
#[test]
fn a_fresh_session_has_nothing_to_prove() {
assert!(Verification::default().guard_deploy().is_ok());
}
#[test]
fn the_refusal_names_the_file_and_the_command_that_settles_it() {
let verification = Verification::default();
verification.record("write_file", &json!({"path": "src/lib.rs"}), true);
let err = verification.guard_deploy().unwrap_err();
assert!(err.contains("src/lib.rs"), "{}", err);
assert!(err.contains("caatinga_build"), "{}", err);
assert!(err.contains("Nothing was submitted"), "{}", err);
}
}