1use std::collections::BTreeMap;
33
34use cel::{Context, Program, Value};
35
36use crate::error::{ValidationError, ValidationResult};
37use crate::identity::MAX_EXPR_LEN;
38
39#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
43pub struct VerifyStats {
44 pub files: i64,
46 pub bytes: i64,
48 pub errors: i64,
50}
51
52#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
55pub struct RestoredStats {
56 pub files: i64,
58 pub checksum_matches: bool,
60}
61
62#[derive(Debug, Clone, Default)]
64pub struct SuccessExprInputs<'a> {
65 pub stats: VerifyStats,
67 pub snapshot: BTreeMap<String, String>,
69 pub restored: Option<RestoredStats>,
71 pub tier: String,
73 pub _marker: std::marker::PhantomData<&'a ()>,
75}
76
77fn compile(expr: &str) -> ValidationResult<Program> {
81 if expr.len() > MAX_EXPR_LEN {
82 return Err(ValidationError::SuccessExprCompile {
83 expr: expr.to_string(),
84 reason: format!(
85 "expression is {} bytes; the maximum is {MAX_EXPR_LEN}",
86 expr.len()
87 ),
88 });
89 }
90 Program::compile(expr).map_err(|e| ValidationError::SuccessExprCompile {
91 expr: expr.to_string(),
92 reason: e.to_string(),
93 })
94}
95
96fn context<'a>(inputs: &SuccessExprInputs<'_>) -> Context<'a> {
98 let mut ctx = Context::default();
99 let stats: BTreeMap<String, i64> = BTreeMap::from([
101 ("files".to_string(), inputs.stats.files),
102 ("bytes".to_string(), inputs.stats.bytes),
103 ("errors".to_string(), inputs.stats.errors),
104 ]);
105 let _ = ctx.add_variable("stats", &stats);
106 let _ = ctx.add_variable("snapshot", &inputs.snapshot);
107 let _ = ctx.add_variable("tier", &inputs.tier);
108 match inputs.restored {
111 Some(r) => {
112 let restored = serde_json::json!({
115 "files": r.files,
116 "checksumMatches": r.checksum_matches,
117 });
118 let _ = ctx.add_variable("restored", &restored);
119 }
120 None => {
121 let empty: BTreeMap<String, i64> = BTreeMap::new();
122 let _ = ctx.add_variable("restored", &empty);
123 }
124 }
125 ctx
126}
127
128pub fn eval_success_expr(expr: &str, inputs: &SuccessExprInputs<'_>) -> ValidationResult<bool> {
132 let program = compile(expr)?;
133 let ctx = context(inputs);
134 match program.execute(&ctx) {
135 Ok(Value::Bool(b)) => Ok(b),
136 Ok(other) => Err(ValidationError::SuccessExprType {
137 expr: expr.to_string(),
138 got: other.type_of().to_string(),
139 }),
140 Err(e) => Err(ValidationError::SuccessExprEval {
141 expr: expr.to_string(),
142 reason: e.to_string(),
143 }),
144 }
145}
146
147pub fn validate_success_expr(expr: &str) -> ValidationResult {
154 let program = compile(expr)?;
155 let inputs = SuccessExprInputs {
158 stats: VerifyStats {
159 files: 1,
160 bytes: 1,
161 errors: 0,
162 },
163 snapshot: BTreeMap::from([("id".to_string(), "trial".to_string())]),
164 restored: Some(RestoredStats {
165 files: 1,
166 checksum_matches: true,
167 }),
168 tier: "deep".to_string(),
169 _marker: std::marker::PhantomData,
170 };
171 let ctx = context(&inputs);
172 match program.execute(&ctx) {
173 Ok(Value::Bool(_)) => Ok(()),
174 Ok(other) => Err(ValidationError::SuccessExprType {
175 expr: expr.to_string(),
176 got: other.type_of().to_string(),
177 }),
178 Err(cel::ExecutionError::UndeclaredReference(name)) => {
181 Err(ValidationError::SuccessExprEval {
182 expr: expr.to_string(),
183 reason: format!("undeclared reference to '{name}'"),
184 })
185 }
186 Err(_) => Ok(()),
187 }
188}
189
190#[cfg(test)]
191mod tests {
192 use super::*;
193
194 fn inputs(files: i64, errors: i64) -> SuccessExprInputs<'static> {
195 SuccessExprInputs {
196 stats: VerifyStats {
197 files,
198 bytes: files * 100,
199 errors,
200 },
201 snapshot: BTreeMap::from([("id".to_string(), "k1".to_string())]),
202 restored: None,
203 tier: "quick".to_string(),
204 _marker: std::marker::PhantomData,
205 }
206 }
207
208 #[test]
209 fn evaluates_true_and_false() {
210 let expr = "stats.files > 0 && stats.errors == 0";
211 assert!(eval_success_expr(expr, &inputs(5, 0)).unwrap());
212 assert!(!eval_success_expr(expr, &inputs(0, 0)).unwrap());
214 assert!(!eval_success_expr(expr, &inputs(5, 2)).unwrap());
216 }
217
218 #[test]
219 fn deep_restored_environment_is_available() {
220 let mut i = inputs(3, 0);
221 i.restored = Some(RestoredStats {
222 files: 3,
223 checksum_matches: true,
224 });
225 assert!(eval_success_expr("restored.files > 0 && restored.checksumMatches", &i).unwrap());
226 i.restored = Some(RestoredStats {
227 files: 3,
228 checksum_matches: false,
229 });
230 assert!(!eval_success_expr("restored.checksumMatches", &i).unwrap());
231 }
232
233 #[test]
234 fn snapshot_metadata_is_available() {
235 let i = inputs(1, 0);
236 assert!(eval_success_expr("snapshot.id == 'k1'", &i).unwrap());
237 }
238
239 #[test]
240 fn tier_is_available_for_branching() {
241 let i = inputs(5, 0);
243 assert_eq!(i.tier, "quick");
244 let expr = "tier == 'deep' ? restored.checksumMatches : stats.files > 0";
245 assert!(eval_success_expr(expr, &i).unwrap());
246 let mut d = inputs(5, 0);
248 d.tier = "deep".to_string();
249 d.restored = Some(RestoredStats {
250 files: 5,
251 checksum_matches: true,
252 });
253 assert!(eval_success_expr(expr, &d).unwrap());
254 d.restored = Some(RestoredStats {
255 files: 5,
256 checksum_matches: false,
257 });
258 assert!(!eval_success_expr(expr, &d).unwrap());
259 }
260
261 #[test]
262 fn non_bool_result_is_an_error() {
263 let err = eval_success_expr("stats.files", &inputs(1, 0)).unwrap_err();
264 assert!(matches!(err, ValidationError::SuccessExprType { .. }));
265 }
266
267 #[test]
268 fn typo_is_an_error() {
269 let err = eval_success_expr("statss.files > 0", &inputs(1, 0)).unwrap_err();
270 assert!(matches!(err, ValidationError::SuccessExprEval { .. }));
271 }
272
273 #[test]
276 fn validate_accepts_valid_bool_exprs() {
277 assert!(validate_success_expr("stats.files > 0 && stats.errors == 0").is_ok());
278 assert!(validate_success_expr("restored.checksumMatches").is_ok());
279 assert!(validate_success_expr("snapshot.id != ''").is_ok());
280 assert!(
282 validate_success_expr("tier == 'deep' ? restored.checksumMatches : stats.files > 0")
283 .is_ok()
284 );
285 assert!(validate_success_expr("snapshot.id != '' && stats.errors == 0").is_ok());
287 }
288
289 #[test]
290 fn validate_rejects_syntax_error() {
291 let err = validate_success_expr("stats.files >").unwrap_err();
292 assert!(matches!(err, ValidationError::SuccessExprCompile { .. }));
293 }
294
295 #[test]
296 fn validate_rejects_out_of_scope_variable() {
297 let err = validate_success_expr("bogus > 0").unwrap_err();
298 assert!(matches!(err, ValidationError::SuccessExprEval { .. }));
299 }
300
301 #[test]
302 fn validate_rejects_non_bool_result() {
303 let err = validate_success_expr("stats.files + 1").unwrap_err();
304 assert!(matches!(err, ValidationError::SuccessExprType { .. }));
305 }
306
307 #[test]
308 fn validate_rejects_over_length_expr() {
309 let long = format!("stats.files == {} ", "1".repeat(MAX_EXPR_LEN));
310 let err = validate_success_expr(&long).unwrap_err();
311 assert!(matches!(err, ValidationError::SuccessExprCompile { .. }));
312 }
313}