ryo-suggest 0.1.0

[experimental] Pattern-based suggestion engine for RYO
Documentation
//! RequireTestForMutation - Ensures mutation implementations have tests
//!
//! This rule checks that every `impl Mutation for X` has a corresponding test.

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};

/// RequireTestForMutation rule
///
/// Detects Mutation implementations without corresponding tests.
///
/// # Rule Code
/// RL001
///
/// # Detection
/// 1. Find all `impl Mutation for X` blocks
/// 2. Check if there's a test function for X (naming pattern: `test_*_mutation` or `*_mutation_*`)
///
/// # Example Violation
/// ```ignore
/// // src/basic/add_field.rs
/// pub struct AddFieldMutation { ... }
/// impl Mutation for AddFieldMutation { ... }
/// // ERROR: No test for AddFieldMutation
/// ```
pub struct RequireTestForMutation;

impl RequireTestForMutation {
    pub fn new() -> Self {
        Self
    }

    /// Check if a symbol path represents a Mutation impl
    fn is_mutation_impl(&self, path: &SymbolPath) -> bool {
        let path_str = path.to_string();
        // Pattern: ends with "::impl_Mutation" or contains "Mutation" in impl
        path_str.contains("Mutation") && path_str.contains("impl_")
    }

    /// Extract the mutation struct name from an impl path
    fn extract_mutation_name(&self, path: &SymbolPath) -> Option<String> {
        let path_str = path.to_string();
        // Pattern: "test_crate::module::MyMutation::impl_Mutation_for_MyMutation"
        // or "test_crate::module::impl_Mutation_for_MyMutation"

        // Look for pattern like "for_XxxMutation"
        if let Some(idx) = path_str.find("_for_") {
            let after = &path_str[idx + 5..];
            // Take until next "::" or end
            let name = after.split("::").next().unwrap_or(after);
            if name.contains("Mutation") {
                return Some(name.to_string());
            }
        }

        // Fallback: look for struct name containing "Mutation"
        for segment in path.segments() {
            if segment.contains("Mutation") && !segment.starts_with("impl_") {
                return Some(segment.to_string());
            }
        }

        None
    }

    /// Check if there's a test for a given mutation
    fn has_test_for_mutation(
        &self,
        ctx: &AnalysisContext,
        mutation_name: &str,
        mutation_file: &str,
    ) -> bool {
        // Normalize mutation name for pattern matching
        let snake_name = self.to_snake_case(mutation_name);

        // Check for test functions in the same file
        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();

                // Check if this is a test function (contains "test" in path or name)
                let is_test = func_name.starts_with("test_")
                    || path_str.contains("::tests::")
                    || func_name.contains("_test");

                if is_test {
                    // Check if the test is for this mutation
                    let func_lower = func_name.to_lowercase();
                    let snake_lower = snake_name.to_lowercase();
                    let name_lower = mutation_name.to_lowercase();

                    // Pattern matching:
                    // - test_add_field_mutation
                    // - test_add_field
                    // - add_field_mutation_test
                    if func_lower.contains(&snake_lower)
                        || func_lower.contains(&name_lower.replace("mutation", ""))
                    {
                        // Additional check: same file or tests module in same crate
                        if let Some(span) = ctx.registry.span(id) {
                            let test_file = span.file.to_string();
                            // Same file or tests submodule
                            if test_file == mutation_file
                                || test_file.contains(&mutation_file.replace(".rs", ""))
                            {
                                return true;
                            }
                        }
                    }
                }
            }
        }

        false
    }

    /// Convert CamelCase to snake_case
    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 // Tests must be written manually
    }

    fn priority_weight(&self) -> f32 {
        2.0 // High priority - test coverage is critical
    }

    fn detect(&self, ctx: &AnalysisContext, symbols: &[SymbolId]) -> Vec<SuggestOpportunity> {
        let mut opportunities = Vec::new();
        let mut next_id = 0u32;

        // If specific symbols provided, check only those
        let symbols_to_check: Box<dyn Iterator<Item = SymbolId>> = if symbols.is_empty() {
            // Check all Impl symbols
            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,
            };

            // Check if this is a Mutation impl
            if !self.is_mutation_impl(path) {
                continue;
            }

            // Extract mutation name
            let mutation_name = match self.extract_mutation_name(path) {
                Some(name) => name,
                None => continue,
            };

            // Get location using SymbolId/SymbolPath
            let Some(location) = SuggestLocation::from_context(ctx, symbol_id) else {
                continue;
            };

            // Check if test exists
            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>> {
        // Test generation would require AI assistance - return empty for now
        // Users should write tests manually
        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"); // Edge case
    }

    #[test]
    fn test_extract_mutation_name() {
        let rule = RequireTestForMutation::new();

        // Test pattern: impl_Mutation_for_X
        let path =
            SymbolPath::parse("ryo_mutations::basic::field::impl_Mutation_for_AddFieldMutation")
                .unwrap();
        assert_eq!(
            rule.extract_mutation_name(&path),
            Some("AddFieldMutation".to_string())
        );

        // Test pattern: struct in path
        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(&not_mutation));
    }
}