1use serde::{Deserialize, Serialize};
10use std::collections::{HashMap, HashSet};
11
12#[cfg(feature = "wasm")]
13use wasm_bindgen::prelude::*;
14
15#[derive(Debug, Clone, Serialize, Deserialize)]
17#[serde(rename_all = "camelCase")]
18pub struct Review {
19 pub reviewer_id: String,
21 pub status: String,
23 pub timestamp: f64,
25 #[serde(skip_serializing_if = "Option::is_none")]
27 pub reason: Option<String>,
28 pub at_version: u32,
30}
31
32#[derive(Debug, Clone, Serialize, Deserialize)]
34#[serde(rename_all = "camelCase")]
35pub struct ApprovalPolicy {
36 pub required_count: u32,
39 pub require_unanimous: bool,
41 pub allowed_reviewer_ids: Vec<String>,
43 pub timeout_ms: f64,
45 #[serde(default)]
49 pub required_percentage: u32,
50}
51
52#[derive(Debug, Clone, Serialize, Deserialize)]
54#[serde(rename_all = "camelCase")]
55pub struct ApprovalResult {
56 pub approved: bool,
58 pub approved_by: Vec<String>,
60 pub rejected_by: Vec<String>,
62 pub pending_from: Vec<String>,
64 pub stale_from: Vec<String>,
66 pub reason: String,
68}
69
70pub fn evaluate_approvals_native(
75 reviews: &[Review],
76 policy: &ApprovalPolicy,
77 current_version: u32,
78 now: f64,
79) -> ApprovalResult {
80 let mut approved_by = Vec::new();
81 let mut rejected_by = Vec::new();
82 let mut pending_from = Vec::new();
83 let mut stale_from = Vec::new();
84
85 let effective_reviewers: Vec<String> = if !policy.allowed_reviewer_ids.is_empty() {
87 policy.allowed_reviewer_ids.clone()
88 } else {
89 let mut seen = HashSet::new();
90 reviews
91 .iter()
92 .filter(|r| seen.insert(r.reviewer_id.clone()))
93 .map(|r| r.reviewer_id.clone())
94 .collect()
95 };
96
97 let mut review_map: HashMap<&str, &Review> = HashMap::new();
99 for review in reviews {
100 let dominated = match review_map.get(review.reviewer_id.as_str()) {
101 None => true,
102 Some(existing) => review.timestamp > existing.timestamp,
103 };
104 if dominated {
105 review_map.insert(&review.reviewer_id, review);
106 }
107 }
108
109 for reviewer_id in &effective_reviewers {
110 let Some(review) = review_map.get(reviewer_id.as_str()) else {
111 pending_from.push(reviewer_id.clone());
112 continue;
113 };
114
115 if review.at_version < current_version {
117 stale_from.push(reviewer_id.clone());
118 continue;
119 }
120
121 if policy.timeout_ms > 0.0 && (now - review.timestamp) > policy.timeout_ms {
123 stale_from.push(reviewer_id.clone());
124 continue;
125 }
126
127 match review.status.to_uppercase().as_str() {
128 "APPROVED" => approved_by.push(reviewer_id.clone()),
129 "REJECTED" => rejected_by.push(reviewer_id.clone()),
130 "STALE" => stale_from.push(reviewer_id.clone()),
131 _ => pending_from.push(reviewer_id.clone()),
132 }
133 }
134
135 let (approved, reason) = if !rejected_by.is_empty() {
137 (false, format!("Rejected by {}", rejected_by.join(", ")))
138 } else if policy.require_unanimous {
139 let all_approved = approved_by.len() == effective_reviewers.len()
140 && pending_from.is_empty()
141 && stale_from.is_empty();
142 let reason = if all_approved {
143 format!(
144 "Unanimous approval ({}/{})",
145 approved_by.len(),
146 effective_reviewers.len()
147 )
148 } else {
149 format!(
150 "Awaiting unanimous approval ({}/{})",
151 approved_by.len(),
152 effective_reviewers.len()
153 )
154 };
155 (all_approved, reason)
156 } else {
157 let threshold = if policy.required_percentage > 0 {
159 let pct = policy.required_percentage.min(100) as f64 / 100.0;
160 (pct * effective_reviewers.len() as f64).ceil() as u32
161 } else {
162 policy.required_count
163 };
164 let met = approved_by.len() as u32 >= threshold;
165 let threshold_label = if policy.required_percentage > 0 {
166 format!("{}% = {}", policy.required_percentage, threshold)
167 } else {
168 format!("{}", threshold)
169 };
170 let reason = if met {
171 format!(
172 "Approved ({}/{} required)",
173 approved_by.len(),
174 threshold_label
175 )
176 } else {
177 let remaining = threshold.saturating_sub(approved_by.len() as u32);
178 format!(
179 "Needs {} more approval(s) ({}/{} required)",
180 remaining,
181 approved_by.len(),
182 threshold_label
183 )
184 };
185 (met, reason)
186 };
187
188 ApprovalResult {
189 approved,
190 approved_by,
191 rejected_by,
192 pending_from,
193 stale_from,
194 reason,
195 }
196}
197
198pub fn mark_stale_reviews_native(reviews: &[Review], current_version: u32) -> Vec<Review> {
202 reviews
203 .iter()
204 .map(|r| {
205 if r.at_version < current_version && r.status.to_uppercase() != "STALE" {
206 Review {
207 status: "STALE".to_string(),
208 ..r.clone()
209 }
210 } else {
211 r.clone()
212 }
213 })
214 .collect()
215}
216
217#[cfg_attr(feature = "wasm", wasm_bindgen)]
226pub fn evaluate_approvals(
227 reviews_json: &str,
228 policy_json: &str,
229 current_version: u32,
230 now_ms: f64,
231) -> Result<String, String> {
232 let reviews: Vec<Review> =
233 serde_json::from_str(reviews_json).map_err(|e| format!("Invalid reviews JSON: {e}"))?;
234 let policy: ApprovalPolicy =
235 serde_json::from_str(policy_json).map_err(|e| format!("Invalid policy JSON: {e}"))?;
236
237 let result = evaluate_approvals_native(&reviews, &policy, current_version, now_ms);
238 serde_json::to_string(&result).map_err(|e| format!("Serialization failed: {e}"))
239}
240
241#[cfg_attr(feature = "wasm", wasm_bindgen)]
245pub fn mark_stale_reviews(reviews_json: &str, current_version: u32) -> Result<String, String> {
246 let reviews: Vec<Review> =
247 serde_json::from_str(reviews_json).map_err(|e| format!("Invalid reviews JSON: {e}"))?;
248
249 let result = mark_stale_reviews_native(&reviews, current_version);
250 serde_json::to_string(&result).map_err(|e| format!("Serialization failed: {e}"))
251}
252
253#[cfg(test)]
254mod tests {
255 use super::*;
256
257 fn default_policy() -> ApprovalPolicy {
258 ApprovalPolicy {
259 required_count: 1,
260 require_unanimous: false,
261 allowed_reviewer_ids: vec![],
262 timeout_ms: 0.0,
263 required_percentage: 0,
264 }
265 }
266
267 fn review(id: &str, status: &str, version: u32) -> Review {
268 Review {
269 reviewer_id: id.to_string(),
270 status: status.to_string(),
271 timestamp: 1_000_000.0,
272 reason: None,
273 at_version: version,
274 }
275 }
276
277 #[test]
278 fn test_single_approval_meets_default_policy() {
279 let reviews = vec![review("agent-1", "APPROVED", 1)];
280 let result = evaluate_approvals_native(&reviews, &default_policy(), 1, 2_000_000.0);
281 assert!(result.approved);
282 assert_eq!(result.approved_by, vec!["agent-1"]);
283 }
284
285 #[test]
286 fn test_no_reviews_pending() {
287 let policy = ApprovalPolicy {
288 allowed_reviewer_ids: vec!["agent-1".to_string()],
289 ..default_policy()
290 };
291 let result = evaluate_approvals_native(&[], &policy, 1, 2_000_000.0);
292 assert!(!result.approved);
293 assert_eq!(result.pending_from, vec!["agent-1"]);
294 }
295
296 #[test]
297 fn test_rejection_overrides_approval() {
298 let reviews = vec![
299 review("agent-1", "APPROVED", 1),
300 review("agent-2", "REJECTED", 1),
301 ];
302 let policy = ApprovalPolicy {
303 required_count: 1,
304 ..default_policy()
305 };
306 let result = evaluate_approvals_native(&reviews, &policy, 1, 2_000_000.0);
307 assert!(!result.approved);
308 assert!(result.reason.contains("Rejected"));
309 }
310
311 #[test]
312 fn test_stale_review_for_old_version() {
313 let reviews = vec![review("agent-1", "APPROVED", 1)];
314 let result = evaluate_approvals_native(&reviews, &default_policy(), 2, 2_000_000.0);
315 assert!(!result.approved);
316 assert_eq!(result.stale_from, vec!["agent-1"]);
317 }
318
319 #[test]
320 fn test_timed_out_review() {
321 let reviews = vec![Review {
322 reviewer_id: "agent-1".to_string(),
323 status: "APPROVED".to_string(),
324 timestamp: 1_000_000.0,
325 reason: None,
326 at_version: 1,
327 }];
328 let policy = ApprovalPolicy {
329 timeout_ms: 500_000.0,
330 ..default_policy()
331 };
332 let result = evaluate_approvals_native(&reviews, &policy, 1, 2_000_000.0);
334 assert!(!result.approved);
335 assert_eq!(result.stale_from, vec!["agent-1"]);
336 }
337
338 #[test]
339 fn test_unanimous_policy() {
340 let policy = ApprovalPolicy {
341 required_count: 2,
342 require_unanimous: true,
343 allowed_reviewer_ids: vec!["agent-1".to_string(), "agent-2".to_string()],
344 timeout_ms: 0.0,
345 required_percentage: 0,
346 };
347
348 let reviews = vec![review("agent-1", "APPROVED", 1)];
350 let result = evaluate_approvals_native(&reviews, &policy, 1, 2_000_000.0);
351 assert!(!result.approved);
352 assert!(result.reason.contains("Awaiting unanimous"));
353
354 let reviews = vec![
356 review("agent-1", "APPROVED", 1),
357 review("agent-2", "APPROVED", 1),
358 ];
359 let result = evaluate_approvals_native(&reviews, &policy, 1, 2_000_000.0);
360 assert!(result.approved);
361 assert!(result.reason.contains("Unanimous"));
362 }
363
364 #[test]
365 fn test_latest_review_wins() {
366 let reviews = vec![
367 Review {
368 reviewer_id: "agent-1".to_string(),
369 status: "REJECTED".to_string(),
370 timestamp: 1_000_000.0,
371 reason: None,
372 at_version: 1,
373 },
374 Review {
375 reviewer_id: "agent-1".to_string(),
376 status: "APPROVED".to_string(),
377 timestamp: 2_000_000.0,
378 reason: None,
379 at_version: 1,
380 },
381 ];
382 let result = evaluate_approvals_native(&reviews, &default_policy(), 1, 3_000_000.0);
383 assert!(result.approved);
384 assert_eq!(result.approved_by, vec!["agent-1"]);
385 }
386
387 #[test]
388 fn test_mark_stale_reviews() {
389 let reviews = vec![
390 review("agent-1", "APPROVED", 1),
391 review("agent-2", "APPROVED", 2),
392 ];
393 let result = mark_stale_reviews_native(&reviews, 2);
394 assert_eq!(result[0].status, "STALE");
395 assert_eq!(result[1].status, "APPROVED");
396 }
397
398 #[test]
399 fn test_wasm_evaluate_approvals() {
400 let reviews_json =
401 r#"[{"reviewerId":"agent-1","status":"APPROVED","timestamp":1000000,"atVersion":1}]"#;
402 let policy_json =
403 r#"{"requiredCount":1,"requireUnanimous":false,"allowedReviewerIds":[],"timeoutMs":0}"#;
404 let result_json = evaluate_approvals(reviews_json, policy_json, 1, 2_000_000.0).unwrap();
405 let result: ApprovalResult = serde_json::from_str(&result_json).unwrap();
406 assert!(result.approved);
407 }
408
409 #[test]
410 fn test_wasm_mark_stale() {
411 let reviews_json =
412 r#"[{"reviewerId":"agent-1","status":"APPROVED","timestamp":1000000,"atVersion":1}]"#;
413 let result_json = mark_stale_reviews(reviews_json, 2).unwrap();
414 let result: Vec<Review> = serde_json::from_str(&result_json).unwrap();
415 assert_eq!(result[0].status, "STALE");
416 }
417
418 #[test]
419 fn test_percentage_threshold_51_percent() {
420 let ids: Vec<String> = (1..=10).map(|i| format!("agent-{i}")).collect();
422 let policy = ApprovalPolicy {
423 required_count: 0,
424 require_unanimous: false,
425 allowed_reviewer_ids: ids,
426 timeout_ms: 0.0,
427 required_percentage: 51,
428 };
429
430 let reviews: Vec<Review> = (1..=5)
432 .map(|i| review(&format!("agent-{i}"), "APPROVED", 1))
433 .collect();
434 let result = evaluate_approvals_native(&reviews, &policy, 1, 2_000_000.0);
435 assert!(!result.approved, "5/10 should not meet 51%");
436 assert!(result.reason.contains("51%"));
437
438 let reviews: Vec<Review> = (1..=6)
440 .map(|i| review(&format!("agent-{i}"), "APPROVED", 1))
441 .collect();
442 let result = evaluate_approvals_native(&reviews, &policy, 1, 2_000_000.0);
443 assert!(result.approved, "6/10 should meet 51%");
444 }
445
446 #[test]
447 fn test_percentage_threshold_20_percent() {
448 let policy = ApprovalPolicy {
450 required_count: 0,
451 require_unanimous: false,
452 allowed_reviewer_ids: vec![
453 "a1".into(),
454 "a2".into(),
455 "a3".into(),
456 "a4".into(),
457 "a5".into(),
458 ],
459 timeout_ms: 0.0,
460 required_percentage: 20,
461 };
462
463 let reviews = vec![review("a1", "APPROVED", 1)];
464 let result = evaluate_approvals_native(&reviews, &policy, 1, 2_000_000.0);
465 assert!(result.approved, "1/5 should meet 20%");
466 }
467
468 #[test]
469 fn test_percentage_overrides_count() {
470 let policy = ApprovalPolicy {
472 required_count: 10,
473 require_unanimous: false,
474 allowed_reviewer_ids: vec![
475 "a1".into(),
476 "a2".into(),
477 "a3".into(),
478 "a4".into(),
479 "a5".into(),
480 ],
481 timeout_ms: 0.0,
482 required_percentage: 20,
483 };
484
485 let reviews = vec![review("a1", "APPROVED", 1)];
486 let result = evaluate_approvals_native(&reviews, &policy, 1, 2_000_000.0);
487 assert!(result.approved, "percentage should override count");
488 }
489}