1use std::fmt;
2use std::fs;
3use std::path::Path;
4use std::process::Command;
5use std::str::FromStr;
6
7use serde::{Deserialize, Serialize};
8
9#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
14#[serde(rename_all = "snake_case")]
15pub enum TestStatus {
16 Passed,
18 Failed,
20 SkippedMissingGate,
22 SkippedMissingTool,
24 NotRun,
26}
27
28impl fmt::Display for TestStatus {
29 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
30 match self {
31 Self::Passed => write!(f, "passed"),
32 Self::Failed => write!(f, "failed"),
33 Self::SkippedMissingGate => write!(f, "skipped_missing_gate"),
34 Self::SkippedMissingTool => write!(f, "skipped_missing_tool"),
35 Self::NotRun => write!(f, "not_run"),
36 }
37 }
38}
39
40impl FromStr for TestStatus {
41 type Err = String;
42
43 fn from_str(s: &str) -> Result<Self, Self::Err> {
44 match s {
45 "passed" => Ok(Self::Passed),
46 "failed" => Ok(Self::Failed),
47 "skipped_missing_gate" => Ok(Self::SkippedMissingGate),
48 "skipped_missing_tool" => Ok(Self::SkippedMissingTool),
49 "not_run" => Ok(Self::NotRun),
50 other => Err(format!("unknown test status: \"{}\"", other)),
51 }
52 }
53}
54
55#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
56pub struct ParityReport {
57 pub eggress_commit: Option<String>,
58 pub pproxy_version: String,
59 pub os_platform: String,
60 pub rust_version: String,
61 pub python_version: String,
62 pub feature_gates: Vec<String>,
63 pub features: Vec<FeatureReport>,
64}
65
66#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
67pub struct FeatureReport {
68 pub feature_id: String,
69 pub category: String,
70 pub manifest_evidence: String,
71 pub tests_executed: Vec<String>,
72 pub status: TestStatus,
73 pub skip_reason: Option<String>,
74 pub observed_divergence: Option<String>,
75 pub suggested_evidence: String,
76}
77
78#[derive(Debug, Clone, Deserialize, PartialEq)]
79pub struct ManifestEntry {
80 pub feature_id: String,
81 pub category: String,
82 pub evidence: String,
83 #[serde(default)]
84 pub tests: Vec<String>,
85}
86
87impl Default for ParityReport {
88 fn default() -> Self {
89 Self::new()
90 }
91}
92
93impl ParityReport {
94 pub fn new() -> Self {
95 let rust_version = Command::new("rustc")
96 .arg("--version")
97 .output()
98 .ok()
99 .and_then(|o| {
100 if o.status.success() {
101 String::from_utf8(o.stdout)
102 .ok()
103 .map(|s| s.trim().to_string())
104 } else {
105 None
106 }
107 })
108 .unwrap_or_else(|| "unknown".to_string());
109
110 let python_version = detect_python_version().unwrap_or_else(|| "unknown".to_string());
111 let pproxy_version = detect_pproxy_version().unwrap_or_else(|| "unknown".to_string());
112
113 let os_platform = std::env::consts::OS.to_string();
114
115 Self {
116 eggress_commit: detect_eggress_commit(),
117 pproxy_version,
118 os_platform,
119 rust_version,
120 python_version,
121 feature_gates: Vec::new(),
122 features: Vec::new(),
123 }
124 }
125
126 pub fn add_feature(&mut self, feature: FeatureReport) {
127 self.features.push(feature);
128 }
129
130 pub fn to_json(&self) -> String {
131 serde_json::to_string_pretty(self).expect("serialization should not fail")
132 }
133
134 pub fn to_markdown(&self) -> String {
135 let mut md = String::new();
136
137 md.push_str("# Eggress Parity Report\n\n");
138
139 md.push_str("## Environment\n\n");
140 md.push_str("| Field | Value |\n");
141 md.push_str("|-------|-------|\n");
142 md.push_str(&format!(
143 "| eggress commit | {} |\n",
144 self.eggress_commit.as_deref().unwrap_or("n/a")
145 ));
146 md.push_str(&format!("| pproxy version | {} |\n", self.pproxy_version));
147 md.push_str(&format!("| OS platform | {} |\n", self.os_platform));
148 md.push_str(&format!("| rust version | {} |\n", self.rust_version));
149 md.push_str(&format!("| python version | {} |\n", self.python_version));
150
151 if !self.feature_gates.is_empty() {
152 md.push_str("\n## Feature Gates\n\n");
153 for gate in &self.feature_gates {
154 md.push_str(&format!("- `{}`\n", gate));
155 }
156 }
157
158 md.push_str("\n## Feature Results\n\n");
159 md.push_str("| Feature ID | Category | Status | Manifest Evidence | Tests Executed | Skip Reason | Divergence | Suggested Evidence |\n");
160 md.push_str("|-----------|----------|--------|-------------------|----------------|-------------|------------|--------------------|\n");
161
162 for f in &self.features {
163 let tests = f.tests_executed.join(", ");
164 let skip = f.skip_reason.as_deref().unwrap_or("-");
165 let divergence = f.observed_divergence.as_deref().unwrap_or("-");
166 md.push_str(&format!(
167 "| {} | {} | {} | {} | {} | {} | {} | {} |\n",
168 f.feature_id,
169 f.category,
170 f.status,
171 f.manifest_evidence,
172 tests,
173 skip,
174 divergence,
175 f.suggested_evidence,
176 ));
177 }
178
179 md
180 }
181
182 pub fn write_json(&self, path: &Path) -> std::io::Result<()> {
183 fs::write(path, self.to_json())
184 }
185
186 pub fn write_markdown(&self, path: &Path) -> std::io::Result<()> {
187 fs::write(path, self.to_markdown())
188 }
189}
190
191pub fn detect_eggress_commit() -> Option<String> {
192 if let Ok(val) = std::env::var("EGRESS_COMMIT") {
193 if !val.is_empty() {
194 return Some(val);
195 }
196 }
197
198 Command::new("git")
199 .args(["rev-parse", "--short", "HEAD"])
200 .output()
201 .ok()
202 .and_then(|o| {
203 if o.status.success() {
204 String::from_utf8(o.stdout)
205 .ok()
206 .map(|s| s.trim().to_string())
207 } else {
208 None
209 }
210 })
211}
212
213pub fn detect_python_version() -> Option<String> {
214 Command::new("python3")
215 .arg("--version")
216 .output()
217 .ok()
218 .and_then(|o| {
219 if o.status.success() {
220 String::from_utf8(o.stdout)
221 .ok()
222 .map(|s| s.trim().to_string())
223 } else {
224 None
225 }
226 })
227}
228
229pub fn detect_pproxy_version() -> Option<String> {
230 Command::new("python3")
231 .args(["-c", "import pproxy; print(pproxy.__version__)"])
232 .output()
233 .ok()
234 .and_then(|o| {
235 if o.status.success() {
236 String::from_utf8(o.stdout)
237 .ok()
238 .map(|s| s.trim().to_string())
239 } else {
240 None
241 }
242 })
243}
244
245#[derive(Debug, Deserialize)]
246struct ManifestFile {
247 #[serde(default)]
248 features: Vec<ManifestEntry>,
249}
250
251pub fn load_manifest(path: &Path) -> std::io::Result<Vec<ManifestEntry>> {
252 let content = fs::read_to_string(path)?;
253 let manifest: ManifestFile = toml::from_str(&content)
254 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
255 Ok(manifest.features)
256}
257
258pub fn manifest_entry_to_feature(entry: &ManifestEntry) -> FeatureReport {
259 FeatureReport {
260 feature_id: entry.feature_id.clone(),
261 category: entry.category.clone(),
262 manifest_evidence: entry.evidence.clone(),
263 tests_executed: entry.tests.clone(),
264 status: TestStatus::NotRun,
265 skip_reason: None,
266 observed_divergence: None,
267 suggested_evidence: entry.evidence.clone(),
268 }
269}
270
271#[cfg(test)]
272mod tests {
273 use super::*;
274 use tempfile::NamedTempFile;
275
276 fn sample_report() -> ParityReport {
277 ParityReport {
278 eggress_commit: Some("abc1234".to_string()),
279 pproxy_version: "1.1.4".to_string(),
280 os_platform: "macos".to_string(),
281 rust_version: "rustc 1.75.0 (82e1608df 2023-12-21)".to_string(),
282 python_version: "Python 3.12.0".to_string(),
283 feature_gates: vec!["EGRESS_REQUIRE_SHADOWSOCKS_INTEROP".to_string()],
284 features: vec![
285 FeatureReport {
286 feature_id: "socks5_tcp".to_string(),
287 category: "protocol".to_string(),
288 manifest_evidence: "SOCKS5 supported".to_string(),
289 tests_executed: vec!["test_socks5_connect".to_string()],
290 status: TestStatus::Passed,
291 skip_reason: None,
292 observed_divergence: None,
293 suggested_evidence: "SOCKS5 supported".to_string(),
294 },
295 FeatureReport {
296 feature_id: "shadowsocks_udp".to_string(),
297 category: "protocol".to_string(),
298 manifest_evidence: "SS UDP supported".to_string(),
299 tests_executed: vec![],
300 status: TestStatus::SkippedMissingTool,
301 skip_reason: Some("pproxy not available".to_string()),
302 observed_divergence: None,
303 suggested_evidence: "SS UDP supported".to_string(),
304 },
305 ],
306 }
307 }
308
309 #[test]
310 fn json_round_trip() {
311 let report = sample_report();
312 let json = report.to_json();
313 let parsed: ParityReport = serde_json::from_str(&json).unwrap();
314 assert_eq!(parsed, report);
315 }
316
317 #[test]
318 fn markdown_generation() {
319 let report = sample_report();
320 let md = report.to_markdown();
321 assert!(md.contains("# Eggress Parity Report"));
322 assert!(md.contains("| eggress commit | abc1234 |"));
323 assert!(md.contains("| pproxy version | 1.1.4 |"));
324 assert!(md.contains("| rust version | rustc 1.75.0 (82e1608df 2023-12-21) |"));
325 assert!(md.contains("| python version | Python 3.12.0 |"));
326 assert!(md.contains("| socks5_tcp | protocol | passed |"));
327 assert!(md.contains("| shadowsocks_udp | protocol | skipped_missing_tool |"));
328 assert!(md.contains("EGRESS_REQUIRE_SHADOWSOCKS_INTEROP"));
329 }
330
331 #[test]
332 fn manifest_loading() {
333 let toml_content = r#"
334[[features]]
335feature_id = "test_feature"
336category = "test_cat"
337evidence = "test evidence"
338tests = ["test_a", "test_b"]
339"#;
340 let file = NamedTempFile::new().unwrap();
341 fs::write(file.path(), toml_content).unwrap();
342
343 let entries = load_manifest(file.path()).unwrap();
344 assert_eq!(entries.len(), 1);
345 assert_eq!(entries[0].feature_id, "test_feature");
346 assert_eq!(entries[0].category, "test_cat");
347 assert_eq!(entries[0].evidence, "test evidence");
348 assert_eq!(entries[0].tests, vec!["test_a", "test_b"]);
349 }
350
351 #[test]
352 fn manifest_entry_to_feature_conversion() {
353 let entry = ManifestEntry {
354 feature_id: "conv_test".to_string(),
355 category: "convert".to_string(),
356 evidence: "some evidence".to_string(),
357 tests: vec!["t1".to_string()],
358 };
359 let feature = manifest_entry_to_feature(&entry);
360 assert_eq!(feature.feature_id, "conv_test");
361 assert_eq!(feature.status, TestStatus::NotRun);
362 assert_eq!(feature.manifest_evidence, "some evidence");
363 assert_eq!(feature.suggested_evidence, "some evidence");
364 assert!(feature.skip_reason.is_none());
365 }
366
367 #[test]
368 fn manifest_loading_empty_array() {
369 let toml_content = "# empty\n";
370 let file = NamedTempFile::new().unwrap();
371 fs::write(file.path(), toml_content).unwrap();
372
373 let entries = load_manifest(file.path()).unwrap();
374 assert!(entries.is_empty());
375 }
376
377 #[test]
378 fn markdown_with_no_features() {
379 let report = ParityReport {
380 eggress_commit: None,
381 pproxy_version: "unknown".to_string(),
382 os_platform: "linux".to_string(),
383 rust_version: "unknown".to_string(),
384 python_version: "unknown".to_string(),
385 feature_gates: vec![],
386 features: vec![],
387 };
388 let md = report.to_markdown();
389 assert!(md.contains("| eggress commit | n/a |"));
390 assert!(md.contains("| pproxy version | unknown |"));
391 assert!(md.contains("| Feature ID | Category | Status"));
392 }
393
394 #[test]
395 fn write_json_to_file() {
396 let report = sample_report();
397 let file = NamedTempFile::new().unwrap();
398 report.write_json(file.path()).unwrap();
399
400 let content = fs::read_to_string(file.path()).unwrap();
401 let parsed: ParityReport = serde_json::from_str(&content).unwrap();
402 assert_eq!(parsed, report);
403 }
404
405 #[test]
406 fn write_markdown_to_file() {
407 let report = sample_report();
408 let file = NamedTempFile::new().unwrap();
409 report.write_markdown(file.path()).unwrap();
410
411 let content = fs::read_to_string(file.path()).unwrap();
412 assert!(content.contains("# Eggress Parity Report"));
413 assert!(content.contains("socks5_tcp"));
414 }
415
416 #[test]
417 fn test_status_roundtrip() {
418 for variant in &[
419 TestStatus::Passed,
420 TestStatus::Failed,
421 TestStatus::SkippedMissingGate,
422 TestStatus::SkippedMissingTool,
423 TestStatus::NotRun,
424 ] {
425 let s = variant.to_string();
426 let parsed: TestStatus = s.parse().unwrap();
427 assert_eq!(parsed, *variant);
428 }
429 }
430
431 #[test]
432 fn test_status_invalid_parse() {
433 let result = "bogus".parse::<TestStatus>();
434 assert!(result.is_err());
435 }
436
437 #[test]
438 fn test_status_serde() {
439 let status = TestStatus::SkippedMissingGate;
440 let json = serde_json::to_string(&status).unwrap();
441 assert_eq!(json, "\"skipped_missing_gate\"");
442 let parsed: TestStatus = serde_json::from_str(&json).unwrap();
443 assert_eq!(parsed, status);
444 }
445}