use std::path::Path;
use std::time::Duration;
use klasp_core::{
CheckConfig, CheckResult, CheckSource, CheckSourceConfig, CheckSourceError, RepoState,
};
use super::shell::{run_with_timeout, ShellOutcome, DEFAULT_TIMEOUT_SECS};
const SOURCE_ID: &str = "pytest";
pub(super) const MAX_FINDINGS: usize = 50;
mod junit;
mod verdict;
use verdict::{outcome_to_verdict, sniff_version_warning};
#[derive(Default)]
pub struct PytestSource {
_private: (),
}
impl PytestSource {
pub const fn new() -> Self {
Self { _private: () }
}
}
impl CheckSource for PytestSource {
fn source_id(&self) -> &str {
SOURCE_ID
}
fn supports_config(&self, config: &CheckConfig) -> bool {
matches!(config.source, CheckSourceConfig::Pytest { .. })
}
fn run(
&self,
config: &CheckConfig,
state: &RepoState,
) -> Result<CheckResult, CheckSourceError> {
let (extra_args, config_path, junit_xml) = match &config.source {
CheckSourceConfig::Pytest {
extra_args,
config_path,
junit_xml,
} => (extra_args.clone(), config_path.clone(), *junit_xml),
other => {
return Err(CheckSourceError::Other(
format!("PytestSource cannot run {other:?}").into(),
));
}
};
let want_junit = junit_xml.unwrap_or(false);
let junit_tempfile = if want_junit {
Some(
tempfile::Builder::new()
.prefix("klasp-pytest-junit-")
.suffix(".xml")
.tempfile()
.map_err(|e| CheckSourceError::Spawn { source: e })?,
)
} else {
None
};
let junit_path = junit_tempfile.as_ref().map(|tf| tf.path().to_path_buf());
let command = build_command(
extra_args.as_deref(),
config_path.as_deref(),
junit_path.as_deref(),
);
let timeout = Duration::from_secs(config.timeout_secs.unwrap_or(DEFAULT_TIMEOUT_SECS));
let outcome = run_with_timeout(&command, &state.root, &state.base_ref, timeout)?;
let version_warning = sniff_version_warning(&state.root);
let junit_payload = junit_path
.as_deref()
.and_then(|p| std::fs::read_to_string(p).ok());
let v = outcome_to_verdict(
&config.name,
&outcome,
junit_payload.as_deref(),
version_warning.as_deref(),
);
Ok(CheckResult {
source_id: SOURCE_ID.to_string(),
check_name: config.name.clone(),
verdict: v,
raw_stdout: Some(outcome.stdout),
raw_stderr: Some(outcome.stderr),
})
}
}
fn build_command(
extra_args: Option<&str>,
config_path: Option<&Path>,
junit_path: Option<&Path>,
) -> String {
let mut parts: Vec<String> = vec!["pytest".into()];
if let Some(path) = config_path {
parts.push("-c".into());
parts.push(shell_quote(&path.to_string_lossy()));
}
if let Some(path) = junit_path {
parts.push(format!(
"--junitxml={}",
shell_quote(&path.to_string_lossy())
));
}
if let Some(extra) = extra_args {
let trimmed = extra.trim();
if !trimmed.is_empty() {
parts.push(trimmed.to_string());
}
}
parts.join(" ")
}
fn shell_quote(value: &str) -> String {
let escaped = value.replace('\'', "'\\''");
format!("'{escaped}'")
}
#[cfg(test)]
mod tests {
use std::path::Path;
use klasp_core::{CheckConfig, CheckSourceConfig};
use super::*;
fn pytest_check() -> CheckConfig {
CheckConfig {
name: "tests".into(),
triggers: vec![],
source: CheckSourceConfig::Pytest {
extra_args: None,
config_path: None,
junit_xml: None,
},
timeout_secs: None,
}
}
fn shell_check() -> CheckConfig {
CheckConfig {
name: "shell".into(),
triggers: vec![],
source: CheckSourceConfig::Shell {
command: "true".into(),
},
timeout_secs: None,
}
}
#[test]
fn supports_config_only_for_pytest() {
let source = PytestSource::new();
assert!(source.supports_config(&pytest_check()));
assert!(!source.supports_config(&shell_check()));
}
#[test]
fn build_command_minimal() {
assert_eq!(build_command(None, None, None), "pytest");
}
#[test]
fn build_command_with_config_path_and_junit() {
let cmd = build_command(
None,
Some(Path::new("pytest.ini")),
Some(Path::new(".klasp-pytest-junit.xml")),
);
assert!(cmd.starts_with("pytest -c 'pytest.ini'"));
assert!(cmd.contains("--junitxml='.klasp-pytest-junit.xml'"));
}
#[test]
fn build_command_with_extra_args_appended_last() {
let cmd = build_command(Some("-x -q tests/"), None, None);
assert_eq!(cmd, "pytest -x -q tests/");
}
#[test]
fn build_command_drops_blank_extra_args() {
assert_eq!(build_command(Some(" "), None, None), "pytest");
}
#[test]
fn shell_quote_handles_embedded_single_quotes() {
assert_eq!(shell_quote("a'b"), "'a'\\''b'");
}
}