Skip to main content

decy_verify/
diff_test.rs

1//! Differential testing against GCC semantics (S5).
2//!
3//! Compiles original C with gcc, compiles transpiled Rust with rustc,
4//! runs both binaries, and compares stdout + exit codes to prove
5//! behavioral equivalence.
6//!
7//! # Example
8//!
9//! ```no_run
10//! use decy_verify::diff_test::{diff_test, DiffTestConfig};
11//!
12//! let c_code = "int main() { return 0; }";
13//! let rust_code = "fn main() {}";
14//! let config = DiffTestConfig::default();
15//! let result = diff_test(c_code, rust_code, &config).unwrap();
16//! assert!(result.stdout_matches);
17//! assert!(result.exit_code_matches);
18//! ```
19
20use anyhow::{Context, Result};
21use std::path::{Path, PathBuf};
22use std::process::Command;
23use tempfile::TempDir;
24
25/// Output captured from running a compiled binary.
26#[derive(Debug, Clone, PartialEq, Eq)]
27pub struct ExecutionOutput {
28    /// Standard output
29    pub stdout: String,
30    /// Standard error
31    pub stderr: String,
32    /// Process exit code (0 = success)
33    pub exit_code: i32,
34}
35
36/// Result of a differential test comparing C and Rust execution.
37#[derive(Debug, Clone)]
38pub struct DiffTestResult {
39    /// Output from the compiled C binary
40    pub c_output: ExecutionOutput,
41    /// Output from the compiled Rust binary
42    pub rust_output: ExecutionOutput,
43    /// Whether stdout is identical
44    pub stdout_matches: bool,
45    /// Whether exit codes are identical
46    pub exit_code_matches: bool,
47    /// List of specific divergences found
48    pub divergences: Vec<String>,
49}
50
51impl DiffTestResult {
52    /// Returns true when both stdout and exit code match.
53    pub fn passed(&self) -> bool {
54        self.stdout_matches && self.exit_code_matches
55    }
56}
57
58/// Configuration for differential testing.
59#[derive(Debug, Clone)]
60pub struct DiffTestConfig {
61    /// Timeout in seconds for each binary execution
62    pub timeout_secs: u64,
63    /// Path to the gcc compiler
64    pub gcc_path: String,
65    /// Path to the rustc compiler
66    pub rustc_path: String,
67    /// Whether to also compare stderr output
68    pub compare_stderr: bool,
69}
70
71impl Default for DiffTestConfig {
72    fn default() -> Self {
73        Self {
74            timeout_secs: 5,
75            gcc_path: "gcc".to_string(),
76            rustc_path: "rustc".to_string(),
77            compare_stderr: false,
78        }
79    }
80}
81
82/// Compile C source code with gcc and return the temp directory + binary path.
83///
84/// The caller owns the returned `TempDir`; dropping it cleans up all files.
85pub fn compile_c(c_code: &str, config: &DiffTestConfig) -> Result<(TempDir, PathBuf)> {
86    let tmp = TempDir::new().context("Failed to create temp directory for C compilation")?;
87    let src = tmp.path().join("input.c");
88    let bin = tmp.path().join("c_binary");
89
90    std::fs::write(&src, c_code).context("Failed to write C source to temp file")?;
91
92    let output = Command::new(&config.gcc_path)
93        .arg("-o")
94        .arg(&bin)
95        .arg("-x")
96        .arg("c")
97        .arg("-std=c99")
98        .arg("-lm")
99        .arg(&src)
100        .output()
101        .with_context(|| format!("Failed to run gcc ({})", config.gcc_path))?;
102
103    if !output.status.success() {
104        let stderr = String::from_utf8_lossy(&output.stderr);
105        anyhow::bail!("gcc compilation failed:\n{}", stderr);
106    }
107
108    Ok((tmp, bin))
109}
110
111/// Compile Rust source code with rustc and return the temp directory + binary path.
112///
113/// The caller owns the returned `TempDir`; dropping it cleans up all files.
114pub fn compile_rust(rust_code: &str, config: &DiffTestConfig) -> Result<(TempDir, PathBuf)> {
115    let tmp = TempDir::new().context("Failed to create temp directory for Rust compilation")?;
116    let src = tmp.path().join("input.rs");
117    let bin = tmp.path().join("rust_binary");
118
119    std::fs::write(&src, rust_code).context("Failed to write Rust source to temp file")?;
120
121    let output = Command::new(&config.rustc_path)
122        .arg("--edition=2021")
123        .arg("-o")
124        .arg(&bin)
125        .arg(&src)
126        .output()
127        .with_context(|| format!("Failed to run rustc ({})", config.rustc_path))?;
128
129    if !output.status.success() {
130        let stderr = String::from_utf8_lossy(&output.stderr);
131        anyhow::bail!("rustc compilation failed:\n{}", stderr);
132    }
133
134    Ok((tmp, bin))
135}
136
137/// Run a compiled binary and capture its output.
138pub fn run_binary(binary: &Path) -> Result<ExecutionOutput> {
139    let output = Command::new(binary)
140        .output()
141        .with_context(|| format!("Failed to execute binary: {}", binary.display()))?;
142
143    Ok(ExecutionOutput {
144        stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
145        stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
146        exit_code: output.status.code().unwrap_or(-1),
147    })
148}
149
150/// Run a full differential test: compile C with gcc, compile Rust with rustc,
151/// execute both, and compare outputs.
152pub fn diff_test(c_code: &str, rust_code: &str, config: &DiffTestConfig) -> Result<DiffTestResult> {
153    // Compile both
154    let (_c_dir, c_bin) =
155        compile_c(c_code, config).context("C compilation failed during diff test")?;
156    let (_rs_dir, rs_bin) =
157        compile_rust(rust_code, config).context("Rust compilation failed during diff test")?;
158
159    // Run both
160    let c_output = run_binary(&c_bin).context("Failed to run C binary")?;
161    let rust_output = run_binary(&rs_bin).context("Failed to run Rust binary")?;
162
163    // Compare
164    let stdout_matches = c_output.stdout == rust_output.stdout;
165    let exit_code_matches = c_output.exit_code == rust_output.exit_code;
166
167    let mut divergences = Vec::new();
168
169    if !stdout_matches {
170        divergences.push(format!(
171            "stdout differs:\n  C:    {:?}\n  Rust: {:?}",
172            c_output.stdout, rust_output.stdout
173        ));
174    }
175
176    if !exit_code_matches {
177        divergences.push(format!(
178            "exit code differs: C={}, Rust={}",
179            c_output.exit_code, rust_output.exit_code
180        ));
181    }
182
183    if config.compare_stderr && c_output.stderr != rust_output.stderr {
184        divergences.push(format!(
185            "stderr differs:\n  C:    {:?}\n  Rust: {:?}",
186            c_output.stderr, rust_output.stderr
187        ));
188    }
189
190    Ok(DiffTestResult { c_output, rust_output, stdout_matches, exit_code_matches, divergences })
191}
192
193#[cfg(test)]
194mod tests {
195    use super::*;
196
197    // ========================================================================
198    // Compilation tests
199    // ========================================================================
200
201    #[test]
202    fn test_compile_c_valid() {
203        let config = DiffTestConfig::default();
204        let result = compile_c("int main() { return 0; }", &config);
205        assert!(result.is_ok(), "Valid C should compile: {:?}", result.err());
206        let (_dir, bin) = result.unwrap();
207        assert!(bin.exists(), "Binary should exist after compilation");
208    }
209
210    #[test]
211    fn test_compile_c_invalid() {
212        let config = DiffTestConfig::default();
213        let result = compile_c("int main( { }", &config);
214        assert!(result.is_err(), "Invalid C should fail compilation");
215        let err_msg = result.unwrap_err().to_string();
216        assert!(
217            err_msg.contains("gcc compilation failed"),
218            "Error should mention gcc: {}",
219            err_msg
220        );
221    }
222
223    #[test]
224    fn test_compile_c_bad_gcc_path() {
225        let config =
226            DiffTestConfig { gcc_path: "/nonexistent/gcc".to_string(), ..Default::default() };
227        let result = compile_c("int main() { return 0; }", &config);
228        assert!(result.is_err(), "Bad gcc path should error");
229    }
230
231    #[test]
232    fn test_compile_rust_valid() {
233        let config = DiffTestConfig::default();
234        let result = compile_rust("fn main() {}", &config);
235        assert!(result.is_ok(), "Valid Rust should compile: {:?}", result.err());
236        let (_dir, bin) = result.unwrap();
237        assert!(bin.exists(), "Binary should exist after compilation");
238    }
239
240    #[test]
241    fn test_compile_rust_invalid() {
242        let config = DiffTestConfig::default();
243        let result = compile_rust("fn main( {}", &config);
244        assert!(result.is_err(), "Invalid Rust should fail compilation");
245        let err_msg = result.unwrap_err().to_string();
246        assert!(
247            err_msg.contains("rustc compilation failed"),
248            "Error should mention rustc: {}",
249            err_msg
250        );
251    }
252
253    #[test]
254    fn test_compile_rust_bad_rustc_path() {
255        let config =
256            DiffTestConfig { rustc_path: "/nonexistent/rustc".to_string(), ..Default::default() };
257        let result = compile_rust("fn main() {}", &config);
258        assert!(result.is_err(), "Bad rustc path should error");
259    }
260
261    // ========================================================================
262    // S5 Prediction tests: behavioral equivalence
263    // ========================================================================
264
265    #[test]
266    fn test_s5_p1_integer_arithmetic() {
267        let c_code = r#"
268#include <stdio.h>
269int add(int a, int b) { return a + b; }
270int main() {
271    printf("%d\n", add(2, 3));
272    return 0;
273}
274"#;
275        let rust_code = r#"
276fn add(a: i32, b: i32) -> i32 { a + b }
277fn main() {
278    println!("{}", add(2, 3));
279}
280"#;
281        let config = DiffTestConfig::default();
282        let result = diff_test(c_code, rust_code, &config).expect("diff_test should succeed");
283        assert!(
284            result.stdout_matches,
285            "Integer arithmetic stdout should match: {:?}",
286            result.divergences
287        );
288        assert!(result.exit_code_matches, "Exit codes should match: {:?}", result.divergences);
289        assert!(result.passed());
290    }
291
292    #[test]
293    fn test_s5_p2_array_indexing() {
294        let c_code = r#"
295#include <stdio.h>
296int main() {
297    int arr[] = {10, 20, 30};
298    printf("%d\n", arr[1]);
299    return 0;
300}
301"#;
302        let rust_code = r#"
303fn main() {
304    let arr = [10, 20, 30];
305    println!("{}", arr[1]);
306}
307"#;
308        let config = DiffTestConfig::default();
309        let result = diff_test(c_code, rust_code, &config).expect("diff_test should succeed");
310        assert!(
311            result.stdout_matches,
312            "Array indexing stdout should match: {:?}",
313            result.divergences
314        );
315        assert!(result.passed());
316    }
317
318    #[test]
319    fn test_s5_p3_string_output() {
320        let c_code = r#"
321#include <stdio.h>
322int main() {
323    printf("hello world\n");
324    return 0;
325}
326"#;
327        let rust_code = r#"
328fn main() {
329    println!("hello world");
330}
331"#;
332        let config = DiffTestConfig::default();
333        let result = diff_test(c_code, rust_code, &config).expect("diff_test should succeed");
334        assert!(result.stdout_matches, "String output should match: {:?}", result.divergences);
335        assert!(result.passed());
336    }
337
338    // ========================================================================
339    // Edge cases
340    // ========================================================================
341
342    #[test]
343    fn test_empty_stdout() {
344        let c_code = "int main() { return 0; }";
345        let rust_code = "fn main() {}";
346        let config = DiffTestConfig::default();
347        let result = diff_test(c_code, rust_code, &config).expect("diff_test should succeed");
348        assert!(result.stdout_matches, "Empty stdout should match");
349        assert_eq!(result.c_output.stdout, "");
350        assert_eq!(result.rust_output.stdout, "");
351        assert!(result.passed());
352    }
353
354    #[test]
355    fn test_multiline_output() {
356        let c_code = r#"
357#include <stdio.h>
358int main() {
359    printf("line1\n");
360    printf("line2\n");
361    printf("line3\n");
362    return 0;
363}
364"#;
365        let rust_code = r#"
366fn main() {
367    println!("line1");
368    println!("line2");
369    println!("line3");
370}
371"#;
372        let config = DiffTestConfig::default();
373        let result = diff_test(c_code, rust_code, &config).expect("diff_test should succeed");
374        assert!(result.stdout_matches, "Multiline output should match: {:?}", result.divergences);
375        assert!(result.passed());
376    }
377
378    #[test]
379    fn test_nonzero_exit_code() {
380        let c_code = "int main() { return 42; }";
381        let rust_code = "fn main() { std::process::exit(42); }";
382        let config = DiffTestConfig::default();
383        let result = diff_test(c_code, rust_code, &config).expect("diff_test should succeed");
384        assert_eq!(result.c_output.exit_code, 42);
385        assert_eq!(result.rust_output.exit_code, 42);
386        assert!(result.exit_code_matches);
387        assert!(result.passed());
388    }
389
390    #[test]
391    fn test_stdout_divergence_detected() {
392        let c_code = r#"
393#include <stdio.h>
394int main() { printf("from C\n"); return 0; }
395"#;
396        let rust_code = r#"
397fn main() { println!("from Rust"); }
398"#;
399        let config = DiffTestConfig::default();
400        let result = diff_test(c_code, rust_code, &config).expect("diff_test should succeed");
401        assert!(!result.stdout_matches, "Different outputs should diverge");
402        assert!(!result.divergences.is_empty());
403        assert!(!result.passed());
404    }
405
406    #[test]
407    fn test_exit_code_divergence_detected() {
408        let c_code = "int main() { return 0; }";
409        let rust_code = "fn main() { std::process::exit(1); }";
410        let config = DiffTestConfig::default();
411        let result = diff_test(c_code, rust_code, &config).expect("diff_test should succeed");
412        assert!(!result.exit_code_matches, "Different exit codes should diverge");
413        assert!(!result.passed());
414    }
415
416    // ========================================================================
417    // Config tests
418    // ========================================================================
419
420    #[test]
421    fn test_default_config() {
422        let config = DiffTestConfig::default();
423        assert_eq!(config.timeout_secs, 5);
424        assert_eq!(config.gcc_path, "gcc");
425        assert_eq!(config.rustc_path, "rustc");
426        assert!(!config.compare_stderr);
427    }
428
429    #[test]
430    fn test_compare_stderr_flag() {
431        let c_code = "int main() { return 0; }";
432        let rust_code = "fn main() {}";
433        let config = DiffTestConfig { compare_stderr: true, ..Default::default() };
434        let result = diff_test(c_code, rust_code, &config).expect("diff_test should succeed");
435        assert!(result.passed());
436    }
437
438    #[test]
439    fn test_diff_test_result_passed() {
440        let result = DiffTestResult {
441            c_output: ExecutionOutput {
442                stdout: "ok\n".to_string(),
443                stderr: String::new(),
444                exit_code: 0,
445            },
446            rust_output: ExecutionOutput {
447                stdout: "ok\n".to_string(),
448                stderr: String::new(),
449                exit_code: 0,
450            },
451            stdout_matches: true,
452            exit_code_matches: true,
453            divergences: vec![],
454        };
455        assert!(result.passed());
456    }
457
458    #[test]
459    fn test_diff_test_result_failed() {
460        let result = DiffTestResult {
461            c_output: ExecutionOutput {
462                stdout: "a\n".to_string(),
463                stderr: String::new(),
464                exit_code: 0,
465            },
466            rust_output: ExecutionOutput {
467                stdout: "b\n".to_string(),
468                stderr: String::new(),
469                exit_code: 0,
470            },
471            stdout_matches: false,
472            exit_code_matches: true,
473            divergences: vec!["stdout differs".to_string()],
474        };
475        assert!(!result.passed());
476    }
477}