1use faucet_core::FaucetError;
17use schemars::JsonSchema;
18use serde::{Deserialize, Serialize};
19use serde_json::Value;
20
21#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
23#[serde(deny_unknown_fields)]
24pub struct ReconcileSpec {
25 pub count: CountProbe,
28 #[serde(default)]
32 pub tolerance_pct: f64,
33}
34
35#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
37#[serde(deny_unknown_fields)]
38pub struct CountProbe {
39 #[serde(rename = "type")]
41 pub kind: String,
42 #[serde(default)]
44 pub config: Value,
45 #[serde(default)]
49 pub count_field: Option<String>,
50}
51
52impl ReconcileSpec {
53 pub fn validate(&self) -> Result<(), FaucetError> {
56 if self.count.kind.trim().is_empty() {
57 return Err(FaucetError::Config(
58 "reconcile: `count.type` must name a connector".into(),
59 ));
60 }
61 if !self.tolerance_pct.is_finite() || !(0.0..100.0).contains(&self.tolerance_pct) {
62 return Err(FaucetError::Config(format!(
63 "reconcile: tolerance_pct must be in [0, 100), got {}",
64 self.tolerance_pct
65 )));
66 }
67 Ok(())
68 }
69}
70
71pub fn extract_count(records: &[Value], field: Option<&str>) -> Result<u64, String> {
75 let first = records
76 .first()
77 .ok_or_else(|| "reconcile: the count probe returned no rows".to_string())?;
78 let as_u64 = |v: &Value| -> Option<u64> {
79 match v {
80 Value::Number(n) => n.as_u64().or_else(|| n.as_f64().map(|f| f.max(0.0) as u64)),
81 Value::String(s) => s.trim().parse::<u64>().ok(),
82 _ => None,
83 }
84 };
85 match field {
86 Some(f) => {
87 let v = first.get(f).ok_or_else(|| {
88 format!("reconcile: count_field '{f}' not found in the probe row")
89 })?;
90 as_u64(v).ok_or_else(|| format!("reconcile: count_field '{f}' is not a number: {v}"))
91 }
92 None => first
93 .as_object()
94 .and_then(|m| m.values().find_map(as_u64))
95 .or_else(|| as_u64(first))
97 .ok_or_else(|| {
98 "reconcile: the count probe's first row has no numeric field".to_string()
99 }),
100 }
101}
102
103pub fn evaluate(written: u64, authoritative: u64, tolerance_pct: f64) -> Result<(), String> {
107 let threshold = (authoritative as f64) * (1.0 - tolerance_pct / 100.0);
108 if (written as f64) + f64::EPSILON >= threshold {
109 return Ok(());
110 }
111 let shortfall = authoritative.saturating_sub(written);
112 Err(format!(
113 "completeness reconciliation failed: wrote {written} rows but the authoritative count is \
114 {authoritative} (short by {shortfall}; tolerance {tolerance_pct}%). Refusing to report a \
115 truncated run as successful."
116 ))
117}
118
119pub async fn run(
124 spec: &ReconcileSpec,
125 auth: &crate::auth_catalog::AuthCatalog,
126 written: u64,
127) -> Result<(), FaucetError> {
128 spec.validate()?;
129 let source =
130 crate::registry::build_source(&spec.count.kind, spec.count.config.clone(), auth, None)
131 .await
132 .map_err(|e| {
133 FaucetError::Source(format!("reconcile: building the count probe: {e}"))
134 })?;
135 let records = source.fetch_all().await?;
136 let authoritative =
137 extract_count(&records, spec.count.count_field.as_deref()).map_err(FaucetError::Source)?;
138 evaluate(written, authoritative, spec.tolerance_pct).map_err(FaucetError::Source)
139}
140
141#[cfg(test)]
142mod tests {
143 use super::*;
144 use serde_json::json;
145
146 #[test]
147 fn extract_named_field() {
148 let recs = vec![json!({"n": 42, "other": "x"})];
149 assert_eq!(extract_count(&recs, Some("n")).unwrap(), 42);
150 }
151
152 #[test]
153 fn extract_first_numeric_when_no_field() {
154 let recs = vec![json!({"label": "orders", "count": 7})];
155 assert_eq!(extract_count(&recs, None).unwrap(), 7);
156 }
157
158 #[test]
159 fn extract_string_number() {
160 let recs = vec![json!({"n": "100"})];
161 assert_eq!(extract_count(&recs, Some("n")).unwrap(), 100);
162 }
163
164 #[test]
165 fn extract_errors_on_empty_or_missing() {
166 assert!(extract_count(&[], None).is_err());
167 assert!(extract_count(&[json!({"a": "x"})], None).is_err());
168 assert!(extract_count(&[json!({"a": 1})], Some("b")).is_err());
169 }
170
171 #[test]
172 fn evaluate_passes_when_complete() {
173 assert!(evaluate(100, 100, 0.0).is_ok());
174 assert!(evaluate(101, 100, 0.0).is_ok());
175 }
176
177 #[test]
178 fn evaluate_fails_on_shortfall() {
179 let err = evaluate(90, 100, 0.0).unwrap_err();
180 assert!(err.contains("short by 10"), "{err}");
181 }
182
183 #[test]
184 fn evaluate_honors_tolerance() {
185 assert!(evaluate(99, 100, 1.0).is_ok());
187 assert!(evaluate(98, 100, 1.0).is_err());
188 }
189
190 #[cfg(feature = "source-csv")]
191 #[tokio::test]
192 async fn run_reconciles_against_a_count_probe() {
193 let dir = tempfile::tempdir().unwrap();
195 let path = dir.path().join("count.csv");
196 std::fs::write(&path, "n\n5\n").unwrap();
197 let spec = ReconcileSpec {
198 count: CountProbe {
199 kind: "csv".into(),
200 config: json!({ "path": path.to_str().unwrap() }),
201 count_field: Some("n".into()),
202 },
203 tolerance_pct: 0.0,
204 };
205 let auth = crate::auth_catalog::AuthCatalog::new();
206 assert!(run(&spec, &auth, 5).await.is_ok());
208 let err = run(&spec, &auth, 3).await.unwrap_err();
210 assert!(err.to_string().contains("reconciliation failed"), "{err}");
211 }
212
213 #[test]
214 fn validate_rejects_bad_spec() {
215 let bad_tol = ReconcileSpec {
216 count: CountProbe {
217 kind: "postgres".into(),
218 config: json!({}),
219 count_field: None,
220 },
221 tolerance_pct: 150.0,
222 };
223 assert!(bad_tol.validate().is_err());
224 let empty_kind = ReconcileSpec {
225 count: CountProbe {
226 kind: "".into(),
227 config: json!({}),
228 count_field: None,
229 },
230 tolerance_pct: 0.0,
231 };
232 assert!(empty_kind.validate().is_err());
233 }
234}