1use std::collections::BTreeMap;
6use std::fs;
7use std::path::Path;
8use std::process::Command;
9use std::time::Duration;
10
11use serde::{Deserialize, Serialize};
12
13use super::observations::ProxyObservation;
14use super::scenario::ScenarioCategory;
15
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
18#[serde(rename_all = "snake_case")]
19pub enum CertificationProfile {
20 Differential,
22 Platform,
24}
25
26#[derive(Debug, Clone, Serialize, Deserialize)]
28pub struct OracleReport {
29 pub pproxy_version: String,
31 pub eggress_commit: Option<String>,
33 pub os_platform: String,
35 pub rust_version: String,
37 pub python_version: String,
39 pub elapsed_ms: u64,
41 pub scenarios: Vec<ScenarioResult>,
43 pub summary: ReportSummary,
45}
46
47#[derive(Debug, Clone, Serialize, Deserialize)]
49pub struct ScenarioResult {
50 pub id: String,
52 pub category: ScenarioCategory,
54 pub description: String,
56 pub status: ScenarioStatus,
58 pub comparisons: Vec<ComparisonResult>,
60 pub elapsed_ms: u64,
62 pub error: Option<String>,
64 pub skip_reason: Option<String>,
66 #[serde(skip_serializing_if = "Option::is_none")]
68 pub pproxy_observation: Option<ProxyObservation>,
69 #[serde(skip_serializing_if = "Option::is_none")]
71 pub eggress_observation: Option<ProxyObservation>,
72 #[serde(skip_serializing_if = "Option::is_none")]
74 pub timing_tolerance_ms: Option<u64>,
75 #[serde(default, skip_serializing_if = "Vec::is_empty")]
77 pub divergence_ids: Vec<String>,
78 #[serde(rename = "profile", skip_serializing_if = "Option::is_none")]
80 pub certification_profile: Option<CertificationProfile>,
81 #[serde(default, skip_serializing_if = "Vec::is_empty")]
83 pub capability_ids: Vec<String>,
84}
85
86#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
88#[serde(rename_all = "snake_case")]
89pub enum ScenarioStatus {
90 Pass,
91 Fail,
92 Skipped,
93 Error,
94}
95
96#[derive(Debug, Clone, Serialize, Deserialize)]
98pub struct ComparisonResult {
99 pub dimension: String,
101 pub pproxy_value: String,
103 pub eggress_value: String,
105 pub matched: bool,
107 pub details: Option<String>,
109}
110
111#[derive(Debug, Clone, Serialize, Deserialize)]
113pub struct ReportSummary {
114 pub total: usize,
115 pub passed: usize,
116 pub failed: usize,
117 pub skipped: usize,
118 pub errors: usize,
119}
120
121impl Default for OracleReport {
122 fn default() -> Self {
123 Self::new()
124 }
125}
126
127impl OracleReport {
128 pub fn new() -> Self {
130 Self {
131 pproxy_version: detect_pproxy_version().unwrap_or_else(|| "unknown".to_string()),
132 eggress_commit: detect_eggress_commit(),
133 os_platform: std::env::consts::OS.to_string(),
134 rust_version: detect_rust_version(),
135 python_version: detect_python_version().unwrap_or_else(|| "unknown".to_string()),
136 elapsed_ms: 0,
137 scenarios: Vec::new(),
138 summary: ReportSummary {
139 total: 0,
140 passed: 0,
141 failed: 0,
142 skipped: 0,
143 errors: 0,
144 },
145 }
146 }
147
148 pub fn add_scenario(&mut self, result: ScenarioResult) {
150 match result.status {
151 ScenarioStatus::Pass => self.summary.passed += 1,
152 ScenarioStatus::Fail => self.summary.failed += 1,
153 ScenarioStatus::Skipped => self.summary.skipped += 1,
154 ScenarioStatus::Error => self.summary.errors += 1,
155 }
156 self.summary.total += 1;
157 self.scenarios.push(result);
158 }
159
160 pub fn set_elapsed(&mut self, elapsed: Duration) {
162 self.elapsed_ms = elapsed.as_millis() as u64;
163 }
164
165 pub fn to_json(&self) -> String {
167 serde_json::to_string_pretty(self).expect("oracle report serialization should not fail")
168 }
169
170 pub fn write_json(&self, path: &Path) -> std::io::Result<()> {
172 if let Some(parent) = path.parent() {
173 fs::create_dir_all(parent)?;
174 }
175 fs::write(path, self.to_json())
176 }
177
178 pub fn to_markdown(&self) -> String {
180 let mut md = String::new();
181 md.push_str("# Oracle Differential Report\n\n");
182 md.push_str(&format!("**pproxy version:** {}\n", self.pproxy_version));
183 md.push_str(&format!(
184 "**eggress commit:** {}\n",
185 self.eggress_commit.as_deref().unwrap_or("unknown")
186 ));
187 md.push_str(&format!("**platform:** {}\n", self.os_platform));
188 md.push_str(&format!("**elapsed:** {}ms\n\n", self.elapsed_ms));
189
190 md.push_str("## Summary\n\n");
191 md.push_str("| Metric | Count |\n|--------|-------|\n");
192 md.push_str(&format!("| Total | {} |\n", self.summary.total));
193 md.push_str(&format!("| Passed | {} |\n", self.summary.passed));
194 md.push_str(&format!("| Failed | {} |\n", self.summary.failed));
195 md.push_str(&format!("| Skipped | {} |\n", self.summary.skipped));
196 md.push_str(&format!("| Errors | {} |\n\n", self.summary.errors));
197
198 let mut by_category: BTreeMap<String, Vec<&ScenarioResult>> = BTreeMap::new();
199 for s in &self.scenarios {
200 let cat = format!("{:?}", s.category);
201 by_category.entry(cat).or_default().push(s);
202 }
203
204 for (cat, scenarios) in &by_category {
205 md.push_str(&format!("## {}\n\n", cat));
206 for s in scenarios {
207 let status_icon = match s.status {
208 ScenarioStatus::Pass => "✅",
209 ScenarioStatus::Fail => "❌",
210 ScenarioStatus::Skipped => "⏭️",
211 ScenarioStatus::Error => "⚠️",
212 };
213 md.push_str(&format!(
214 "### {} {} {}\n\n",
215 status_icon, s.id, s.description
216 ));
217
218 if let Some(err) = &s.error {
219 md.push_str(&format!("**Error:** {}\n\n", err));
220 }
221 if let Some(skip) = &s.skip_reason {
222 md.push_str(&format!("**Skip reason:** {}\n\n", skip));
223 }
224
225 if !s.comparisons.is_empty() {
226 md.push_str("| Dimension | Match | Pproxy | Eggress |\n");
227 md.push_str("|-----------|-------|--------|----------|\n");
228 for c in &s.comparisons {
229 let match_icon = if c.matched { "✅" } else { "❌" };
230 md.push_str(&format!(
231 "| {} | {} | {} | {} |\n",
232 c.dimension,
233 match_icon,
234 truncate_md(&c.pproxy_value, 50),
235 truncate_md(&c.eggress_value, 50)
236 ));
237 }
238 md.push('\n');
239 }
240
241 if !s.divergence_ids.is_empty() {
242 md.push_str(&format!(
243 "**Divergences:** {}\n\n",
244 s.divergence_ids.join(", ")
245 ));
246 }
247 }
248 }
249
250 md
251 }
252
253 pub fn check_manifest_consistency(&self, valid_capability_ids: &[&str]) -> Vec<String> {
255 let mut warnings = Vec::new();
256 for s in &self.scenarios {
257 for cap_id in &s.capability_ids {
258 if !valid_capability_ids.contains(&cap_id.as_str()) {
259 warnings.push(format!(
260 "scenario '{}' references unknown capability '{}'",
261 s.id, cap_id
262 ));
263 }
264 }
265 }
266 warnings
267 }
268
269 pub fn scenarios_for_profile(&self, profile: CertificationProfile) -> Vec<&ScenarioResult> {
271 self.scenarios
272 .iter()
273 .filter(|s| s.certification_profile == Some(profile))
274 .collect()
275 }
276}
277
278impl ScenarioResult {
279 pub fn new(id: &str, category: ScenarioCategory, description: &str) -> Self {
280 Self {
281 id: id.to_string(),
282 category,
283 description: description.to_string(),
284 status: ScenarioStatus::Skipped,
285 comparisons: Vec::new(),
286 elapsed_ms: 0,
287 error: None,
288 skip_reason: None,
289 pproxy_observation: None,
290 eggress_observation: None,
291 timing_tolerance_ms: None,
292 divergence_ids: Vec::new(),
293 certification_profile: None,
294 capability_ids: Vec::new(),
295 }
296 }
297
298 pub fn with_status(mut self, status: ScenarioStatus) -> Self {
299 self.status = status;
300 self
301 }
302
303 pub fn with_elapsed(mut self, elapsed: Duration) -> Self {
304 self.elapsed_ms = elapsed.as_millis() as u64;
305 self
306 }
307
308 pub fn with_comparisons(mut self, comparisons: Vec<ComparisonResult>) -> Self {
309 self.comparisons = comparisons;
310 self
311 }
312
313 pub fn with_error(mut self, error: String) -> Self {
314 self.error = Some(error);
315 self
316 }
317
318 pub fn with_skip_reason(mut self, reason: String) -> Self {
319 self.skip_reason = Some(reason);
320 self.status = ScenarioStatus::Skipped;
321 self
322 }
323
324 pub fn with_pproxy_observation(mut self, obs: ProxyObservation) -> Self {
325 self.pproxy_observation = Some(obs);
326 self
327 }
328
329 pub fn with_eggress_observation(mut self, obs: ProxyObservation) -> Self {
330 self.eggress_observation = Some(obs);
331 self
332 }
333
334 pub fn with_timing_tolerance(mut self, tolerance_ms: u64) -> Self {
335 self.timing_tolerance_ms = Some(tolerance_ms);
336 self
337 }
338
339 pub fn with_divergences(mut self, ids: Vec<String>) -> Self {
340 self.divergence_ids = ids;
341 self
342 }
343
344 pub fn with_certification_profile(mut self, profile: CertificationProfile) -> Self {
345 self.certification_profile = Some(profile);
346 self
347 }
348
349 pub fn with_capability_ids(mut self, ids: Vec<String>) -> Self {
350 self.capability_ids = ids;
351 self
352 }
353}
354
355fn truncate_md(s: &str, max: usize) -> String {
356 if s.len() <= max {
357 s.replace('|', "\\|")
358 } else {
359 format!("{}... ({} bytes)", s[..max].replace('|', "\\|"), s.len())
360 }
361}
362
363fn port_substitution_regex() -> &'static regex::Regex {
365 static PORT_RE: std::sync::OnceLock<regex::Regex> = std::sync::OnceLock::new();
366 PORT_RE.get_or_init(|| regex::Regex::new(r":\d{4,5}").expect("valid port regex"))
367}
368
369pub fn normalize_for_comparison(value: &str, scenario_id: &str) -> String {
371 let mut result = value.to_string();
372
373 result = port_substitution_regex()
374 .replace_all(&result, ":PORT")
375 .into_owned();
376
377 if scenario_id.starts_with("cli.") {
378 for prefix in &["INFO:", "WARNING:", "DEBUG:", "Listen: "] {
379 result = result.replace(prefix, "");
380 }
381 }
382
383 result = result.replace("pproxy/", "");
384 result = result.replace("eggress/", "");
385
386 result.trim().to_string()
387}
388
389pub fn make_comparison(
391 dimension: &str,
392 pproxy_value: &str,
393 eggress_value: &str,
394) -> ComparisonResult {
395 let matched = pproxy_value == eggress_value;
396 let details = if matched {
397 None
398 } else {
399 Some(format!(
400 "pproxy: {}, eggress: {}",
401 truncate(pproxy_value, 200),
402 truncate(eggress_value, 200)
403 ))
404 };
405 ComparisonResult {
406 dimension: dimension.to_string(),
407 pproxy_value: pproxy_value.to_string(),
408 eggress_value: eggress_value.to_string(),
409 matched,
410 details,
411 }
412}
413
414fn truncate(s: &str, max_len: usize) -> String {
415 if s.len() <= max_len {
416 s.to_string()
417 } else {
418 format!("{}... ({} bytes total)", &s[..max_len], s.len())
419 }
420}
421
422fn detect_pproxy_version() -> Option<String> {
423 let python = match std::panic::catch_unwind(crate::differential::find_python_binary) {
424 Ok(p) => p,
425 Err(_) => return None,
426 };
427 Command::new(&python)
428 .args([
429 "-c",
430 "import pproxy; print(getattr(pproxy, '__version__', 'unknown'))",
431 ])
432 .stdout(std::process::Stdio::piped())
433 .stderr(std::process::Stdio::null())
434 .output()
435 .ok()
436 .and_then(|o| {
437 if o.status.success() {
438 String::from_utf8(o.stdout)
439 .ok()
440 .map(|s| s.trim().to_string())
441 } else {
442 None
443 }
444 })
445}
446
447fn detect_eggress_commit() -> Option<String> {
448 if let Ok(val) = std::env::var("EGRESS_COMMIT") {
449 if !val.is_empty() {
450 return Some(val);
451 }
452 }
453 Command::new("git")
454 .args(["rev-parse", "--short", "HEAD"])
455 .output()
456 .ok()
457 .and_then(|o| {
458 if o.status.success() {
459 String::from_utf8(o.stdout)
460 .ok()
461 .map(|s| s.trim().to_string())
462 } else {
463 None
464 }
465 })
466}
467
468fn detect_python_version() -> Option<String> {
469 Command::new("python3")
470 .arg("--version")
471 .output()
472 .ok()
473 .and_then(|o| {
474 if o.status.success() {
475 String::from_utf8(o.stdout)
476 .ok()
477 .map(|s| s.trim().to_string())
478 } else {
479 None
480 }
481 })
482}
483
484fn detect_rust_version() -> String {
485 Command::new("rustc")
486 .arg("--version")
487 .output()
488 .ok()
489 .and_then(|o| {
490 if o.status.success() {
491 String::from_utf8(o.stdout)
492 .ok()
493 .map(|s| s.trim().to_string())
494 } else {
495 None
496 }
497 })
498 .unwrap_or_else(|| "unknown".to_string())
499}
500
501#[cfg(test)]
502mod tests {
503 use super::*;
504
505 #[test]
506 fn report_json_roundtrip() {
507 let mut report = OracleReport::new();
508 report.add_scenario(
509 ScenarioResult::new("test", ScenarioCategory::CliDefaults, "test scenario")
510 .with_status(ScenarioStatus::Pass)
511 .with_elapsed(Duration::from_millis(100)),
512 );
513 report.set_elapsed(Duration::from_secs(5));
514
515 let json = report.to_json();
516 let parsed: OracleReport = serde_json::from_str(&json).unwrap();
517 assert_eq!(parsed.scenarios.len(), 1);
518 assert_eq!(parsed.summary.total, 1);
519 assert_eq!(parsed.summary.passed, 1);
520 assert_eq!(parsed.elapsed_ms, 5000);
521 }
522
523 #[test]
524 fn make_comparison_match() {
525 let comp = make_comparison("payload", "hello", "hello");
526 assert!(comp.matched);
527 assert!(comp.details.is_none());
528 }
529
530 #[test]
531 fn make_comparison_mismatch() {
532 let comp = make_comparison("payload", "hello", "world");
533 assert!(!comp.matched);
534 assert!(comp.details.is_some());
535 }
536
537 #[test]
538 fn scenario_category_serde() {
539 let cat = ScenarioCategory::CliDefaults;
540 let json = serde_json::to_string(&cat).unwrap();
541 assert_eq!(json, "\"cli_defaults\"");
542 }
543
544 #[test]
545 fn markdown_report_generation() {
546 let mut report = OracleReport::new();
547 report.add_scenario(
548 ScenarioResult::new("test.pass", ScenarioCategory::CliDefaults, "passing test")
549 .with_status(ScenarioStatus::Pass)
550 .with_comparisons(vec![make_comparison("payload", "hello", "hello")]),
551 );
552 report.add_scenario(
553 ScenarioResult::new("test.fail", ScenarioCategory::HttpSocksTcp, "failing test")
554 .with_status(ScenarioStatus::Fail)
555 .with_error("mismatch".to_string()),
556 );
557
558 let md = report.to_markdown();
559 assert!(md.contains("# Oracle Differential Report"));
560 assert!(md.contains("test.pass"));
561 assert!(md.contains("test.fail"));
562 assert!(md.contains("✅"));
563 assert!(md.contains("❌"));
564 }
565
566 #[test]
567 fn manifest_consistency_check() {
568 let mut report = OracleReport::new();
569 report.add_scenario(
570 ScenarioResult::new("test", ScenarioCategory::CliDefaults, "test")
571 .with_capability_ids(vec!["valid.cap".to_string(), "unknown.cap".to_string()]),
572 );
573
574 let warnings = report.check_manifest_consistency(&["valid.cap"]);
575 assert_eq!(warnings.len(), 1);
576 assert!(warnings[0].contains("unknown.cap"));
577 }
578
579 #[test]
580 fn manifest_consistency_no_warnings_for_valid() {
581 let mut report = OracleReport::new();
582 report.add_scenario(
583 ScenarioResult::new("test", ScenarioCategory::CliDefaults, "test")
584 .with_capability_ids(vec!["cap.a".to_string(), "cap.b".to_string()]),
585 );
586
587 let warnings = report.check_manifest_consistency(&["cap.a", "cap.b"]);
588 assert!(warnings.is_empty());
589 }
590
591 #[test]
592 fn tier_filtering() {
593 let mut report = OracleReport::new();
594 report.add_scenario(
595 ScenarioResult::new("fast", ScenarioCategory::CliDefaults, "fast")
596 .with_certification_profile(CertificationProfile::Differential),
597 );
598 report.add_scenario(
599 ScenarioResult::new("core", ScenarioCategory::HttpSocksTcp, "core")
600 .with_certification_profile(CertificationProfile::Differential),
601 );
602 report.add_scenario(ScenarioResult::new(
603 "no_profile",
604 ScenarioCategory::Chains,
605 "no profile",
606 ));
607
608 let differential = report.scenarios_for_profile(CertificationProfile::Differential);
609 assert_eq!(differential.len(), 2);
610
611 let platform = report.scenarios_for_profile(CertificationProfile::Platform);
612 assert!(platform.is_empty());
613 }
614
615 #[test]
616 fn scenario_result_builder() {
617 let result = ScenarioResult::new("id", ScenarioCategory::Chains, "desc")
618 .with_status(ScenarioStatus::Pass)
619 .with_elapsed(Duration::from_secs(1))
620 .with_comparisons(vec![make_comparison("dim", "a", "a")])
621 .with_divergences(vec!["div1".to_string()])
622 .with_certification_profile(CertificationProfile::Differential)
623 .with_capability_ids(vec!["cap1".to_string()]);
624
625 assert_eq!(result.status, ScenarioStatus::Pass);
626 assert_eq!(result.elapsed_ms, 1000);
627 assert_eq!(result.divergence_ids, vec!["div1"]);
628 assert_eq!(
629 result.certification_profile,
630 Some(CertificationProfile::Differential)
631 );
632 assert_eq!(result.capability_ids, vec!["cap1"]);
633 }
634
635 #[test]
636 fn scenario_result_builder_skip_reason_sets_status() {
637 let result = ScenarioResult::new("id", ScenarioCategory::CliDefaults, "desc")
638 .with_status(ScenarioStatus::Pass)
639 .with_skip_reason("missing deps".to_string());
640 assert_eq!(result.status, ScenarioStatus::Skipped);
641 }
642
643 #[test]
644 fn certification_profile_serde() {
645 let profile = CertificationProfile::Differential;
646 let json = serde_json::to_string(&profile).unwrap();
647 assert_eq!(json, "\"differential\"");
648 let parsed: CertificationProfile = serde_json::from_str(&json).unwrap();
649 assert_eq!(parsed, profile);
650 }
651
652 #[test]
653 fn markdown_includes_divergences() {
654 let mut report = OracleReport::new();
655 report.add_scenario(
656 ScenarioResult::new("div_test", ScenarioCategory::Udp, "udp test")
657 .with_status(ScenarioStatus::Pass)
658 .with_divergences(vec!["div.timing".to_string(), "div.payload".to_string()]),
659 );
660
661 let md = report.to_markdown();
662 assert!(md.contains("**Divergences:** div.timing, div.payload"));
663 }
664
665 #[test]
666 fn markdown_includes_skip_reason() {
667 let mut report = OracleReport::new();
668 report.add_scenario(
669 ScenarioResult::new("skip_test", ScenarioCategory::CliDefaults, "skipped")
670 .with_skip_reason("needs root".to_string()),
671 );
672
673 let md = report.to_markdown();
674 assert!(md.contains("**Skip reason:** needs root"));
675 assert!(md.contains("⏭️"));
676 }
677
678 #[test]
679 fn markdown_truncates_long_values() {
680 let mut report = OracleReport::new();
681 let long_value = "x".repeat(200);
682 report.add_scenario(
683 ScenarioResult::new("long", ScenarioCategory::HttpSocksTcp, "long values")
684 .with_status(ScenarioStatus::Fail)
685 .with_comparisons(vec![make_comparison("dim", &long_value, "short")]),
686 );
687
688 let md = report.to_markdown();
689 assert!(md.contains("... (200 bytes)"));
690 }
691
692 #[test]
693 fn json_roundtrip_preserves_new_fields() {
694 let mut report = OracleReport::new();
695 report.add_scenario(
696 ScenarioResult::new("full", ScenarioCategory::Chains, "full fields")
697 .with_status(ScenarioStatus::Pass)
698 .with_timing_tolerance(50)
699 .with_divergences(vec!["d1".to_string()])
700 .with_certification_profile(CertificationProfile::Differential)
701 .with_capability_ids(vec!["cap.x".to_string()]),
702 );
703
704 let json = report.to_json();
705 let parsed: OracleReport = serde_json::from_str(&json).unwrap();
706 let s = &parsed.scenarios[0];
707 assert_eq!(s.timing_tolerance_ms, Some(50));
708 assert_eq!(s.divergence_ids, vec!["d1"]);
709 assert_eq!(
710 s.certification_profile,
711 Some(CertificationProfile::Differential)
712 );
713 assert_eq!(s.capability_ids, vec!["cap.x"]);
714 }
715}