1use std::collections::HashSet;
18use std::path::Path;
19
20const REQUIRED_FIELDS: &[&str] = &[
21 "id",
22 "raw_uri",
23 "pproxy_interpretation",
24 "expected_interpretation",
25 "compatibility_tier",
26 "has_credentials",
27 "expected_redacted_display",
28 "expected_warnings",
29];
30
31const VALID_TIERS: &[&str] = &[
32 "compatible",
33 "supported",
34 "partial",
35 "intentional_non_parity",
36 "unsupported",
37];
38
39const CLI_REQUIRED_FIELDS: &[&str] = &[
40 "id",
41 "args",
42 "expected_exit_code",
43 "expected_warnings",
44 "toml_content_must_contain",
45];
46
47const PYTHON_API_REQUIRED_FIELDS: &[&str] = &[
48 "id",
49 "category",
50 "description",
51 "pproxy_behavior",
52 "egress_behavior",
53 "tier",
54];
55
56const PYTHON_API_VALID_TIERS: &[&str] = &["A", "B", "C", "D", "N/A", "A ", "B ", "C ", "D "];
57
58#[derive(Debug, thiserror::Error)]
60pub enum CorpusValidationError {
61 #[error("failed to read file {path}: {error}")]
62 FileRead { path: String, error: std::io::Error },
63 #[error("failed to parse TOML in {path}: {error}")]
64 TomlParse { path: String, error: String },
65 #[error("case '{case_id}' in {path} is missing required field '{field}'")]
66 MissingField {
67 case_id: String,
68 path: String,
69 field: &'static str,
70 },
71 #[error(
72 "case '{case_id}' in {path} has invalid compatibility_tier '{tier}'; valid: {valid:?}"
73 )]
74 InvalidTier {
75 case_id: String,
76 path: String,
77 tier: String,
78 valid: Vec<&'static str>,
79 },
80 #[error("case '{case_id}' in {path} has non-string id")]
81 NonStringId { case_id: String, path: String },
82 #[error("corpus has duplicate case id '{id}' in {path}")]
83 DuplicateId { id: String, path: String },
84 #[error("{path} has zero cases")]
85 Empty { path: String },
86 #[error("case '{case_id}' in {path} has_credentials=true but expected_redacted_display does not contain '****'")]
87 MissingRedaction { case_id: String, path: String },
88 #[error("case '{case_id}' in {path} has expected_toml but it is empty")]
89 EmptyToml { case_id: String, path: String },
90 #[error("case '{case_id}' in {path} has unsupported/intentional_non_parity tier but no manifest feature maps to it")]
91 UnmappedFeature { case_id: String, path: String },
92}
93
94fn load_toml(path: &Path) -> Result<toml::Value, CorpusValidationError> {
96 let path_str = path.display().to_string();
97 let content = std::fs::read_to_string(path).map_err(|e| CorpusValidationError::FileRead {
98 path: path_str.clone(),
99 error: e,
100 })?;
101 toml::from_str(&content).map_err(|e| CorpusValidationError::TomlParse {
102 path: path_str,
103 error: e.to_string(),
104 })
105}
106
107fn load_manifest_feature_ids(workspace_root: &Path) -> HashSet<String> {
109 let manifest_path = workspace_root.join("docs/parity/pproxy_capability_manifest.toml");
110 let Ok(value) = load_toml(&manifest_path) else {
111 return HashSet::new();
112 };
113 let mut ids = HashSet::new();
114 if let Some(capabilities) = value.get("capability").and_then(|v| v.as_array()) {
115 for cap in capabilities {
116 if let Some(id) = cap.get("id").and_then(|v| v.as_str()) {
117 ids.insert(id.to_string());
118 }
119 }
120 }
121 ids
122}
123
124fn uri_scheme(raw_uri: &str) -> Option<&str> {
126 raw_uri.find("://").map(|i| &raw_uri[..i])
127}
128
129pub fn validate_uri_corpus(path: &Path) -> Result<usize, CorpusValidationError> {
133 let path_str = path.display().to_string();
134 let value = load_toml(path)?;
135
136 let cases =
137 value
138 .get("cases")
139 .and_then(|v| v.as_array())
140 .ok_or(CorpusValidationError::Empty {
141 path: path_str.clone(),
142 })?;
143
144 if cases.is_empty() {
145 return Err(CorpusValidationError::Empty {
146 path: path_str.clone(),
147 });
148 }
149
150 let valid_tiers: Vec<&'static str> = VALID_TIERS.to_vec();
151 let mut seen_ids = HashSet::new();
152
153 for case in cases {
154 let raw_id = case
155 .get("id")
156 .and_then(|v| v.as_str())
157 .unwrap_or("<missing>");
158 let case_id = raw_id.to_string();
159
160 for &field in REQUIRED_FIELDS {
161 if case.get(field).is_none() {
162 return Err(CorpusValidationError::MissingField {
163 case_id,
164 path: path_str.clone(),
165 field,
166 });
167 }
168 }
169
170 let tier = case
172 .get("compatibility_tier")
173 .and_then(|v| v.as_str())
174 .unwrap_or("");
175 if !valid_tiers.contains(&tier) {
176 return Err(CorpusValidationError::InvalidTier {
177 case_id,
178 path: path_str.clone(),
179 tier: tier.to_string(),
180 valid: valid_tiers.clone(),
181 });
182 }
183
184 if !seen_ids.insert(case_id.clone()) {
186 return Err(CorpusValidationError::DuplicateId {
187 id: case_id,
188 path: path_str.clone(),
189 });
190 }
191
192 if case
194 .get("expected_warnings")
195 .and_then(|v| v.as_array())
196 .is_none()
197 {
198 return Err(CorpusValidationError::MissingField {
199 case_id,
200 path: path_str.clone(),
201 field: "expected_warnings (must be array)",
202 });
203 }
204
205 let has_creds = case
207 .get("has_credentials")
208 .and_then(|v| v.as_bool())
209 .unwrap_or(false);
210 if has_creds {
211 let display = case
212 .get("expected_redacted_display")
213 .and_then(|v| v.as_str())
214 .unwrap_or("");
215 if !display.contains("****") {
216 return Err(CorpusValidationError::MissingRedaction {
217 case_id,
218 path: path_str.clone(),
219 });
220 }
221 }
222
223 if let Some(toml_val) = case.get("expected_toml") {
225 if toml_val
226 .as_str()
227 .map(|s| s.trim().is_empty())
228 .unwrap_or(true)
229 {
230 return Err(CorpusValidationError::EmptyToml {
231 case_id,
232 path: path_str.clone(),
233 });
234 }
235 }
236 }
237
238 Ok(cases.len())
239}
240
241pub fn validate_workspace_uri_corpus() -> Result<usize, CorpusValidationError> {
244 let workspace_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
245 .join("..")
246 .join("..");
247 let path = workspace_root.join("tests/compat/fixtures/pproxy_uri_corpus.toml");
248 validate_uri_corpus(&path)
249}
250
251pub fn validate_cli_cases(workspace_root: &Path) -> Result<usize, CorpusValidationError> {
255 let cli_dir = workspace_root.join("tests/compat/fixtures/pproxy_cli_cases");
256 let path_str = cli_dir.display().to_string();
257
258 let mut entries: Vec<_> = std::fs::read_dir(&cli_dir)
259 .map_err(|e| CorpusValidationError::FileRead {
260 path: path_str.clone(),
261 error: e,
262 })?
263 .filter_map(|e| e.ok())
264 .filter(|e| e.path().extension().is_some_and(|ext| ext == "toml"))
265 .collect();
266 entries.sort_by_key(|e| e.path());
267
268 if entries.is_empty() {
269 return Err(CorpusValidationError::Empty { path: path_str });
270 }
271
272 let mut seen_ids = HashSet::new();
273
274 for entry in &entries {
275 let path = entry.path();
276 let path_str = path.display().to_string();
277 let value = load_toml(&path)?;
278
279 let raw_id = value
280 .get("id")
281 .and_then(|v| v.as_str())
282 .unwrap_or("<missing>");
283 let case_id = raw_id.to_string();
284
285 for &field in CLI_REQUIRED_FIELDS {
286 if value.get(field).is_none() {
287 return Err(CorpusValidationError::MissingField {
288 case_id,
289 path: path_str,
290 field,
291 });
292 }
293 }
294
295 if value.get("args").and_then(|v| v.as_array()).is_none() {
297 return Err(CorpusValidationError::MissingField {
298 case_id,
299 path: path_str,
300 field: "args (must be array)",
301 });
302 }
303
304 if value
306 .get("expected_exit_code")
307 .and_then(|v| v.as_integer())
308 .is_none()
309 {
310 return Err(CorpusValidationError::MissingField {
311 case_id,
312 path: path_str,
313 field: "expected_exit_code (must be integer)",
314 });
315 }
316
317 if value
319 .get("expected_warnings")
320 .and_then(|v| v.as_array())
321 .is_none()
322 {
323 return Err(CorpusValidationError::MissingField {
324 case_id,
325 path: path_str,
326 field: "expected_warnings (must be array)",
327 });
328 }
329
330 if value
332 .get("toml_content_must_contain")
333 .and_then(|v| v.as_array())
334 .is_none()
335 {
336 return Err(CorpusValidationError::MissingField {
337 case_id,
338 path: path_str,
339 field: "toml_content_must_contain (must be array)",
340 });
341 }
342
343 if !seen_ids.insert(case_id.clone()) {
345 return Err(CorpusValidationError::DuplicateId {
346 id: case_id,
347 path: path_str,
348 });
349 }
350 }
351
352 Ok(entries.len())
353}
354
355pub fn validate_workspace_cli_cases() -> Result<usize, CorpusValidationError> {
357 let workspace_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
358 .join("..")
359 .join("..");
360 validate_cli_cases(&workspace_root)
361}
362
363pub fn validate_corpus_manifest_mapping(path: &Path) -> Result<usize, CorpusValidationError> {
369 let workspace_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
370 .join("..")
371 .join("..");
372 let manifest_ids = load_manifest_feature_ids(&workspace_root);
373 let path_str = path.display().to_string();
374 let value = load_toml(path)?;
375
376 let cases =
377 value
378 .get("cases")
379 .and_then(|v| v.as_array())
380 .ok_or(CorpusValidationError::Empty {
381 path: path_str.clone(),
382 })?;
383
384 let mut checked = 0;
385 for case in cases {
386 let raw_id = case
387 .get("id")
388 .and_then(|v| v.as_str())
389 .unwrap_or("<missing>");
390 let case_id = raw_id.to_string();
391 let tier = case
392 .get("compatibility_tier")
393 .and_then(|v| v.as_str())
394 .unwrap_or("");
395
396 if tier != "unsupported" && tier != "intentional_non_parity" {
398 continue;
399 }
400
401 let raw_uri = case.get("raw_uri").and_then(|v| v.as_str()).unwrap_or("");
402 let scheme = uri_scheme(raw_uri).unwrap_or("");
403
404 let expected_features: Vec<&str> = match scheme {
409 "h2" => vec!["uri.scheme_h2"],
410 "ws" | "wss" => vec!["uri.scheme_ws"],
411 "raw" => vec!["uri.scheme_raw"],
412 "quic" | "h3" => vec![],
413 "ssr" => vec!["uri.scheme_ssr", "protocol.ssr"],
414 "ssh" => vec!["uri.scheme_ssh"],
415 "ftp" => vec![],
416 _ => vec![],
417 };
418
419 if !expected_features.is_empty() {
420 let has_mapping = expected_features.iter().any(|f| manifest_ids.contains(*f));
421 if !has_mapping {
422 return Err(CorpusValidationError::UnmappedFeature {
423 case_id,
424 path: path_str.clone(),
425 });
426 }
427 }
428
429 checked += 1;
430 }
431
432 Ok(checked)
433}
434
435pub fn validate_workspace_corpus_full(
443) -> Result<(usize, usize, usize, usize), CorpusValidationError> {
444 let corpus = validate_workspace_uri_corpus()?;
445 let cli = validate_workspace_cli_cases()?;
446 let workspace_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
447 .join("..")
448 .join("..");
449 let corpus_path = workspace_root.join("tests/compat/fixtures/pproxy_uri_corpus.toml");
450 let mapped = validate_corpus_manifest_mapping(&corpus_path)?;
451 let python_api = validate_workspace_python_api_cases()?;
452 Ok((corpus, cli, mapped, python_api))
453}
454
455pub fn validate_python_api_cases(path: &Path) -> Result<usize, CorpusValidationError> {
464 let path_str = path.display().to_string();
465 let value = load_toml(path)?;
466
467 let cases: Vec<toml::Value> = value
470 .get("cases")
471 .or_else(|| value.get("case"))
472 .and_then(|v| v.as_array())
473 .cloned()
474 .ok_or_else(|| CorpusValidationError::Empty {
475 path: path_str.clone(),
476 })?;
477
478 if cases.is_empty() {
479 return Err(CorpusValidationError::Empty {
480 path: path_str.clone(),
481 });
482 }
483
484 let valid_tiers: Vec<&'static str> = PYTHON_API_VALID_TIERS.to_vec();
485 let mut seen_ids = HashSet::new();
486
487 for case in &cases {
488 let raw_id = case
489 .get("id")
490 .and_then(|v| v.as_str())
491 .unwrap_or("<missing>");
492 let case_id = raw_id.to_string();
493
494 for &field in PYTHON_API_REQUIRED_FIELDS {
495 if case.get(field).is_none() {
496 return Err(CorpusValidationError::MissingField {
497 case_id,
498 path: path_str.clone(),
499 field,
500 });
501 }
502 }
503
504 let tier = case.get("tier").and_then(|v| v.as_str()).unwrap_or("");
505 if !valid_tiers.contains(&tier) {
506 return Err(CorpusValidationError::InvalidTier {
507 case_id,
508 path: path_str.clone(),
509 tier: tier.to_string(),
510 valid: vec!["A", "B", "C", "D", "N/A"],
511 });
512 }
513
514 if !seen_ids.insert(case_id.clone()) {
515 return Err(CorpusValidationError::DuplicateId {
516 id: case_id,
517 path: path_str.clone(),
518 });
519 }
520 }
521
522 Ok(cases.len())
523}
524
525pub fn validate_workspace_python_api_cases() -> Result<usize, CorpusValidationError> {
527 let workspace_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
528 .join("..")
529 .join("..");
530 let path = workspace_root.join("tests/compat/fixtures/python_api_cases.toml");
531 validate_python_api_cases(&path)
532}
533
534#[cfg(test)]
535mod tests {
536 use super::*;
537
538 #[test]
539 fn workspace_uri_corpus_is_valid() {
540 let n = validate_workspace_uri_corpus().expect("pproxy_uri_corpus.toml should validate");
541 assert!(n > 0, "corpus must have at least one case");
542 assert!(n >= 50, "corpus should have at least 50 cases, got {n}");
543 }
544
545 #[test]
546 fn workspace_cli_cases_are_valid() {
547 let n = validate_workspace_cli_cases().expect("cli_cases should validate");
548 assert!(n > 0, "cli_cases must have at least one fixture");
549 }
550
551 #[test]
552 fn corpus_manifest_mapping_is_valid() {
553 let workspace_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
554 .join("..")
555 .join("..");
556 let corpus_path = workspace_root.join("tests/compat/fixtures/pproxy_uri_corpus.toml");
557 let n = validate_corpus_manifest_mapping(&corpus_path)
558 .expect("corpus-to-manifest mapping should validate");
559 assert!(
560 n > 0,
561 "should have at least one unsupported/intentional_non_parity case"
562 );
563 }
564
565 #[test]
566 fn full_corpus_validation() {
567 let (corpus, cli, mapped, python_api) =
568 validate_workspace_corpus_full().expect("full corpus validation should pass");
569 assert!(corpus >= 50);
570 assert!(cli >= 1);
571 assert!(mapped >= 1);
572 assert!(
573 python_api >= 50,
574 "python_api cases should have at least 50 cases, got {python_api}"
575 );
576 }
577
578 #[test]
579 fn workspace_python_api_cases_are_valid() {
580 let n =
581 validate_workspace_python_api_cases().expect("python_api_cases.toml should validate");
582 assert!(
583 n >= 50,
584 "python_api cases should have at least 50 cases, got {n}"
585 );
586 }
587}