ryo-suggest 0.1.0

[experimental] Pattern-based suggestion engine for RYO
Documentation
//! SpecGroupInconsistency - Detects mixed Groups in same module
//!
//! This rule checks that all Spec TypeAliases in a module use the same Group.

use std::collections::HashMap;

use ryo_analysis::context::AnalysisContext;
use ryo_analysis::{SymbolId, SymbolKind, SymbolPath};

use super::SpecSuggest;
use crate::lint::{LintDetails, LintSuggest};
use crate::{
    LintSeverity, MutationSpec, OpportunityId, SafetyLevel, Suggest, SuggestCategory,
    SuggestLocation, SuggestOpportunity, SuggestResult,
};

/// SpecGroupInconsistency rule
///
/// Detects modules where Spec TypeAliases use different Groups.
///
/// # Rule Code
/// RS004 (Ryo Spec)
///
/// # Detection
/// 1. Group Spec TypeAliases by their parent module
/// 2. Extract the Group name from each Spec definition
/// 3. Report modules with multiple different Groups
///
/// # Example Violation
/// ```ignore
/// // src/domain/task.rs
/// type TaskSpec = Spec<DomainGroup, Task>;      // Uses DomainGroup
/// type UserSpec = Spec<OtherGroup, User>;       // Uses OtherGroup - inconsistent!
/// ```
///
/// # Fix
/// This is a report-only rule. Manual review is required to decide
/// which Group should be used consistently.
pub struct SpecGroupInconsistency {
    /// Suffix pattern to identify Spec TypeAliases (default: "Spec")
    spec_suffix: String,
}

impl SpecGroupInconsistency {
    pub fn new() -> Self {
        Self {
            spec_suffix: "Spec".to_string(),
        }
    }

    /// Create with custom suffix pattern
    pub fn with_suffix(suffix: impl Into<String>) -> Self {
        Self {
            spec_suffix: suffix.into(),
        }
    }

    /// Extract group name from a Spec TypeAlias using code graph dependencies
    ///
    /// Looks for types used by this symbol that might be Group markers.
    /// Returns the first type that looks like a Group (ends with "Group").
    fn extract_group_name(&self, ctx: &AnalysisContext, symbol_id: SymbolId) -> Option<String> {
        let typeflow = ctx.typeflow_graph();

        // Look for types used by this symbol (via TypeFlow)
        for used_id in typeflow.types_used_by(symbol_id) {
            if let Some(path) = ctx.registry.path(used_id) {
                let name = path.name();
                // Heuristic: Group types often end with "Group"
                if name.ends_with("Group") {
                    return Some(name.to_string());
                }
            }
        }

        None
    }
}

impl SpecSuggest for SpecGroupInconsistency {
    fn spec_suffix(&self) -> &str {
        &self.spec_suffix
    }
}

impl Default for SpecGroupInconsistency {
    fn default() -> Self {
        Self::new()
    }
}

impl Suggest for SpecGroupInconsistency {
    fn name(&self) -> &'static str {
        "spec-group-inconsistency"
    }

    fn description(&self) -> &str {
        "Detects modules where Spec TypeAliases use different Groups"
    }

    fn category(&self) -> SuggestCategory {
        SuggestCategory::Lint
    }

    fn safety_level(&self) -> SafetyLevel {
        SafetyLevel::Manual // Requires manual decision on which Group to use
    }

    fn priority_weight(&self) -> f32 {
        1.5 // Medium-high priority for consistency
    }

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

        // Collect Spec TypeAliases grouped by module
        // Map: module_path -> Vec<(symbol_id, alias_name, group_name)>
        let mut module_specs: HashMap<SymbolPath, Vec<(SymbolId, String, Option<String>)>> =
            HashMap::new();

        // If specific symbols provided, check only those
        let symbols_to_check: Vec<SymbolId> = if symbols.is_empty() {
            ctx.registry.iter_by_kind(SymbolKind::TypeAlias).collect()
        } else {
            symbols.to_vec()
        };

        for symbol_id in symbols_to_check {
            let path = match ctx.registry.path(symbol_id) {
                Some(p) => p,
                None => continue,
            };

            let alias_name = path.name();

            // Check if this looks like a Spec TypeAlias
            if !self.is_spec_alias(alias_name) {
                continue;
            }

            // Get module path
            let module_path = match self.get_module_path(path) {
                Some(mp) => mp,
                None => continue,
            };

            // Extract group name
            let group_name = self.extract_group_name(ctx, symbol_id);

            module_specs.entry(module_path).or_default().push((
                symbol_id,
                alias_name.to_string(),
                group_name,
            ));
        }

        // Check each module for group inconsistencies
        for (module_path, specs) in module_specs {
            // Skip modules with only one Spec
            if specs.len() < 2 {
                continue;
            }

            // Collect unique groups in this module
            let groups: Vec<_> = specs.iter().filter_map(|(_, _, g)| g.as_ref()).collect();

            // Check if there are multiple different groups
            let unique_groups: std::collections::HashSet<_> = groups.iter().collect();
            if unique_groups.len() <= 1 {
                continue; // All specs use the same group (or none have detectable groups)
            }

            // Found inconsistency - create opportunity for each inconsistent Spec
            // Get the most common group as the "expected" one
            let mut group_counts: HashMap<&String, usize> = HashMap::new();
            for g in &groups {
                *group_counts.entry(g).or_insert(0) += 1;
            }
            let expected_group = group_counts
                .iter()
                .max_by_key(|(_, count)| *count)
                .map(|(g, _)| (*g).clone());

            // Create opportunities for specs that don't match the expected group
            for (symbol_id, alias_name, group_name) in &specs {
                let spec_group = match group_name {
                    Some(g) => g,
                    None => continue,
                };

                if expected_group.as_ref() == Some(spec_group) {
                    continue; // This one matches the expected group
                }

                let Some(location) = SuggestLocation::from_context(ctx, *symbol_id) else {
                    continue;
                };

                let all_groups: Vec<_> = unique_groups.iter().map(|g| g.as_str()).collect();

                let opp = self.create_lint_opportunity(
                    OpportunityId::new(next_id),
                    vec![*symbol_id],
                    location,
                    format!(
                        "Module `{}` has mixed Spec Groups: `{}` uses `{}` but module also has `{}`",
                        module_path,
                        alias_name,
                        spec_group,
                        expected_group.as_deref().unwrap_or("unknown")
                    ),
                    LintDetails {
                        suggestion: Some(format!(
                            "Consider using `{}` for all Specs in this module",
                            expected_group.as_deref().unwrap_or("a consistent Group")
                        )),
                        expected: Some(format!(
                            "All Specs use `{}`",
                            expected_group.as_deref().unwrap_or("same Group")
                        )),
                        actual: Some(format!("Groups found: {}", all_groups.join(", "))),
                    },
                );

                opportunities.push(opp);
                next_id += 1;
            }
        }

        opportunities
    }

    fn to_mutation_specs(
        &self,
        _ctx: &AnalysisContext,
        _opportunity: &SuggestOpportunity,
    ) -> SuggestResult<Vec<MutationSpec>> {
        // Report-only rule - no automatic fix
        Ok(Vec::new())
    }
}

impl LintSuggest for SpecGroupInconsistency {
    fn code(&self) -> &'static str {
        "RS004"
    }

    fn default_severity(&self) -> LintSeverity {
        LintSeverity::Warning
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_is_spec_alias() {
        let rule = SpecGroupInconsistency::new();

        assert!(rule.is_spec_alias("TaskSpec"));
        assert!(rule.is_spec_alias("UserSpec"));
        assert!(!rule.is_spec_alias("Spec"));
        assert!(!rule.is_spec_alias("Task"));
    }

    #[test]
    fn test_custom_suffix() {
        let rule = SpecGroupInconsistency::with_suffix("Domain");

        assert!(rule.is_spec_alias("TaskDomain"));
        assert!(!rule.is_spec_alias("TaskSpec"));
    }
}