use faucet_core::FaucetError;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use serde_json::Value;
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct ReconcileSpec {
pub count: CountProbe,
#[serde(default)]
pub tolerance_pct: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct CountProbe {
#[serde(rename = "type")]
pub kind: String,
#[serde(default)]
pub config: Value,
#[serde(default)]
pub count_field: Option<String>,
}
impl ReconcileSpec {
pub fn validate(&self) -> Result<(), FaucetError> {
if self.count.kind.trim().is_empty() {
return Err(FaucetError::Config(
"reconcile: `count.type` must name a connector".into(),
));
}
if !self.tolerance_pct.is_finite() || !(0.0..100.0).contains(&self.tolerance_pct) {
return Err(FaucetError::Config(format!(
"reconcile: tolerance_pct must be in [0, 100), got {}",
self.tolerance_pct
)));
}
Ok(())
}
}
pub fn extract_count(records: &[Value], field: Option<&str>) -> Result<u64, String> {
let first = records
.first()
.ok_or_else(|| "reconcile: the count probe returned no rows".to_string())?;
let as_u64 = |v: &Value| -> Option<u64> {
match v {
Value::Number(n) => n.as_u64().or_else(|| n.as_f64().map(|f| f.max(0.0) as u64)),
Value::String(s) => s.trim().parse::<u64>().ok(),
_ => None,
}
};
match field {
Some(f) => {
let v = first.get(f).ok_or_else(|| {
format!("reconcile: count_field '{f}' not found in the probe row")
})?;
as_u64(v).ok_or_else(|| format!("reconcile: count_field '{f}' is not a number: {v}"))
}
None => first
.as_object()
.and_then(|m| m.values().find_map(as_u64))
.or_else(|| as_u64(first))
.ok_or_else(|| {
"reconcile: the count probe's first row has no numeric field".to_string()
}),
}
}
pub fn evaluate(written: u64, authoritative: u64, tolerance_pct: f64) -> Result<(), String> {
let threshold = (authoritative as f64) * (1.0 - tolerance_pct / 100.0);
if (written as f64) + f64::EPSILON >= threshold {
return Ok(());
}
let shortfall = authoritative.saturating_sub(written);
Err(format!(
"completeness reconciliation failed: wrote {written} rows but the authoritative count is \
{authoritative} (short by {shortfall}; tolerance {tolerance_pct}%). Refusing to report a \
truncated run as successful."
))
}
pub async fn run(
spec: &ReconcileSpec,
auth: &crate::auth_catalog::AuthCatalog,
written: u64,
) -> Result<(), FaucetError> {
spec.validate()?;
let source =
crate::registry::build_source(&spec.count.kind, spec.count.config.clone(), auth, None)
.await
.map_err(|e| {
FaucetError::Source(format!("reconcile: building the count probe: {e}"))
})?;
let records = source.fetch_all().await?;
let authoritative =
extract_count(&records, spec.count.count_field.as_deref()).map_err(FaucetError::Source)?;
evaluate(written, authoritative, spec.tolerance_pct).map_err(FaucetError::Source)
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn extract_named_field() {
let recs = vec![json!({"n": 42, "other": "x"})];
assert_eq!(extract_count(&recs, Some("n")).unwrap(), 42);
}
#[test]
fn extract_first_numeric_when_no_field() {
let recs = vec![json!({"label": "orders", "count": 7})];
assert_eq!(extract_count(&recs, None).unwrap(), 7);
}
#[test]
fn extract_string_number() {
let recs = vec![json!({"n": "100"})];
assert_eq!(extract_count(&recs, Some("n")).unwrap(), 100);
}
#[test]
fn extract_errors_on_empty_or_missing() {
assert!(extract_count(&[], None).is_err());
assert!(extract_count(&[json!({"a": "x"})], None).is_err());
assert!(extract_count(&[json!({"a": 1})], Some("b")).is_err());
}
#[test]
fn evaluate_passes_when_complete() {
assert!(evaluate(100, 100, 0.0).is_ok());
assert!(evaluate(101, 100, 0.0).is_ok());
}
#[test]
fn evaluate_fails_on_shortfall() {
let err = evaluate(90, 100, 0.0).unwrap_err();
assert!(err.contains("short by 10"), "{err}");
}
#[test]
fn evaluate_honors_tolerance() {
assert!(evaluate(99, 100, 1.0).is_ok());
assert!(evaluate(98, 100, 1.0).is_err());
}
#[cfg(feature = "source-csv")]
#[tokio::test]
async fn run_reconciles_against_a_count_probe() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("count.csv");
std::fs::write(&path, "n\n5\n").unwrap();
let spec = ReconcileSpec {
count: CountProbe {
kind: "csv".into(),
config: json!({ "path": path.to_str().unwrap() }),
count_field: Some("n".into()),
},
tolerance_pct: 0.0,
};
let auth = crate::auth_catalog::AuthCatalog::new();
assert!(run(&spec, &auth, 5).await.is_ok());
let err = run(&spec, &auth, 3).await.unwrap_err();
assert!(err.to_string().contains("reconciliation failed"), "{err}");
}
#[test]
fn validate_rejects_bad_spec() {
let bad_tol = ReconcileSpec {
count: CountProbe {
kind: "postgres".into(),
config: json!({}),
count_field: None,
},
tolerance_pct: 150.0,
};
assert!(bad_tol.validate().is_err());
let empty_kind = ReconcileSpec {
count: CountProbe {
kind: "".into(),
config: json!({}),
count_field: None,
},
tolerance_pct: 0.0,
};
assert!(empty_kind.validate().is_err());
}
}