aprender_contracts_cli/commands/
check_parity.rs1use std::path::Path;
14use std::process::Command;
15
16use serde_yaml::Value;
17
18#[derive(Debug)]
19pub struct RowResult {
20 pub id: String,
21 pub status: String,
22 pub verdict: Verdict,
23}
24
25#[derive(Debug)]
26pub enum Verdict {
27 Pass { hits: u64 },
28 Fail { reason: String },
29 Skipped { reason: String },
30}
31
32pub fn run(path: &Path) -> Result<(), Box<dyn std::error::Error>> {
33 let text = std::fs::read_to_string(path)?;
34 let doc: Value = serde_yaml::from_str(&text)?;
35
36 let rows = doc
37 .get("categories")
38 .and_then(Value::as_sequence)
39 .ok_or("contract has no `categories:` sequence — is this a parity matrix?")?;
40
41 let mut results: Vec<RowResult> = Vec::with_capacity(rows.len());
42 for row in rows {
43 results.push(check_row(row));
44 }
45
46 print_report(&results);
47
48 let headline_errors = check_headline(&doc, rows);
49 for e in &headline_errors {
50 println!(" FAIL [HEADLINE] {e}");
51 }
52
53 let failures = results
54 .iter()
55 .filter(|r| matches!(r.verdict, Verdict::Fail { .. }))
56 .count();
57 let total_failures = failures + headline_errors.len();
58 if total_failures == 0 {
59 Ok(())
60 } else {
61 Err(format!("{total_failures} parity check(s) failed").into())
62 }
63}
64
65fn check_headline(doc: &Value, rows: &[Value]) -> Vec<String> {
68 let mut errors = Vec::new();
69
70 let (mut shipped, mut partial, mut missing) = (0u64, 0u64, 0u64);
71 for row in rows {
72 match row.get("status").and_then(Value::as_str) {
73 Some("SHIPPED") => shipped += 1,
74 Some("PARTIAL") => partial += 1,
75 Some("NONE" | "MISSING") => missing += 1,
76 _ => {}
77 }
78 }
79 let actual_total = rows.len() as u64;
80
81 let Some(headline) = doc.get("headline") else {
82 return errors;
83 };
84
85 if let Some(declared) = headline.get("total_rows").and_then(Value::as_u64) {
86 if declared != actual_total {
87 errors.push(format!(
88 "headline.total_rows {declared} ≠ actual {actual_total}"
89 ));
90 }
91 }
92
93 let Some(counts) = headline.get("counts") else {
94 return errors;
95 };
96 for (name, actual) in &[
97 ("shipped", shipped),
98 ("partial", partial),
99 ("missing", missing),
100 ] {
101 if let Some(declared) = counts.get(*name).and_then(Value::as_u64) {
102 if declared != *actual {
103 errors.push(format!(
104 "headline.counts.{name} {declared} ≠ actual {actual}"
105 ));
106 }
107 }
108 }
109
110 errors
111}
112
113fn check_row(row: &Value) -> RowResult {
114 let id = row
115 .get("id")
116 .and_then(Value::as_str)
117 .unwrap_or("<unnamed>")
118 .to_string();
119 let status = row
120 .get("status")
121 .and_then(Value::as_str)
122 .unwrap_or("UNKNOWN")
123 .to_string();
124
125 let Some(cmd) = row.get("cross_check_command").and_then(Value::as_str) else {
126 return RowResult {
127 id,
128 status,
129 verdict: Verdict::Skipped {
130 reason: "no cross_check_command".to_string(),
131 },
132 };
133 };
134
135 let output = match Command::new("sh").arg("-c").arg(cmd.trim()).output() {
136 Ok(o) => o,
137 Err(e) => {
138 return RowResult {
139 id,
140 status,
141 verdict: Verdict::Skipped {
142 reason: format!("exec failed: {e}"),
143 },
144 };
145 }
146 };
147
148 let stdout = String::from_utf8_lossy(&output.stdout);
149 let stdout_trimmed = stdout.trim();
150 let Ok(hits) = stdout_trimmed.parse::<u64>() else {
151 return RowResult {
152 id,
153 status,
154 verdict: Verdict::Skipped {
155 reason: format!("non-numeric output: {stdout_trimmed:?}"),
156 },
157 };
158 };
159
160 let min = row
161 .get("expected_min_hits")
162 .and_then(Value::as_u64)
163 .or_else(|| {
164 row.get("expected_variant_count_min")
165 .and_then(Value::as_u64)
166 });
167 let max = row
168 .get("expected_max_hits")
169 .and_then(Value::as_u64)
170 .or_else(|| {
171 row.get("expected_variant_count_max")
172 .and_then(Value::as_u64)
173 });
174
175 if let Some(m) = min {
176 if hits < m {
177 return RowResult {
178 id,
179 status,
180 verdict: Verdict::Fail {
181 reason: format!("hits {hits} < expected_min {m}"),
182 },
183 };
184 }
185 }
186 if let Some(m) = max {
187 if hits > m {
188 return RowResult {
189 id,
190 status,
191 verdict: Verdict::Fail {
192 reason: format!("hits {hits} > expected_max {m}"),
193 },
194 };
195 }
196 }
197
198 RowResult {
199 id,
200 status,
201 verdict: Verdict::Pass { hits },
202 }
203}
204
205fn print_report(results: &[RowResult]) {
206 let mut pass = 0usize;
207 let mut fail = 0usize;
208 let mut skip = 0usize;
209 for r in results {
210 match &r.verdict {
211 Verdict::Pass { hits } => {
212 pass += 1;
213 println!(" PASS [{:<8}] {} (hits={hits})", r.status, r.id);
214 }
215 Verdict::Fail { reason } => {
216 fail += 1;
217 println!(" FAIL [{:<8}] {} ({reason})", r.status, r.id);
218 }
219 Verdict::Skipped { reason } => {
220 skip += 1;
221 println!(" SKIP [{:<8}] {} ({reason})", r.status, r.id);
222 }
223 }
224 }
225 println!();
226 println!(
227 "{} row(s) checked: {pass} pass, {fail} fail, {skip} skip",
228 results.len()
229 );
230}