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 = "cargo";
pub(super) const MAX_FINDINGS: usize = 50;
const ALLOWED_SUBCOMMANDS: &[&str] = &["check", "clippy", "test", "build"];
mod messages;
mod verdict;
use verdict::{fail_with_optional_warning, outcome_to_verdict, sniff_version_warning};
#[derive(Default)]
pub struct CargoSource {
_private: (),
}
impl CargoSource {
pub const fn new() -> Self {
Self { _private: () }
}
}
impl CheckSource for CargoSource {
fn source_id(&self) -> &str {
SOURCE_ID
}
fn supports_config(&self, config: &CheckConfig) -> bool {
matches!(config.source, CheckSourceConfig::Cargo { .. })
}
fn run(
&self,
config: &CheckConfig,
state: &RepoState,
) -> Result<CheckResult, CheckSourceError> {
let (subcommand, extra_args, package) = match &config.source {
CheckSourceConfig::Cargo {
subcommand,
extra_args,
package,
} => (subcommand.clone(), extra_args.clone(), package.clone()),
other => {
return Err(CheckSourceError::Other(
format!("CargoSource cannot run {other:?}").into(),
));
}
};
let version_warning = sniff_version_warning(&state.root);
if !ALLOWED_SUBCOMMANDS.contains(&subcommand.as_str()) {
let detail = format!(
"cargo recipe `{}`: unknown subcommand `{subcommand}` \
(expected one of: {})",
config.name,
ALLOWED_SUBCOMMANDS.join(", ")
);
let v = fail_with_optional_warning(&config.name, detail, version_warning.as_deref());
return Ok(CheckResult {
source_id: SOURCE_ID.to_string(),
check_name: config.name.clone(),
verdict: v,
raw_stdout: None,
raw_stderr: None,
});
}
let command = build_command(&subcommand, package.as_deref(), extra_args.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 v = outcome_to_verdict(
&config.name,
&subcommand,
&outcome,
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(subcommand: &str, package: Option<&str>, extra_args: Option<&str>) -> String {
let mut parts: Vec<String> = vec!["cargo".into(), subcommand.to_string()];
match package {
Some(p) => {
parts.push("-p".into());
parts.push(shell_quote(p));
}
None => {
parts.push("--workspace".into());
}
}
let user_overrides_format = extra_args
.map(|s| s.contains("--message-format"))
.unwrap_or(false);
if subcommand != "test" && !user_overrides_format {
parts.push("--message-format=json".into());
}
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 klasp_core::{CheckConfig, CheckSourceConfig, GitEvent, RepoState, Verdict};
use super::*;
fn cargo_check_config(subcommand: &str) -> CheckConfig {
CheckConfig {
name: "build".into(),
triggers: vec![],
source: CheckSourceConfig::Cargo {
subcommand: subcommand.into(),
extra_args: None,
package: 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_cargo() {
let source = CargoSource::new();
assert!(source.supports_config(&cargo_check_config("check")));
assert!(!source.supports_config(&shell_check()));
}
#[test]
fn build_command_workspace_default() {
let cmd = build_command("check", None, None);
assert_eq!(cmd, "cargo check --workspace --message-format=json");
}
#[test]
fn build_command_with_package_skips_workspace() {
let cmd = build_command("clippy", Some("klasp-core"), None);
assert_eq!(cmd, "cargo clippy -p 'klasp-core' --message-format=json");
}
#[test]
fn build_command_test_skips_message_format() {
let cmd = build_command("test", None, None);
assert_eq!(cmd, "cargo test --workspace");
}
#[test]
fn build_command_appends_extra_args_last() {
let cmd = build_command("clippy", None, Some("-- -D warnings"));
assert_eq!(
cmd,
"cargo clippy --workspace --message-format=json -- -D warnings"
);
}
#[test]
fn build_command_drops_blank_extra_args() {
let cmd = build_command("check", None, Some(" "));
assert_eq!(cmd, "cargo check --workspace --message-format=json");
}
#[test]
fn shell_quote_handles_embedded_single_quotes() {
assert_eq!(shell_quote("a'b"), "'a'\\''b'");
}
#[test]
fn unknown_subcommand_fails_without_running_cargo() {
let cfg = CheckConfig {
name: "build".into(),
triggers: vec![],
source: CheckSourceConfig::Cargo {
subcommand: "uninstall".into(),
extra_args: None,
package: None,
},
timeout_secs: None,
};
let state = RepoState {
root: std::env::temp_dir(),
git_event: GitEvent::Commit,
base_ref: "HEAD~1".into(),
staged_files: vec![],
};
let result = CargoSource::new()
.run(&cfg, &state)
.expect("unknown subcommand surfaces as Verdict::Fail, not a runtime error");
match result.verdict {
Verdict::Fail { message, .. } => {
assert!(message.contains("uninstall"));
assert!(message.contains("expected one of"));
}
other => panic!("expected Fail, got {other:?}"),
}
}
}