/ ferris-proof-plugins / src / proptest_plugin.rs
proptest_plugin.rs
  1  use anyhow::{anyhow, Result};
  2  use ferris_proof_core::{
  3      plugins::{
  4          PerformanceMetrics, PluginMetadata, StructuredResult, ToolInfo, VerificationInput,
  5          VerificationOutput, VerificationPlugin, VersionRange,
  6      },
  7      types::*,
  8      verification::Target,
  9  };
 10  use serde_json::json;
 11  use std::path::PathBuf;
 12  use std::process::Command;
 13  use std::time::Duration;
 14  use tracing::{debug, info};
 15  
 16  pub struct ProptestPlugin {
 17      tool_path: PathBuf,
 18      initialized: bool,
 19  }
 20  
 21  impl ProptestPlugin {
 22      pub fn new() -> Self {
 23          Self {
 24              tool_path: PathBuf::from("proptest"), // Default to system PATH
 25              initialized: false,
 26          }
 27      }
 28  
 29      /// Check if the proptest crate is available in the current environment
 30      fn check_proptest_crate_availability(&self) -> bool {
 31          // Try to create a minimal Cargo.toml and check if proptest can be resolved
 32          let temp_dir = std::env::temp_dir().join("ferris_proof_proptest_check");
 33  
 34          if std::fs::create_dir_all(&temp_dir).is_err() {
 35              return false;
 36          }
 37  
 38          let cargo_toml_content = r#"
 39  [package]
 40  name = "proptest-check"
 41  version = "0.1.0"
 42  edition = "2021"
 43  
 44  [dependencies]
 45  proptest = "1.0"
 46  "#;
 47  
 48          let cargo_toml_path = temp_dir.join("Cargo.toml");
 49          if std::fs::write(&cargo_toml_path, cargo_toml_content).is_err() {
 50              return false;
 51          }
 52  
 53          // Try to run cargo check
 54          let check_result = Command::new("cargo")
 55              .current_dir(&temp_dir)
 56              .args(["check", "--quiet"])
 57              .output();
 58  
 59          // Clean up
 60          let _ = std::fs::remove_dir_all(&temp_dir);
 61  
 62          match check_result {
 63              Ok(output) => output.status.success(),
 64              Err(_) => false,
 65          }
 66      }
 67  
 68      /// Run proptest on a Rust target
 69      fn run_proptest(
 70          &self,
 71          target: &Target,
 72          config: &VerificationInput,
 73      ) -> Result<VerificationOutput> {
 74          let start_time = std::time::Instant::now();
 75  
 76          match target {
 77              Target::RustFile(path) => {
 78                  info!("Running proptest on Rust file: {:?}", path);
 79  
 80                  // Create a temporary directory for test execution
 81                  let temp_dir = config
 82                      .context
 83                      .cache_dir
 84                      .join(format!("proptest_{}", uuid::Uuid::new_v4()));
 85                  std::fs::create_dir_all(&temp_dir)?;
 86  
 87                  // Run cargo test with proptest
 88                  let mut cmd = Command::new("cargo");
 89                  cmd.current_dir(path.parent().unwrap_or_else(|| std::path::Path::new(".")));
 90                  cmd.args(["test", "--test", "prop_tests", "--", "--nocapture"]);
 91  
 92                  // Set environment variables for proptest
 93                  cmd.env(
 94                      "PROPTEST_CASES",
 95                      config
 96                          .config
 97                          .tool_config
 98                          .get("cases")
 99                          .and_then(|v| v.as_u64())
100                          .unwrap_or(1000)
101                          .to_string(),
102                  );
103                  cmd.env(
104                      "PROPTEST_MAX_SHRINK_ITERS",
105                      config
106                          .config
107                          .tool_config
108                          .get("max_shrink_iters")
109                          .and_then(|v| v.as_u64())
110                          .unwrap_or(10000)
111                          .to_string(),
112                  );
113  
114                  debug!("Executing command: {:?}", cmd);
115  
116                  let output = cmd.output()?;
117                  let execution_time = start_time.elapsed();
118  
119                  // Parse proptest output
120                  let structured_result = self.parse_proptest_output(
121                      &String::from_utf8_lossy(&output.stdout),
122                      &String::from_utf8_lossy(&output.stderr),
123                  )?;
124  
125                  // Create violations for any test failures
126                  let violations = if structured_result.status == Status::Error {
127                      vec![Violation {
128                          id: "PROPTEST_FAILURE".to_string(),
129                          severity: Severity::Error,
130                          location: Location {
131                              file: path.clone(),
132                              line: None,
133                              column: None,
134                              span: None,
135                          },
136                          message: "Property-based tests failed".to_string(),
137                          suggestion: Some(
138                              "Check the test output for specific failure details".to_string(),
139                          ),
140                          rule: "proptest_verification".to_string(),
141                      }]
142                  } else {
143                      Vec::new()
144                  };
145  
146                  Ok(VerificationOutput {
147                      status: structured_result.status,
148                      violations,
149                      artifacts: vec![], // TODO: Generate test reports
150                      tool_output: ToolOutput {
151                          tool: "proptest".to_string(),
152                          stdout: String::from_utf8_lossy(&output.stdout).to_string(),
153                          stderr: String::from_utf8_lossy(&output.stderr).to_string(),
154                          exit_code: output.status.code().unwrap_or(-1),
155                          execution_time,
156                      },
157                      metrics: VerificationMetrics {
158                          total_time: execution_time,
159                          cache_hit_rate: 0.0,
160                          memory_usage: 0, // TODO: Monitor memory usage
161                          test_cases_executed: structured_result
162                              .statistics
163                              .get("test_cases_executed")
164                              .and_then(|v| v.as_u64())
165                              .unwrap_or(0) as u32,
166                      },
167                  })
168              }
169              _ => Err(anyhow!("Proptest plugin only supports Rust files")),
170          }
171      }
172  
173      /// Parse proptest output into structured results
174      fn parse_proptest_output(&self, stdout: &str, stderr: &str) -> Result<StructuredResult> {
175          let output = stdout.to_string() + stderr;
176  
177          // Look for test failures
178          if output.contains("test FAILED") || output.contains("panic") {
179              return Ok(StructuredResult {
180                  status: Status::Error,
181                  violations: vec![],
182                  statistics: json!({
183                      "test_cases_executed": self.extract_test_cases(&output),
184                      "failures": self.extract_failures(&output),
185                      "successes": self.extract_successes(&output)
186                  }),
187                  performance: PerformanceMetrics {
188                      execution_time: Duration::from_millis(0), // Will be set by caller
189                      memory_usage: 0,
190                      cpu_usage: 0.0,
191                      cache_hits: 0,
192                  },
193              });
194          }
195  
196          // Look for successful completion
197          if output.contains("test result: ok") {
198              return Ok(StructuredResult {
199                  status: Status::Success,
200                  violations: vec![],
201                  statistics: json!({
202                      "test_cases_executed": self.extract_test_cases(&output),
203                      "failures": 0,
204                      "successes": self.extract_successes(&output)
205                  }),
206                  performance: PerformanceMetrics {
207                      execution_time: Duration::from_millis(0),
208                      memory_usage: 0,
209                      cpu_usage: 0.0,
210                      cache_hits: 0,
211                  },
212              });
213          }
214  
215          // Default to success if no failures detected
216          Ok(StructuredResult {
217              status: Status::Success,
218              violations: vec![],
219              statistics: json!({
220                  "test_cases_executed": 0,
221                  "failures": 0,
222                  "successes": 0
223              }),
224              performance: PerformanceMetrics {
225                  execution_time: Duration::from_millis(0),
226                  memory_usage: 0,
227                  cpu_usage: 0.0,
228                  cache_hits: 0,
229              },
230          })
231      }
232  
233      fn extract_test_cases(&self, output: &str) -> u64 {
234          // Look for patterns like "1030 tests run"
235          let regex = regex::Regex::new(r"(\d+)\s+(?:test|tests|case|cases)").unwrap();
236          if let Some(captures) = regex.captures(output) {
237              captures
238                  .get(1)
239                  .and_then(|m| m.as_str().parse().ok())
240                  .unwrap_or(0)
241          } else {
242              0
243          }
244      }
245  
246      fn extract_failures(&self, output: &str) -> u64 {
247          // Count occurrences of "FAILED" or "panicked"
248          let failed_count = output.matches("FAILED").count();
249          let panic_count = output.matches("panicked").count();
250          (failed_count + panic_count) as u64
251      }
252  
253      fn extract_successes(&self, output: &str) -> u64 {
254          // Look for "passed" or "ok" in test results
255          let passed_count = output.matches("passed").count();
256          let ok_count = output.matches("ok").count();
257          (passed_count + ok_count) as u64
258      }
259  }
260  
261  impl VerificationPlugin for ProptestPlugin {
262      fn name(&self) -> &str {
263          "proptest"
264      }
265  
266      fn version(&self) -> &str {
267          env!("CARGO_PKG_VERSION")
268      }
269  
270      fn supported_techniques(&self) -> Vec<Technique> {
271          vec![Technique::PropertyTests]
272      }
273  
274      fn supported_versions(&self) -> VersionRange {
275          VersionRange {
276              min: Some(semver::Version::new(0, 1, 0)),
277              max: Some(semver::Version::new(1, 0, 0)),
278              requires_exact: None,
279          }
280      }
281  
282      fn check_availability(&self) -> Result<ToolInfo> {
283          // Check if Rust and Cargo are available
284          let cargo_result = Command::new("cargo").args(["--version"]).output();
285  
286          let rustc_result = Command::new("rustc").args(["--version"]).output();
287  
288          match (cargo_result, rustc_result) {
289              (Ok(cargo_output), Ok(rustc_output))
290                  if cargo_output.status.success() && rustc_output.status.success() =>
291              {
292                  let cargo_version = String::from_utf8_lossy(&cargo_output.stdout);
293                  let rustc_version = String::from_utf8_lossy(&rustc_output.stdout);
294  
295                  // Extract version numbers
296                  let cargo_ver = cargo_version
297                      .split_whitespace()
298                      .nth(1)
299                      .unwrap_or("unknown")
300                      .to_string();
301  
302                  let rustc_ver = rustc_version
303                      .split_whitespace()
304                      .nth(1)
305                      .unwrap_or("unknown")
306                      .to_string();
307  
308                  // Check if proptest crate is available by trying to compile a simple test
309                  let proptest_available = self.check_proptest_crate_availability();
310  
311                  let version = format!("cargo {} / rustc {}", cargo_ver, rustc_ver);
312                  let mut capabilities = vec![
313                      "property_testing".to_string(),
314                      "test_generation".to_string(),
315                      "shrinking".to_string(),
316                      "rust_integration".to_string(),
317                  ];
318  
319                  if proptest_available {
320                      capabilities.push("proptest_crate".to_string());
321                  }
322  
323                  Ok(ToolInfo {
324                      name: "proptest".to_string(),
325                      version,
326                      path: PathBuf::from("cargo"),
327                      available: true,
328                      capabilities,
329                  })
330              }
331              (Ok(cargo_output), Ok(_)) if !cargo_output.status.success() => {
332                  Err(anyhow!("Cargo is not working properly"))
333              }
334              (Ok(_), Ok(rustc_output)) if !rustc_output.status.success() => {
335                  Err(anyhow!("Rustc is not working properly"))
336              }
337              (Ok(_), Ok(_)) => {
338                  // Both commands succeeded but we didn't handle this case above
339                  Err(anyhow!("Unexpected cargo/rustc status"))
340              }
341              (Err(cargo_err), _) => Err(anyhow!("Cargo not found: {}", cargo_err)),
342              (_, Err(rustc_err)) => Err(anyhow!("Rustc not found: {}", rustc_err)),
343          }
344      }
345  
346      fn verify(&self, input: VerificationInput) -> Result<VerificationOutput> {
347          if !self.initialized {
348              return Err(anyhow!("Proptest plugin not initialized"));
349          }
350  
351          self.run_proptest(&input.target, &input)
352      }
353  
354      fn parse_output(&self, raw_output: &str) -> Result<StructuredResult> {
355          self.parse_proptest_output(raw_output, "")
356      }
357  
358      fn metadata(&self) -> PluginMetadata {
359          PluginMetadata {
360              name: "proptest".to_string(),
361              version: self.version().to_string(),
362              description: "Property-based testing plugin using the proptest framework".to_string(),
363              author: "FerrisProof Team".to_string(),
364              license: "MIT".to_string(),
365              homepage: Some(
366                  "https://altsysrq.github.io/proptest-book/proptest/index.html".to_string(),
367              ),
368              techniques: vec![Technique::PropertyTests],
369              supported_platforms: vec![
370                  "linux".to_string(),
371                  "macos".to_string(),
372                  "windows".to_string(),
373              ],
374              dependencies: vec!["cargo".to_string(), "rustc".to_string()],
375          }
376      }
377  
378      fn initialize(&mut self, config: &serde_json::Value) -> Result<()> {
379          // Extract proptest configuration
380          if let Some(tool_config) = config.get("proptest") {
381              if let Some(path) = tool_config.get("path").and_then(|v| v.as_str()) {
382                  self.tool_path = PathBuf::from(path);
383              }
384          }
385  
386          // Verify tool availability
387          let tool_info = self.check_availability()?;
388          if !tool_info.available {
389              return Err(anyhow!("Proptest is not available: {}", tool_info.version));
390          }
391  
392          self.initialized = true;
393          info!(
394              "Proptest plugin initialized with tool: {:?}",
395              self.tool_path
396          );
397          Ok(())
398      }
399  
400      fn cleanup(&mut self) -> Result<()> {
401          self.initialized = false;
402          debug!("Proptest plugin cleaned up");
403          Ok(())
404      }
405  }
406  
407  impl Default for ProptestPlugin {
408      fn default() -> Self {
409          Self::new()
410      }
411  }
412  
413  #[cfg(test)]
414  mod tests {
415      use super::*;
416  
417      #[test]
418      fn test_plugin_metadata() {
419          let plugin = ProptestPlugin::new();
420          let metadata = plugin.metadata();
421  
422          assert_eq!(metadata.name, "proptest");
423          assert_eq!(metadata.techniques, vec![Technique::PropertyTests]);
424          assert!(metadata.supported_platforms.contains(&"linux".to_string()));
425      }
426  
427      #[test]
428      fn test_supported_techniques() {
429          let plugin = ProptestPlugin::new();
430          let techniques = plugin.supported_techniques();
431  
432          assert_eq!(techniques.len(), 1);
433          assert!(techniques.contains(&Technique::PropertyTests));
434      }
435  
436      #[test]
437      fn test_output_parsing() {
438          let plugin = ProptestPlugin::new();
439  
440          let success_output = "test result: ok. 1000 tests run.";
441          let result = plugin.parse_proptest_output(success_output, "").unwrap();
442          assert_eq!(result.status, Status::Success);
443  
444          let failure_output = "test FAILED: property should hold\n1 tests run.";
445          let result = plugin.parse_proptest_output(failure_output, "").unwrap();
446          assert_eq!(result.status, Status::Error);
447      }
448  
449      #[test]
450      fn test_statistics_extraction() {
451          let plugin = ProptestPlugin::new();
452  
453          let output = "test result: ok. 1030 tests run, 0 failed.";
454          assert_eq!(plugin.extract_test_cases(output), 1030);
455          assert_eq!(plugin.extract_failures(output), 0);
456          assert_eq!(plugin.extract_successes(output), 1); // from "ok"
457      }
458  }