use ryo_analysis::context::AnalysisContext;
use ryo_analysis::{SymbolId, SymbolKind, SymbolPath};
use crate::{
LintSeverity, MutationSpec, OpportunityId, SafetyLevel, Suggest, SuggestCategory,
SuggestLocation, SuggestOpportunity, SuggestResult,
};
use super::{LintDetails, LintSuggest};
pub struct RequireTestForMutation;
impl RequireTestForMutation {
pub fn new() -> Self {
Self
}
fn is_mutation_impl(&self, path: &SymbolPath) -> bool {
let path_str = path.to_string();
path_str.contains("Mutation") && path_str.contains("impl_")
}
fn extract_mutation_name(&self, path: &SymbolPath) -> Option<String> {
let path_str = path.to_string();
if let Some(idx) = path_str.find("_for_") {
let after = &path_str[idx + 5..];
let name = after.split("::").next().unwrap_or(after);
if name.contains("Mutation") {
return Some(name.to_string());
}
}
for segment in path.segments() {
if segment.contains("Mutation") && !segment.starts_with("impl_") {
return Some(segment.to_string());
}
}
None
}
fn has_test_for_mutation(
&self,
ctx: &AnalysisContext,
mutation_name: &str,
mutation_file: &str,
) -> bool {
let snake_name = self.to_snake_case(mutation_name);
for (id, path) in ctx.registry.iter() {
if let Some(SymbolKind::Function) = ctx.registry.kind(id) {
let path_str = path.to_string();
let func_name = path.name();
let is_test = func_name.starts_with("test_")
|| path_str.contains("::tests::")
|| func_name.contains("_test");
if is_test {
let func_lower = func_name.to_lowercase();
let snake_lower = snake_name.to_lowercase();
let name_lower = mutation_name.to_lowercase();
if func_lower.contains(&snake_lower)
|| func_lower.contains(&name_lower.replace("mutation", ""))
{
if let Some(span) = ctx.registry.span(id) {
let test_file = span.file.to_string();
if test_file == mutation_file
|| test_file.contains(&mutation_file.replace(".rs", ""))
{
return true;
}
}
}
}
}
}
false
}
fn to_snake_case(&self, name: &str) -> String {
let mut result = String::new();
for (i, ch) in name.chars().enumerate() {
if ch.is_uppercase() {
if i > 0 {
result.push('_');
}
result.push(ch.to_ascii_lowercase());
} else {
result.push(ch);
}
}
result
}
}
impl Default for RequireTestForMutation {
fn default() -> Self {
Self::new()
}
}
impl Suggest for RequireTestForMutation {
fn name(&self) -> &'static str {
"require-test-for-mutation"
}
fn description(&self) -> &str {
"Ensures that every Mutation implementation has corresponding test coverage"
}
fn category(&self) -> SuggestCategory {
SuggestCategory::Lint
}
fn safety_level(&self) -> SafetyLevel {
SafetyLevel::Manual }
fn priority_weight(&self) -> f32 {
2.0 }
fn detect(&self, ctx: &AnalysisContext, symbols: &[SymbolId]) -> Vec<SuggestOpportunity> {
let mut opportunities = Vec::new();
let mut next_id = 0u32;
let symbols_to_check: Box<dyn Iterator<Item = SymbolId>> = if symbols.is_empty() {
Box::new(ctx.registry.iter_by_kind(SymbolKind::Impl))
} else {
Box::new(symbols.iter().copied())
};
for symbol_id in symbols_to_check {
let path = match ctx.registry.path(symbol_id) {
Some(p) => p,
None => continue,
};
if !self.is_mutation_impl(path) {
continue;
}
let mutation_name = match self.extract_mutation_name(path) {
Some(name) => name,
None => continue,
};
let Some(location) = SuggestLocation::from_context(ctx, symbol_id) else {
continue;
};
if !self.has_test_for_mutation(ctx, &mutation_name, &location.file) {
let opp = self.create_lint_opportunity(
OpportunityId::new(next_id),
vec![symbol_id],
location,
format!("Mutation `{}` has no test coverage", mutation_name),
LintDetails {
suggestion: Some(format!(
"Add test function like `test_{}` or `test_{}_apply`",
self.to_snake_case(&mutation_name),
self.to_snake_case(&mutation_name.replace("Mutation", ""))
)),
expected: Some(format!(
"#[test] fn test_{}*",
self.to_snake_case(&mutation_name)
)),
actual: Some("No test found".to_string()),
},
);
opportunities.push(opp);
next_id += 1;
}
}
opportunities
}
fn to_mutation_specs(
&self,
_ctx: &AnalysisContext,
_opportunity: &SuggestOpportunity,
) -> SuggestResult<Vec<MutationSpec>> {
Ok(Vec::new())
}
}
impl LintSuggest for RequireTestForMutation {
fn code(&self) -> &'static str {
"RL001"
}
fn default_severity(&self) -> LintSeverity {
LintSeverity::Error
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_to_snake_case() {
let rule = RequireTestForMutation::new();
assert_eq!(rule.to_snake_case("AddFieldMutation"), "add_field_mutation");
assert_eq!(rule.to_snake_case("RenameStruct"), "rename_struct");
assert_eq!(rule.to_snake_case("URL"), "u_r_l"); }
#[test]
fn test_extract_mutation_name() {
let rule = RequireTestForMutation::new();
let path =
SymbolPath::parse("ryo_mutations::basic::field::impl_Mutation_for_AddFieldMutation")
.unwrap();
assert_eq!(
rule.extract_mutation_name(&path),
Some("AddFieldMutation".to_string())
);
let path2 = SymbolPath::parse("ryo_mutations::basic::RemoveFieldMutation").unwrap();
assert_eq!(
rule.extract_mutation_name(&path2),
Some("RemoveFieldMutation".to_string())
);
}
#[test]
fn test_is_mutation_impl() {
let rule = RequireTestForMutation::new();
let mutation_impl =
SymbolPath::parse("ryo_mutations::basic::impl_Mutation_for_AddField").unwrap();
assert!(rule.is_mutation_impl(&mutation_impl));
let not_mutation = SymbolPath::parse("ryo_mutations::basic::AddField").unwrap();
assert!(!rule.is_mutation_impl(¬_mutation));
}
}