Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/runners/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ pub mod mutmut;
pub mod mypy;
pub mod npm_audit;
pub mod pip_audit;
pub(super) mod python_venv;
pub mod playwright;
pub mod proptest;
pub mod pytest;
Expand Down
56 changes: 48 additions & 8 deletions src/runners/mypy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ use crate::error::Result;
use crate::plugin::{Layer, TestRunner};
use crate::process::{OsProcessRunner, SubprocessRunner};
use crate::report::{Finding, LayerMetrics, LayerResult, LayerStatus, Severity};
use crate::runners::python_venv::venv_tool;
use std::path::Path;
use std::sync::Arc;
use std::time::Instant;
Expand All @@ -31,21 +32,15 @@ impl TestRunner for MypyRunner {
fn is_available(&self, project: &ProjectInfo) -> bool {
if project.language != crate::detect::Language::Python { return false; }
let root = Path::new(&project.root);
let local = root.join(".venv").join("bin").join("mypy");
if local.exists() { return true; }
if venv_tool(root, "mypy").is_some() { return true; }
self.proc.is_available("mypy", &["--version"])
}

fn run(&self, project: &ProjectInfo) -> Result<LayerResult> {
let start = Instant::now();
let root = Path::new(&project.root);

let local = root.join(".venv").join("bin").join("mypy");
let mypy_cmd = if local.exists() {
local.to_string_lossy().to_string()
} else {
"mypy".to_string()
};
let mypy_cmd = venv_tool(root, "mypy").unwrap_or_else(|| "mypy".to_string());

match self.proc.run(&mypy_cmd, &[".", "--ignore-missing-imports", "--no-error-summary"], root) {
Ok(out) => {
Expand Down Expand Up @@ -177,6 +172,51 @@ mod tests {
assert!(!r.is_available(&i));
}

#[test]
fn available_via_windows_scripts_exe() {
let dir = tempfile::tempdir().unwrap();
let scripts = dir.path().join(".venv").join("Scripts");
std::fs::create_dir_all(&scripts).unwrap();
std::fs::write(scripts.join("mypy.exe"), b"").unwrap();
let info = ProjectInfo {
language: Language::Python,
root: dir.path().to_string_lossy().to_string(),
has_tests: true,
package_name: None,
frameworks: Default::default(),
workspace_root: None,
};
let r = MypyRunner { proc: Arc::new(MockProcessRunner::unavailable()) };
assert!(r.is_available(&info), "mypy.exe in .venv/Scripts must make runner available");
}

#[test]
fn run_uses_windows_venv_mypy_exe() {
let dir = tempfile::tempdir().unwrap();
let scripts = dir.path().join(".venv").join("Scripts");
std::fs::create_dir_all(&scripts).unwrap();
std::fs::write(scripts.join("mypy.exe"), b"").unwrap();
let info = ProjectInfo {
language: Language::Python,
root: dir.path().to_string_lossy().to_string(),
has_tests: true,
package_name: None,
frameworks: Default::default(),
workspace_root: None,
};

struct CapturingProc;
impl SubprocessRunner for CapturingProc {
fn run(&self, cmd: &str, _: &[&str], _: &Path) -> std::io::Result<crate::process::ProcessOutput> {
assert!(cmd.contains("Scripts") && cmd.ends_with("mypy.exe"),
"run() must use .venv/Scripts/mypy.exe on Windows layout, got: {cmd}");
Ok(crate::process::ProcessOutput { stdout: "Success: no issues found in 1 source file".to_string(), stderr: String::new(), success: true })
}
}
let r = MypyRunner { proc: Arc::new(CapturingProc) };
r.run(&info).unwrap();
}

#[test]
fn available_when_mypy_installed() {
let r = MypyRunner { proc: Arc::new(MockProcessRunner::passing("mypy 1.8.0")) };
Expand Down
56 changes: 48 additions & 8 deletions src/runners/pip_audit.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ use crate::error::Result;
use crate::plugin::{Layer, TestRunner};
use crate::process::{OsProcessRunner, SubprocessRunner};
use crate::report::{Finding, LayerMetrics, LayerResult, LayerStatus, Severity};
use crate::runners::python_venv::venv_tool;
use std::path::Path;
use std::sync::Arc;
use std::time::Instant;
Expand All @@ -30,21 +31,15 @@ impl TestRunner for PipAuditRunner {
fn is_available(&self, project: &ProjectInfo) -> bool {
if project.language != crate::detect::Language::Python { return false; }
let root = Path::new(&project.root);
let local = root.join(".venv").join("bin").join("pip-audit");
if local.exists() { return true; }
if venv_tool(root, "pip-audit").is_some() { return true; }
self.proc.is_available("pip-audit", &["--version"])
}

fn run(&self, project: &ProjectInfo) -> Result<LayerResult> {
let start = Instant::now();
let root = Path::new(&project.root);

let local = root.join(".venv").join("bin").join("pip-audit");
let cmd = if local.exists() {
local.to_string_lossy().to_string()
} else {
"pip-audit".to_string()
};
let cmd = venv_tool(root, "pip-audit").unwrap_or_else(|| "pip-audit".to_string());

match self.proc.run(&cmd, &["--format=json", "--progress-spinner=off"], root) {
Ok(out) => {
Expand Down Expand Up @@ -172,6 +167,51 @@ mod tests {
assert!(!r.is_available(&i));
}

#[test]
fn available_via_windows_scripts_exe() {
let dir = tempfile::tempdir().unwrap();
let scripts = dir.path().join(".venv").join("Scripts");
std::fs::create_dir_all(&scripts).unwrap();
std::fs::write(scripts.join("pip-audit.exe"), b"").unwrap();
let info = ProjectInfo {
language: Language::Python,
root: dir.path().to_string_lossy().to_string(),
has_tests: true,
package_name: None,
frameworks: Default::default(),
workspace_root: None,
};
let r = PipAuditRunner { proc: Arc::new(MockProcessRunner::unavailable()) };
assert!(r.is_available(&info), "pip-audit.exe in .venv/Scripts must make runner available");
}

#[test]
fn run_uses_windows_venv_pip_audit_exe() {
let dir = tempfile::tempdir().unwrap();
let scripts = dir.path().join(".venv").join("Scripts");
std::fs::create_dir_all(&scripts).unwrap();
std::fs::write(scripts.join("pip-audit.exe"), b"").unwrap();
let info = ProjectInfo {
language: Language::Python,
root: dir.path().to_string_lossy().to_string(),
has_tests: true,
package_name: None,
frameworks: Default::default(),
workspace_root: None,
};

struct CapturingProc;
impl SubprocessRunner for CapturingProc {
fn run(&self, cmd: &str, _: &[&str], _: &Path) -> std::io::Result<crate::process::ProcessOutput> {
assert!(cmd.contains("Scripts") && cmd.ends_with("pip-audit.exe"),
"run() must use .venv/Scripts/pip-audit.exe on Windows layout, got: {cmd}");
Ok(crate::process::ProcessOutput { stdout: r#"{"dependencies":[]}"#.to_string(), stderr: String::new(), success: true })
}
}
let r = PipAuditRunner { proc: Arc::new(CapturingProc) };
r.run(&info).unwrap();
}

#[test]
fn available_when_pip_audit_installed() {
let r = PipAuditRunner { proc: Arc::new(MockProcessRunner::passing("pip-audit 2.4.0")) };
Expand Down
86 changes: 72 additions & 14 deletions src/runners/pytest.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ use crate::error::Result;
use crate::plugin::{Layer, TestRunner};
use crate::process::{OsProcessRunner, SubprocessRunner};
use crate::report::{Finding, LayerMetrics, LayerResult, LayerStatus, Severity};
use crate::runners::python_venv::venv_tool;
use std::path::Path;
use std::sync::Arc;
use std::time::Instant;
Expand All @@ -27,25 +28,16 @@ impl TestRunner for PytestRunner {

fn is_available(&self, project: &ProjectInfo) -> bool {
if project.language != crate::detect::Language::Python { return false; }
// Check if pytest is installed in the project's environment
let root = Path::new(&project.root);
// Try project-local pytest first, then system
let local = root.join(".venv").join("bin").join("pytest");
if local.exists() { return true; }
if venv_tool(root, "pytest").is_some() { return true; }
self.proc.is_available("pytest", &["--version"])
}

fn run(&self, project: &ProjectInfo) -> Result<LayerResult> {
let start = Instant::now();
let root = Path::new(&project.root);

// Prefer .venv/bin/pytest if it exists
let local_pytest = root.join(".venv").join("bin").join("pytest");
let pytest_cmd = if local_pytest.exists() {
local_pytest.to_string_lossy().to_string()
} else {
"pytest".to_string()
};
let pytest_cmd = venv_tool(root, "pytest").unwrap_or_else(|| "pytest".to_string());

// Add --cov if pytest-cov is available in the environment
let has_cov = has_pytest_cov(root);
Expand Down Expand Up @@ -156,15 +148,21 @@ fn has_pytest_cov(root: &Path) -> bool {
if content.contains("pytest-cov") { return true; }
}
}
// Also check if pytest-cov is installed in venv
root.join(".venv").join("lib").exists()
&& root.join(".venv").join("bin").join("pytest").exists()
// Check POSIX venv: .venv/lib/<pythonX.Y>/site-packages/pytest_cov
if root.join(".venv").join("lib").exists()
&& venv_tool(root, "pytest").is_some()
&& std::fs::read_dir(root.join(".venv").join("lib"))
.ok()
.and_then(|mut d| d.next())
.and_then(|e| e.ok())
.map(|site| site.path().join("site-packages").join("pytest_cov").exists())
.unwrap_or(false)
{
return true;
}
// Check Windows venv: .venv/Lib/site-packages/pytest_cov
root.join(".venv").join("Lib").join("site-packages").join("pytest_cov").exists()
&& venv_tool(root, "pytest").is_some()
}

/// Parse `TOTAL ... 85%` line from pytest-cov output.
Expand Down Expand Up @@ -278,6 +276,51 @@ mod tests {
assert!(!r.is_available(&i));
}

#[test]
fn available_via_windows_scripts_exe() {
let dir = tempfile::tempdir().unwrap();
let scripts = dir.path().join(".venv").join("Scripts");
std::fs::create_dir_all(&scripts).unwrap();
std::fs::write(scripts.join("pytest.exe"), b"").unwrap();
let info = ProjectInfo {
language: Language::Python,
root: dir.path().to_string_lossy().to_string(),
has_tests: true,
package_name: None,
frameworks: Default::default(),
workspace_root: None,
};
let r = PytestRunner { proc: Arc::new(MockProcessRunner::unavailable()) };
assert!(r.is_available(&info), "pytest.exe in .venv/Scripts must make runner available");
}

#[test]
fn run_uses_windows_venv_pytest_exe() {
let dir = tempfile::tempdir().unwrap();
let scripts = dir.path().join(".venv").join("Scripts");
std::fs::create_dir_all(&scripts).unwrap();
std::fs::write(scripts.join("pytest.exe"), b"").unwrap();
let info = ProjectInfo {
language: Language::Python,
root: dir.path().to_string_lossy().to_string(),
has_tests: true,
package_name: None,
frameworks: Default::default(),
workspace_root: None,
};

struct CapturingProc;
impl SubprocessRunner for CapturingProc {
fn run(&self, cmd: &str, _: &[&str], _: &Path) -> std::io::Result<crate::process::ProcessOutput> {
assert!(cmd.contains("Scripts") && cmd.ends_with("pytest.exe"),
"run() must use .venv/Scripts/pytest.exe on Windows layout, got: {cmd}");
Ok(crate::process::ProcessOutput { stdout: "1 passed in 0.1s".to_string(), stderr: String::new(), success: true })
}
}
let r = PytestRunner { proc: Arc::new(CapturingProc) };
r.run(&info).unwrap();
}

#[test]
fn run_pass_parses_counts() {
let stdout = "5 passed in 0.45s";
Expand Down Expand Up @@ -335,6 +378,21 @@ mod tests {
assert!(pct.is_none());
}

#[test]
fn has_pytest_cov_detects_windows_lib_layout() {
let dir = tempfile::tempdir().unwrap();
// Create .venv/Scripts/pytest.exe (Windows venv executable)
let scripts = dir.path().join(".venv").join("Scripts");
std::fs::create_dir_all(&scripts).unwrap();
std::fs::write(scripts.join("pytest.exe"), b"").unwrap();
// Create .venv/Lib/site-packages/pytest_cov (Windows site-packages layout)
let site_pkg = dir.path().join(".venv").join("Lib").join("site-packages").join("pytest_cov");
std::fs::create_dir_all(&site_pkg).unwrap();

assert!(has_pytest_cov(dir.path()),
"has_pytest_cov must return true for Windows .venv/Lib/site-packages/pytest_cov layout");
}

proptest! {
#[test]
fn parse_pytest_never_panics(s in ".*") { let _ = parse_pytest_output(&s); }
Expand Down
17 changes: 17 additions & 0 deletions src/runners/python_venv.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
use std::path::Path;

/// Resolve a project-local Python venv executable for `name`.
/// Checks POSIX layout first (`.venv/bin/<name>`), then Windows layout
/// (`.venv/Scripts/<name>.exe`). Returns the path string if found, or
/// `None` to fall back to the system PATH.
pub fn venv_tool(root: &Path, name: &str) -> Option<String> {
let posix = root.join(".venv").join("bin").join(name);
if posix.exists() {
return Some(posix.to_string_lossy().into_owned());
}
let windows = root.join(".venv").join("Scripts").join(format!("{}.exe", name));
if windows.exists() {
return Some(windows.to_string_lossy().into_owned());
}
None
}
Loading